diff --git a/spec/RouteEngine.rollbackRenderState.test.js b/spec/RouteEngine.rollbackRenderState.test.js new file mode 100644 index 0000000..c8ee286 --- /dev/null +++ b/spec/RouteEngine.rollbackRenderState.test.js @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from "vitest"; +import createRouteEngine from "../src/RouteEngine.js"; +import createEffectsHandler from "../src/createEffectsHandler.js"; + +const createTicker = () => ({ + add: vi.fn(), + remove: vi.fn(), +}); + +const findElementById = (elements, id) => { + for (const element of elements || []) { + if (element?.id === id) { + return element; + } + + const nested = findElementById(element?.children, id); + if (nested) { + return nested; + } + } + + return null; +}; + +const createProjectData = () => ({ + screen: { + width: 1920, + height: 1080, + backgroundColor: "#000000", + }, + resources: { + layouts: { + revealDialogue: { + mode: "adv", + elements: [ + { + id: "dialogue-text", + type: "text-revealing", + content: "${dialogue.content}", + revealEffect: "typewriter", + displaySpeed: 30, + textStyleId: "body", + }, + ], + }, + layeredPanel: { + elements: [ + { + id: "panel-text", + type: "text", + content: "Layered panel", + textStyleId: "body", + }, + ], + transitions: [ + { + id: "panel-fade-in", + type: "update", + tween: { + alpha: { + initialValue: 0, + keyframes: [{ duration: 300, value: 1 }], + }, + }, + }, + ], + }, + }, + sounds: {}, + images: {}, + videos: {}, + sprites: {}, + characters: {}, + variables: {}, + transforms: {}, + sectionTransitions: {}, + animations: {}, + fonts: { + bodyFont: { + fileId: "Arial", + }, + }, + colors: { + bodyColor: { + hex: "#FFFFFF", + }, + }, + textStyles: { + body: { + fontId: "bodyFont", + colorId: "bodyColor", + fontSize: 24, + fontWeight: "400", + fontStyle: "normal", + lineHeight: 1.2, + }, + }, + controls: {}, + }, + story: { + initialSceneId: "scene1", + scenes: { + scene1: { + initialSectionId: "section1", + sections: { + section1: { + lines: [ + { + id: "line1", + actions: { + pushLayeredView: { + resourceId: "layeredPanel", + resourceType: "layout", + }, + dialogue: { + mode: "adv", + ui: { + resourceId: "revealDialogue", + }, + content: [ + { + text: "Line 1 should stay fully settled after rollback.", + }, + ], + }, + }, + }, + { + id: "line2", + actions: { + clearLayeredViews: {}, + dialogue: { + mode: "adv", + ui: { + resourceId: "revealDialogue", + }, + content: [ + { + text: "Line 2 exists so line 1 can be restored by rollback.", + }, + ], + }, + }, + }, + ], + }, + }, + }, + }, + }, +}); + +describe("RouteEngine rollback render state", () => { + it("restores rollbacked lines directly in their settled end state", () => { + const routeGraphics = { + render: vi.fn(), + }; + + let engine; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics, + ticker: createTicker(), + }); + + engine = createRouteEngine({ + handlePendingEffects: effectsHandler, + }); + + engine.init({ + initialState: { + projectData: createProjectData(), + }, + }); + + engine.handleActions({ + nextLine: {}, + }); + engine.handleActions({ + nextLine: {}, + }); + engine.handleAction("rollbackByOffset", { offset: -1 }); + + const rollbackRender = routeGraphics.render.mock.calls.at(-1)?.[0]; + + expect(engine.selectSystemState().contexts.at(-1).pointers.read.lineId).toBe( + "line1", + ); + expect(findElementById(rollbackRender.elements, "dialogue-text")).toMatchObject( + { + type: "text-revealing", + revealEffect: "none", + }, + ); + expect(findElementById(rollbackRender.elements, "panel-text")).toMatchObject( + { + type: "text", + content: "Layered panel", + }, + ); + expect(rollbackRender.animations).toEqual([]); + }); + + it("keeps layered view transitions when pushed after line completion", () => { + const routeGraphics = { + render: vi.fn(), + }; + + let engine; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics, + ticker: createTicker(), + }); + + engine = createRouteEngine({ + handlePendingEffects: effectsHandler, + }); + + engine.init({ + initialState: { + projectData: createProjectData(), + }, + }); + + engine.handleAction("markLineCompleted", {}); + engine.handleAction("clearLayeredViews", {}); + engine.handleAction("pushLayeredView", { + resourceId: "layeredPanel", + resourceType: "layout", + }); + + const overlayRender = routeGraphics.render.mock.calls.at(-1)?.[0]; + + expect(engine.selectSystemState().global.isLineCompleted).toBe(true); + expect(findElementById(overlayRender.elements, "panel-text")).toMatchObject({ + type: "text", + content: "Layered panel", + }); + expect(overlayRender.animations).toEqual([ + expect.objectContaining({ + id: "panel-fade-in", + targetId: "layeredView-0", + }), + ]); + }); +}); diff --git a/spec/system/renderState/addLayeredViews.spec.yaml b/spec/system/renderState/addLayeredViews.spec.yaml index b926dd5..3599026 100644 --- a/spec/system/renderState/addLayeredViews.spec.yaml +++ b/spec/system/renderState/addLayeredViews.spec.yaml @@ -451,6 +451,76 @@ out: easing: "linear" relative: true --- +case: keep layered view transitions when pushed after line completion +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + - resources: + layouts: + animatedLayout: + elements: + - type: "text" + content: "Animated" + transitions: + - id: "animated-layout-fade-in" + type: "update" + tween: + alpha: + initialValue: 0 + keyframes: + - duration: 500 + value: 1 + variables: {} + autoMode: false + skipMode: false + isLineCompleted: true + skipTransitionsAndAnimations: false + screen: + width: 1920 + height: 1080 + layeredViews: + - resourceId: animatedLayout + resourceType: layout +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + - id: "layeredView-0" + type: "container" + x: 0 + y: 0 + children: + - id: "layeredView-0-blocker" + type: "rect" + fill: "transparent" + width: 1920 + height: 1080 + x: 0 + y: 0 + click: + payload: + actions: {} + - type: "text" + content: "Animated" + animations: + - id: "animated-layout-fade-in" + type: "update" + targetId: "layeredView-0" + tween: + alpha: + initialValue: 0 + keyframes: + - duration: 500 + value: 1 +--- case: handle layeredView with nested children in: - elements: diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index baa6b6f..8b2da37 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -2065,12 +2065,14 @@ export const addLayeredViews = ( } if (Array.isArray(layout.transitions)) { - pushNormalizedLayoutTransitions({ - animations, - transitions: layout.transitions, - defaultTargetId: `layeredView-${index}`, - idPrefix: `layeredView-${index}`, - }); + if (!skipTransitionsAndAnimations) { + pushNormalizedLayoutTransitions({ + animations, + transitions: layout.transitions, + defaultTargetId: `layeredView-${index}`, + idPrefix: `layeredView-${index}`, + }); + } } // Create a container for this layeredView diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 9314c70..8d393a7 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -953,6 +953,23 @@ export const selectCurrentPageSlots = ( return { saveSlots: slots }; }; +const shouldSettleCurrentLinePresentation = (state) => { + const lastContext = state.contexts?.[state.contexts.length - 1]; + const rollback = lastContext?.rollback; + if ( + !rollback || + !Array.isArray(rollback.timeline) || + typeof rollback.currentIndex !== "number" + ) { + return false; + } + + return ( + rollback.currentIndex >= 0 && + rollback.currentIndex < rollback.timeline.length - 1 + ); +}; + export const selectRenderState = ({ state }) => { const presentationState = selectPresentationState({ state }); const previousPresentationState = selectPreviousPresentationState({ state }); @@ -963,6 +980,8 @@ export const selectRenderState = ({ state }) => { }; const { saveSlots } = selectCurrentPageSlots({ state }); + const settleCurrentLinePresentation = + shouldSettleCurrentLinePresentation(state); const renderState = constructRenderState({ presentationState, @@ -975,7 +994,9 @@ export const selectRenderState = ({ state }) => { canRollback: selectCanRollback({ state }), skipOnlyViewedLines: !allVariables._skipUnseenText, isLineCompleted: state.global.isLineCompleted, - skipTransitionsAndAnimations: !!allVariables._skipTransitionsAndAnimations, + skipTransitionsAndAnimations: + !!allVariables._skipTransitionsAndAnimations || + settleCurrentLinePresentation, layeredViews: state.global.layeredViews, dialogueHistory: selectDialogueHistory({ state }), saveSlots, diff --git a/vt/reference/rollback/reveal-complete--capture-01.webp b/vt/reference/rollback/reveal-complete--capture-01.webp new file mode 100644 index 0000000..5085af7 --- /dev/null +++ b/vt/reference/rollback/reveal-complete--capture-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49548b2c9f87599d20ec1d2ed96057473156790d6219bc554018c64e56fdf99b +size 3642 diff --git a/vt/specs/rollback/reveal-complete.yaml b/vt/specs/rollback/reveal-complete.yaml new file mode 100644 index 0000000..9ee6fe6 --- /dev/null +++ b/vt/specs/rollback/reveal-complete.yaml @@ -0,0 +1,188 @@ +--- +title: Rollback Reveal Complete +description: Minimal rollback sandbox for text-revealing dialogue. After rolling back, the previous line should already be fully revealed with no replayed text animation. +specs: + - starts on a text-revealing line + - provides ten text-revealing lines for manual forward/back testing + - rolls back one line + - the rolled-back line should render immediately in its completed state +skipInitialScreenshot: true +viewport: + id: capture + width: 960 + height: 540 +steps: + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 480 + y: 270 + - action: wait + ms: 80 + - action: click + x: 180 + y: 360 + - action: wait + ms: 50 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#000000" +resources: + characters: + narrator: + name: Guide + layouts: + stageLayout: + elements: + - id: stage + type: rect + width: 1920 + height: 1080 + colorId: bg + click: + payload: + actions: + nextLine: {} + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 100 + y: 760 + width: 1720 + height: 240 + colorId: panel + - id: dialogue-text + type: text-revealing + x: 140 + y: 835 + width: 1460 + content: ${dialogue.content} + revealEffect: typewriter + displaySpeed: 4 + textStyleId: textMain + - id: rollback-button + type: rect + x: 100 + y: 680 + width: 320 + height: 60 + colorId: button + click: + payload: + actions: + rollbackByOffset: + offset: -1 + - id: rollback-label + type: text + x: 130 + y: 700 + content: "[BACK 1 LINE]" + textStyleId: textButton + fonts: + fontDefault: + fileId: Arial + colors: + bg: + hex: "#000000" + panel: + hex: "#4D4D4D" + button: + hex: "#737373" + fg: + hex: "#FFFFFF" + textStyles: + textMain: + fontId: fontDefault + colorId: fg + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + textButton: + fontId: fontDefault + colorId: fg + fontSize: 26 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 +story: + initialSceneId: scene1 + scenes: + scene1: + initialSectionId: section1 + sections: + section1: + lines: + - id: line1 + actions: + background: + resourceId: stageLayout + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Line 1 should be fully visible immediately after rollback." + characterId: narrator + - id: line2 + actions: + dialogue: + content: + - text: "Line 2 is only here so rollback has a previous checkpoint and should still be mid-reveal when the rollback button is pressed." + characterId: narrator + - id: line3 + actions: + dialogue: + content: + - text: "Line 3 gives you another reveal checkpoint so you can keep advancing and verify that going back restores the previous line in its already-finished state." + characterId: narrator + - id: line4 + actions: + dialogue: + content: + - text: "Line 4 continues the long reveal sequence. Use the center of the screen to move forward and the back button to step backward one checkpoint at a time." + characterId: narrator + - id: line5 + actions: + dialogue: + content: + - text: "Line 5 exists for manual testing depth. The important behavior stays the same: rollback should settle on the prior line without replaying the reveal animation." + characterId: narrator + - id: line6 + actions: + dialogue: + content: + - text: "Line 6 keeps the reveal running long enough to make it obvious whether rollback is restoring the final state or incorrectly starting the text animation over." + characterId: narrator + - id: line7 + actions: + dialogue: + content: + - text: "Line 7 is another checkpoint in the same sequence. Advancing should still take two clicks on a revealing line, and rolling back should land on a completed prior line." + characterId: narrator + - id: line8 + actions: + dialogue: + content: + - text: "Line 8 makes the sandbox easier to walk through repeatedly while checking different rollback points near the middle and the end of the sequence." + characterId: narrator + - id: line9 + actions: + dialogue: + content: + - text: "Line 9 is here so you can test rollback near the end of the path and confirm the previous line returns as fully revealed instead of typing in again." + characterId: narrator + - id: line10 + actions: + dialogue: + content: + - text: "Line 10 is the final reveal line in this sandbox. From here, backing up should still show line 9 immediately in its settled end state." + characterId: narrator