Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions docs/AnimationModel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/RouteEngine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
}
}
58 changes: 58 additions & 0 deletions spec/RouteEngine.rollbackRenderState.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ const createProjectData = () => ({

const createPersistentBackgroundProjectData = ({
continuationLineCount = 1,
playbackSpeed,
} = {}) => ({
screen: {
width: 1920,
Expand Down Expand Up @@ -244,6 +245,9 @@ const createPersistentBackgroundProjectData = ({
resourceId: "fadeInLong",
playback: {
continuity: "persistent",
...(playbackSpeed === undefined
? {}
: { speed: playbackSpeed }),
},
},
},
Expand Down Expand Up @@ -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(),
Expand Down
183 changes: 183 additions & 0 deletions spec/constructRenderState.animationPlaybackSpeed.test.js
Original file line number Diff line number Diff line change
@@ -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.");
});
});
50 changes: 50 additions & 0 deletions spec/projectDataSchema.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ describe("projectData schema", () => {
resourceId: "fadeIn",
playback: {
continuity: "persistent",
speed: 2,
},
},
},
Expand All @@ -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({
Expand Down
Loading
Loading