diff --git a/bun.lock b/bun.lock index 5a596e1..efdf161 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ "ajv": "^8.18.0", "husky": "^9.1.7", "prettier": "^3.7.4", - "route-graphics": "1.17.1", + "route-graphics": "1.22.0", "vitest": "4.0.16", }, }, @@ -327,7 +327,7 @@ "rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="], - "route-graphics": ["route-graphics@1.17.1", "", { "dependencies": { "@pixi/unsafe-eval": "^7.4.3", "hotkeys-js": "^4.0.0-beta.7", "js-yaml": "^4.1.0", "pixi.js": "^8.7.1", "playwright": "^1.44.0" }, "bin": { "route-graphics": "bin/route-graphics.js" } }, "sha512-o7Dz1sqT1kGTWZEaEW90Ul89UfYJzeWiq6CpoGizvyqLCHWOqyA1KMgwn5odfGYglbcYFS8ZbbY+fcfaZNd8mQ=="], + "route-graphics": ["route-graphics@1.22.0", "", { "dependencies": { "@pixi/unsafe-eval": "^7.4.3", "hotkeys-js": "^4.0.0-beta.7", "js-yaml": "^4.1.0", "pixi.js": "^8.7.1", "playwright": "^1.44.0" }, "bin": { "route-graphics": "bin/route-graphics.js" } }, "sha512-zwMbJCo8ILHk+hjWVK1oYohhmD7IXfphSpy+SQEMeMaH5KgJvqDYgUxdnmFxbOjEMFybyjL2lbBCttnUBgMZfw=="], "semver": ["semver@7.6.3", "", { "bin": "bin/semver.js" }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], diff --git a/docs/AnimationModel.md b/docs/AnimationModel.md index 72d6e10..eaba7f2 100644 --- a/docs/AnimationModel.md +++ b/docs/AnimationModel.md @@ -119,6 +119,22 @@ In practice: - the selection stops when the background changes or a later background action replaces the animation selection +### Playback speed + +Any action-level animation selection may set: + +```yaml +animations: + resourceId: bg-dissolve + playback: + speed: 2 +``` + +`speed` is a unitless multiplier. `1` is authored speed, `2` is twice as fast, +and `0.5` is half speed. Keyframe `duration` values stay authored in +milliseconds; playback speed scales elapsed time at runtime and scales +persistent animation expiry by the same multiplier. + ### Same-subject transitions Because the comparison is done against resolved previous and next presentation diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index 275e145..12a4540 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -672,6 +672,11 @@ Actions that can be attached to lines to control presentation: | `form` | `{ resourceId, fields, submitActions?, cancelActions? }` | Display a blocking multi-input form | | `cleanAll` | `true` | Clear all presentation state | +Animation selections use `animations.resourceId` plus optional +`animations.playback`. `playback.speed` is a unitless multiplier: `1` is normal, +`2` is twice as fast, and `0.5` is half speed. `playback.continuity` defaults to +render-scoped behavior when omitted. + ### Visual Layers Visual items use a flat `items` array. Each item can set numeric `layer` to diff --git a/package.json b/package.json index e582780..1efcb54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "1.20.4", + "version": "1.21.0", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git", @@ -58,7 +58,7 @@ "ajv": "^8.18.0", "husky": "^9.1.7", "prettier": "^3.7.4", - "route-graphics": "1.17.1", + "route-graphics": "1.22.0", "vitest": "4.0.16" } } diff --git a/spec/RouteEngine.rollbackRenderState.test.js b/spec/RouteEngine.rollbackRenderState.test.js index 81488e6..81e5782 100644 --- a/spec/RouteEngine.rollbackRenderState.test.js +++ b/spec/RouteEngine.rollbackRenderState.test.js @@ -159,6 +159,7 @@ const createProjectData = () => ({ const createPersistentBackgroundProjectData = ({ continuationLineCount = 1, + playbackSpeed, } = {}) => ({ screen: { width: 1920, @@ -244,6 +245,9 @@ const createPersistentBackgroundProjectData = ({ resourceId: "fadeInLong", playback: { continuity: "persistent", + ...(playbackSpeed === undefined + ? {} + : { speed: playbackSpeed }), }, }, }, @@ -640,6 +644,60 @@ describe("RouteEngine rollback render state", () => { } }); + it("expires persistent background playback using playback speed", () => { + const routeGraphics = { + render: vi.fn(), + }; + + let engine; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics, + ticker: createTicker(), + persistence: createNoopPersistence(), + }); + + engine = createRouteEngine({ + handlePendingEffects: effectsHandler, + }); + + const nowSpy = vi.spyOn(Date, "now"); + + try { + nowSpy.mockReturnValue(0); + engine.init({ + initialState: { + projectData: createPersistentBackgroundProjectData({ + playbackSpeed: 2, + }), + }, + }); + + const initialRender = routeGraphics.render.mock.calls.at(-1)?.[0]; + expect(initialRender.animations).toEqual([ + expect.objectContaining({ + playback: { + continuity: "persistent", + speed: 2, + }, + }), + ]); + + nowSpy.mockReturnValue(5001); + expect( + effectsHandler.handleRouteGraphicsEvent("renderComplete", { + id: initialRender.id, + aborted: false, + }), + ).toBe(true); + + const completedRender = routeGraphics.render.mock.calls.at(-1)?.[0]; + expect(completedRender.animations).toEqual([]); + } finally { + nowSpy.mockRestore(); + } + }); + it("does not renew persistent background playback when commit crosses the expiry boundary", () => { const routeGraphics = { render: vi.fn(), diff --git a/spec/constructRenderState.animationPlaybackSpeed.test.js b/spec/constructRenderState.animationPlaybackSpeed.test.js new file mode 100644 index 0000000..eeb8f65 --- /dev/null +++ b/spec/constructRenderState.animationPlaybackSpeed.test.js @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { + constructRenderState, + getAnimationInstanceDurationMs, + getPersistentAnimationContinuationKey, +} from "../src/stores/constructRenderState.js"; + +const createResources = () => ({ + images: { + bg1: { + fileId: "bg.png", + width: 1920, + height: 1080, + }, + }, + animations: { + fadeIn: { + type: "transition", + next: { + tween: { + alpha: { + initialValue: 0, + keyframes: [{ duration: 1000, value: 1 }], + }, + }, + }, + }, + }, +}); + +describe("constructRenderState animation playback speed", () => { + it("passes action-level playback speed through animation instances", () => { + const renderState = constructRenderState({ + presentationState: { + background: { + resourceId: "bg1", + animations: { + resourceId: "fadeIn", + playback: { + speed: 2, + }, + }, + }, + }, + resources: createResources(), + }); + + expect(renderState.animations).toEqual([ + expect.objectContaining({ + id: "bg-cg-animation-in", + playback: { + speed: 2, + }, + }), + ]); + }); + + it("uses playback speed when calculating effective animation duration", () => { + expect( + getAnimationInstanceDurationMs({ + type: "update", + playback: { + speed: 2, + }, + tween: { + x: { + keyframes: [{ duration: 1000, value: 100 }], + }, + }, + }), + ).toBe(500); + + expect( + getAnimationInstanceDurationMs({ + type: "transition", + playback: { + speed: 0.5, + }, + next: { + tween: { + alpha: { + keyframes: [{ duration: 1000, value: 1 }], + }, + }, + }, + }), + ).toBe(2000); + }); + + it("uses speed in persistent continuation keys but ignores continuity", () => { + const baseAnimation = { + id: "bg-cg-animation-transition", + targetId: "bg-cg-background-sprite", + type: "transition", + playback: { + continuity: "persistent", + speed: 1, + }, + next: { + tween: { + alpha: { + keyframes: [{ duration: 1000, value: 1 }], + }, + }, + }, + }; + + expect(getPersistentAnimationContinuationKey(baseAnimation)).toBe( + getPersistentAnimationContinuationKey({ + ...baseAnimation, + playback: { + continuity: "render", + speed: 1, + }, + }), + ); + + expect( + getPersistentAnimationContinuationKey({ + ...baseAnimation, + playback: { + continuity: "persistent", + }, + }), + ).toBe( + getPersistentAnimationContinuationKey({ + ...baseAnimation, + playback: { + continuity: "persistent", + speed: 1, + }, + }), + ); + + expect(getPersistentAnimationContinuationKey(baseAnimation)).not.toBe( + getPersistentAnimationContinuationKey({ + ...baseAnimation, + playback: { + continuity: "persistent", + speed: 2, + }, + }), + ); + }); + + it("rejects invalid playback speeds before creating render-state animations", () => { + expect(() => + constructRenderState({ + presentationState: { + background: { + resourceId: "bg1", + animations: { + resourceId: "fadeIn", + playback: { + speed: 0, + }, + }, + }, + }, + resources: createResources(), + }), + ).toThrow( + "[background.animations.playback] playback.speed must be a finite number greater than 0.", + ); + }); + + it("rejects non-object playback before creating render-state animations", () => { + expect(() => + constructRenderState({ + presentationState: { + background: { + resourceId: "bg1", + animations: { + resourceId: "fadeIn", + playback: null, + }, + }, + }, + resources: createResources(), + }), + ).toThrow("[background.animations.playback] playback must be an object."); + }); +}); diff --git a/spec/projectDataSchema.test.js b/spec/projectDataSchema.test.js index b45d7bf..80a5a68 100644 --- a/spec/projectDataSchema.test.js +++ b/spec/projectDataSchema.test.js @@ -529,6 +529,7 @@ describe("projectData schema", () => { resourceId: "fadeIn", playback: { continuity: "persistent", + speed: 2, }, }, }, @@ -537,6 +538,55 @@ describe("projectData schema", () => { expect(validatePresentationActions.errors).toBeNull(); }); + it("accepts animation playback speed without explicit continuity", () => { + expect( + validatePresentationActions({ + character: { + items: [ + { + id: "hero", + animations: { + resourceId: "slide", + playback: { + speed: 0.5, + }, + }, + }, + ], + }, + }), + ).toBe(true); + expect(validatePresentationActions.errors).toBeNull(); + }); + + it("rejects invalid animation playback speed in presentation actions", () => { + expect( + validatePresentationActions({ + visual: { + items: [ + { + id: "burst", + animations: { + resourceId: "pulse", + playback: { + speed: 0, + }, + }, + }, + ], + }, + }), + ).toBe(false); + expect(validatePresentationActions.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + instancePath: "/visual/items/0/animations/playback/speed", + keyword: "exclusiveMinimum", + }), + ]), + ); + }); + it("accepts whole-screen animation selections in presentation actions", () => { expect( validatePresentationActions({ diff --git a/src/schemas/presentationActions.yaml b/src/schemas/presentationActions.yaml index ac69855..d0ca1c9 100644 --- a/src/schemas/presentationActions.yaml +++ b/src/schemas/presentationActions.yaml @@ -10,8 +10,12 @@ definitions: continuity: type: string enum: [render, persistent] + default: render description: Whether animation continuity is render-scoped or persists across later renders while the action remains active - required: [continuity] + speed: + type: number + exclusiveMinimum: 0 + description: Playback speed multiplier. 1 is authored speed, 2 is twice as fast, 0.5 is half speed. additionalProperties: false animationRef: diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 96811ac..8ed7a1c 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -141,8 +141,14 @@ const createAnimationInstance = ({ delete normalized.playback; normalized.id = id; normalized.targetId = targetId; - if (playback) { - normalized.playback = structuredClone(playback); + if (playback !== undefined) { + const normalizedPlayback = normalizeAnimationPlayback( + playback, + `${animationPath}.playback`, + ); + if (normalizedPlayback !== undefined) { + normalized.playback = normalizedPlayback; + } } return normalized; }; @@ -196,6 +202,59 @@ const resolveAnimationPlayback = (animationDef) => { return animationDef.playback; }; +const normalizeAnimationPlayback = ( + playback, + animationPath = "animation.playback", +) => { + if (playback === undefined) { + return undefined; + } + + if (!playback || typeof playback !== "object" || Array.isArray(playback)) { + throw new Error(`[${animationPath}] playback must be an object.`); + } + + if ( + playback.continuity !== undefined && + playback.continuity !== "render" && + playback.continuity !== "persistent" + ) { + throw new Error( + `[${animationPath}] playback.continuity must be "render" or "persistent".`, + ); + } + + if ( + playback.speed !== undefined && + (typeof playback.speed !== "number" || + !Number.isFinite(playback.speed) || + playback.speed <= 0) + ) { + throw new Error( + `[${animationPath}] playback.speed must be a finite number greater than 0.`, + ); + } + + const normalized = {}; + + if (playback.continuity !== undefined) { + normalized.continuity = playback.continuity; + } + + if (playback.speed !== undefined && playback.speed !== 1) { + normalized.speed = playback.speed; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +}; + +const getAnimationPlaybackSpeed = (animationInstance) => { + const speed = animationInstance?.playback?.speed; + return typeof speed === "number" && Number.isFinite(speed) && speed > 0 + ? speed + : 1; +}; + const hasOwnProperty = (value, key) => Object.prototype.hasOwnProperty.call(value, key); @@ -229,7 +288,15 @@ export const getPersistentAnimationContinuationKey = (animationInstance) => { } const normalized = structuredClone(animationInstance); - delete normalized.playback; + if (normalized.playback) { + delete normalized.playback.continuity; + if (normalized.playback.speed === 1) { + delete normalized.playback.speed; + } + if (Object.keys(normalized.playback).length === 0) { + delete normalized.playback; + } + } return JSON.stringify(sortObjectKeysDeep(normalized)); }; @@ -274,12 +341,13 @@ export const getAnimationInstanceDurationMs = (animationInstance) => { return 0; } - return Math.max( + const authoredDurationMs = Math.max( getTweenDurationMs(animationInstance.tween), getTweenDurationMs(animationInstance.prev?.tween), getTweenDurationMs(animationInstance.next?.tween), getTweenPropertyDurationMs(animationInstance.mask?.progress), ); + return authoredDurationMs / getAnimationPlaybackSpeed(animationInstance); }; const hasPersistentAnimationContinuation = ({ diff --git a/vt/reference/form/basic--capture-03.webp b/vt/reference/form/basic--capture-03.webp index a5f1f0f..f28da8b 100644 --- a/vt/reference/form/basic--capture-03.webp +++ b/vt/reference/form/basic--capture-03.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f07bb93124e3d2f85d2a5c45f3582036c7b78f303ab19f61626a6b4dac88db8f -size 4184 +oid sha256:d6c029677a5ec84505de2704e6e849e41c7ccc3ba62959abbfb01e24e265ed61 +size 4378 diff --git a/vt/reference/visual/playback-speed-01.webp b/vt/reference/visual/playback-speed-01.webp new file mode 100644 index 0000000..6a49f50 --- /dev/null +++ b/vt/reference/visual/playback-speed-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56c195a05f3be623af00fc5c6ad4682d2cf933378cc85b28c81625d3fe88f6a6 +size 2098 diff --git a/vt/specs/visual/playback-speed.yaml b/vt/specs/visual/playback-speed.yaml new file mode 100644 index 0000000..408287f --- /dev/null +++ b/vt/specs/visual/playback-speed.yaml @@ -0,0 +1,81 @@ +--- +title: Visual Playback Speed +skipInitialScreenshot: true +description: | + Visual update animation playback speed should scale elapsed time without + duplicating the reusable animation resource. + + - the top visual uses speed 2 and should reach the end after 500ms + - the bottom visual uses speed 0.5 and should only be a quarter through after 500ms +steps: + - action: wait + ms: 80 + - action: customEvent + name: snapShotKeyFrame + detail: + deltaMS: 500 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 +resources: + images: + marker: + fileId: a32kf3 + width: 100 + height: 100 + transforms: + topStart: + x: 300 + y: 360 + anchorX: 0.5 + anchorY: 0.5 + rotation: 0 + scaleX: 1 + scaleY: 1 + bottomStart: + x: 300 + y: 720 + anchorX: 0.5 + anchorY: 0.5 + rotation: 0 + scaleX: 1 + scaleY: 1 + animations: + slideRight: + type: update + tween: + x: + initialValue: 300 + keyframes: + - duration: 1000 + value: 1300 + easing: linear +story: + initialSceneId: speedScene + scenes: + speedScene: + name: Speed Scene + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + visual: + items: + - id: fast-marker + resourceId: marker + transformId: topStart + animations: + resourceId: slideRight + playback: + speed: 2 + - id: slow-marker + resourceId: marker + transformId: bottomStart + animations: + resourceId: slideRight + playback: + speed: 0.5