diff --git a/package.json b/package.json index e70c6d9a..9ea15d26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "0.7.3", + "version": "0.7.4", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git", diff --git a/spec/RouteEngine.lineCompletion.test.js b/spec/RouteEngine.lineCompletion.test.js index 78757140..8fb223a7 100644 --- a/spec/RouteEngine.lineCompletion.test.js +++ b/spec/RouteEngine.lineCompletion.test.js @@ -135,6 +135,174 @@ const createProjectData = () => ({ }, }); +const createChoiceBlockingProjectData = () => ({ + screen: { + width: 1920, + height: 1080, + backgroundColor: "#000000", + }, + resources: { + layouts: { + staticDialogue: { + mode: "adv", + elements: [ + { + id: "dialogue-text", + type: "text", + content: "${dialogue.content[0].text}", + textStyleId: "body", + }, + ], + }, + choiceLayout: { + elements: [ + { + id: "choice-button", + type: "button", + text: "Continue", + click: { + payload: { + actions: { + nextLine: {}, + }, + }, + }, + }, + ], + }, + }, + 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: { + startAutoMode: {}, + setNextLineConfig: { + manual: { + enabled: true, + }, + auto: { + enabled: true, + trigger: "fromStart", + delay: 900, + }, + applyMode: "persistent", + }, + dialogue: { + mode: "adv", + ui: { + resourceId: "staticDialogue", + }, + content: [ + { + text: "Line 1", + }, + ], + }, + }, + }, + { + id: "line2", + actions: { + dialogue: { + mode: "adv", + ui: { + resourceId: "staticDialogue", + }, + content: [ + { + text: "Make a choice", + }, + ], + }, + choice: { + resourceId: "choiceLayout", + items: [ + { + id: "choice-a", + content: "Continue", + }, + ], + }, + }, + }, + { + id: "line3", + actions: { + dialogue: { + mode: "adv", + ui: { + resourceId: "staticDialogue", + }, + content: [ + { + text: "After choice", + }, + ], + }, + }, + }, + { + id: "line4", + actions: { + dialogue: { + mode: "adv", + ui: { + resourceId: "staticDialogue", + }, + content: [ + { + text: "After line 3", + }, + ], + }, + }, + }, + ], + }, + }, + }, + }, + }, +}); + const findElementById = (elements, id) => { for (const element of elements || []) { if (element?.id === id) { @@ -178,24 +346,24 @@ describe("RouteEngine line completion flow", () => { }); const initialRender = getRenderState(routeGraphics, 0); - expect(findElementById(initialRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "typewriter", - }, - ); + expect( + findElementById(initialRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "typewriter", + }); engine.handleActions({ nextLine: {}, }); const completedRender = getRenderState(routeGraphics, 1); - expect(findElementById(completedRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "none", - }, - ); + expect( + findElementById(completedRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "none", + }); engine.handleActions({ nextLine: {}, @@ -203,20 +371,20 @@ describe("RouteEngine line completion flow", () => { const advancedRender = getLastRenderState(routeGraphics); expect(routeGraphics.render).toHaveBeenCalledTimes(3); - expect(engine.selectSystemState().contexts.at(-1).pointers.read.lineId).toBe( - "line2", - ); - expect(findElementById(advancedRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "typewriter", - content: [ - { - text: "Line 2 should be the next reveal line after the second click.", - }, - ], - }, - ); + expect( + engine.selectSystemState().contexts.at(-1).pointers.read.lineId, + ).toBe("line2"); + expect( + findElementById(advancedRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "typewriter", + content: [ + { + text: "Line 2 should be the next reveal line after the second click.", + }, + ], + }); }); it("advances immediately after a natural renderComplete marks the line complete", () => { @@ -249,32 +417,32 @@ describe("RouteEngine line completion flow", () => { ).toBe(true); const completedRender = getRenderState(routeGraphics, 1); - expect(findElementById(completedRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "none", - }, - ); + expect( + findElementById(completedRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "none", + }); engine.handleActions({ nextLine: {}, }); const advancedRender = getLastRenderState(routeGraphics); - expect(engine.selectSystemState().contexts.at(-1).pointers.read.lineId).toBe( - "line2", - ); - expect(findElementById(advancedRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "typewriter", - content: [ - { - text: "Line 2 should be the next reveal line after the second click.", - }, - ], - }, - ); + expect( + engine.selectSystemState().contexts.at(-1).pointers.read.lineId, + ).toBe("line2"); + expect( + findElementById(advancedRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "typewriter", + content: [ + { + text: "Line 2 should be the next reveal line after the second click.", + }, + ], + }); expect( effectsHandler.handleRouteGraphicsEvent("renderComplete", { @@ -284,31 +452,100 @@ describe("RouteEngine line completion flow", () => { ).toBe(true); const line2CompletedRender = getLastRenderState(routeGraphics); - expect(findElementById(line2CompletedRender.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "none", - }, - ); + expect( + findElementById(line2CompletedRender.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "none", + }); engine.handleActions({ nextLine: {}, }); const line3Render = getLastRenderState(routeGraphics); - expect(engine.selectSystemState().contexts.at(-1).pointers.read.lineId).toBe( - "line3", - ); - expect(findElementById(line3Render.elements, "dialogue-text")).toMatchObject( - { - type: "text-revealing", - revealEffect: "typewriter", - content: [ - { - text: "Line 3 should be reached with one click after line 2 completes naturally.", - }, - ], + expect( + engine.selectSystemState().contexts.at(-1).pointers.read.lineId, + ).toBe("line3"); + expect( + findElementById(line3Render.elements, "dialogue-text"), + ).toMatchObject({ + type: "text-revealing", + revealEffect: "typewriter", + content: [ + { + text: "Line 3 should be reached with one click after line 2 completes naturally.", + }, + ], + }); + }); + + it("stops active playback on a choice line and only allows choice-tagged nextLine", () => { + const routeGraphics = { + render: vi.fn(), + }; + let engine; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics, + ticker: createTicker(), + }); + engine = createRouteEngine({ + handlePendingEffects: effectsHandler, + }); + + engine.init({ + initialState: { + projectData: createChoiceBlockingProjectData(), + }, + }); + + expect(engine.selectSystemState().global.autoMode).toBe(true); + expect(engine.selectSystemState().global.nextLineConfig.auto).toEqual({ + enabled: true, + trigger: "fromStart", + delay: 900, + }); + + engine.handleAction("markLineCompleted", {}); + engine.handleActions({ + nextLine: {}, + }); + + let state = engine.selectSystemState(); + expect(state.contexts.at(-1).pointers.read.lineId).toBe("line2"); + expect(state.global.autoMode).toBe(false); + expect(state.global.skipMode).toBe(false); + expect(state.global.nextLineConfig.auto).toEqual({ + enabled: true, + trigger: "fromStart", + delay: 900, + }); + + engine.handleActions({ + nextLine: {}, + }); + + state = engine.selectSystemState(); + expect(state.contexts.at(-1).pointers.read.lineId).toBe("line2"); + + engine.handleAction("markLineCompleted", {}); + engine.handleActions({ + nextLine: { + _interactionSource: "choice", }, - ); + }); + + state = engine.selectSystemState(); + expect(state.contexts.at(-1).pointers.read.lineId).toBe("line3"); + expect(engine.selectPresentationState().choice).toBeUndefined(); + + engine.handleAction("markLineCompleted", {}); + engine.handleActions({ + nextLine: {}, + }); + + state = engine.selectSystemState(); + expect(state.contexts.at(-1).pointers.read.lineId).toBe("line4"); }); }); diff --git a/spec/createEffectsHandler.routeGraphicsEvents.test.js b/spec/createEffectsHandler.routeGraphicsEvents.test.js index e02aea27..65867db5 100644 --- a/spec/createEffectsHandler.routeGraphicsEvents.test.js +++ b/spec/createEffectsHandler.routeGraphicsEvents.test.js @@ -91,6 +91,7 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { selectRenderState: vi.fn(() => ({ id: "render-1" })), handleAction: vi.fn(), handleActions: vi.fn(), + selectIsChoiceVisible: vi.fn(() => false), }; const effectsHandler = createEffectsHandler({ getEngine: () => engine, @@ -130,6 +131,7 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { selectRenderState: vi.fn(() => ({ id: "render-1" })), handleAction: vi.fn(), handleActions: vi.fn(), + selectIsChoiceVisible: vi.fn(() => false), }; const effectsHandler = createEffectsHandler({ getEngine: () => engine, @@ -180,6 +182,99 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { ); }); + it("does not forward non-choice actions while a choice is visible", async () => { + const engine = { + selectRenderState: vi.fn(() => ({ id: "render-1" })), + handleAction: vi.fn(), + handleActions: vi.fn(), + selectIsChoiceVisible: vi.fn(() => true), + }; + const preprocessPayload = vi.fn(); + const onEvent = vi.fn(); + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics: { + render: vi.fn(), + }, + ticker: createTicker(), + }); + + const payload = { + actions: { + updateVariable: { + id: "blocked", + operations: [ + { + variableId: "marker", + op: "set", + value: "blocked", + }, + ], + }, + }, + _event: { + x: 10, + y: 20, + }, + }; + + const eventHandler = effectsHandler.createRouteGraphicsEventHandler({ + preprocessPayload, + onEvent, + }); + + await eventHandler("click", payload); + + expect(preprocessPayload).not.toHaveBeenCalled(); + expect(engine.handleActions).not.toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith("click", payload); + }); + + it("still forwards choice-tagged actions while a choice is visible", async () => { + const engine = { + selectRenderState: vi.fn(() => ({ id: "render-1" })), + handleAction: vi.fn(), + handleActions: vi.fn(), + selectIsChoiceVisible: vi.fn(() => true), + }; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics: { + render: vi.fn(), + }, + ticker: createTicker(), + }); + + const eventHandler = effectsHandler.createRouteGraphicsEventHandler(); + + await eventHandler("click", { + _interactionSource: "choice", + actions: { + sectionTransition: { + sectionId: "next-section", + }, + }, + _event: { + x: 10, + y: 20, + }, + }); + + expect(engine.handleActions).toHaveBeenCalledWith( + { + sectionTransition: { + sectionId: "next-section", + }, + }, + { + _event: { + x: 10, + y: 20, + }, + }, + ); + }); + it("coalesces replaceable effects by name and keeps the last payload", () => { const ticker = createTicker(); const engine = { diff --git a/spec/system/actions/jumpToLine.spec.yaml b/spec/system/actions/jumpToLine.spec.yaml index e978f880..c3c16824 100644 --- a/spec/system/actions/jumpToLine.spec.yaml +++ b/spec/system/actions/jumpToLine.spec.yaml @@ -110,6 +110,79 @@ out: pendingEffects: - name: handleLineActions --- +case: jump to a choice line stops active playback and clears timers +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: line1 + text: "First line" + - id: line2 + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - pointers: + read: + sectionId: section1 + lineId: line1 + global: + isLineCompleted: true + autoMode: true + skipMode: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: [] + - + lineId: line2 +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: line1 + text: "First line" + - id: line2 + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - pointers: + read: + sectionId: section1 + lineId: line2 + global: + isLineCompleted: false + autoMode: false + skipMode: false + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: + - name: clearAutoNextTimer + - name: clearSkipNextTimer + - name: clearNextLineConfigTimer + - name: handleLineActions +--- case: return unchanged state when lineId is missing in: - state: @@ -319,4 +392,4 @@ out: pendingEffects: - name: "existingEffect" data: "test" - - name: handleLineActions \ No newline at end of file + - name: handleLineActions diff --git a/spec/system/actions/markLineCompleted.spec.yaml b/spec/system/actions/markLineCompleted.spec.yaml index c0733a21..bd94bb07 100644 --- a/spec/system/actions/markLineCompleted.spec.yaml +++ b/spec/system/actions/markLineCompleted.spec.yaml @@ -211,3 +211,77 @@ out: delay: 1500 pendingEffects: - name: render +--- +case: mark line as completed with a visible choice does not start playback timers +in: + - state: + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + isLineCompleted: false + autoMode: true + variables: + _autoForwardTime: 1000 + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromComplete" + delay: 2000 + viewedRegistry: + sections: [] + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] +out: + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + global: + isLineCompleted: true + autoMode: true + variables: + _autoForwardTime: 1000 + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromComplete" + delay: 2000 + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" + pendingEffects: + - name: render diff --git a/spec/system/actions/nextLine.spec.yaml b/spec/system/actions/nextLine.spec.yaml index 1713cd16..95558d3b 100644 --- a/spec/system/actions/nextLine.spec.yaml +++ b/spec/system/actions/nextLine.spec.yaml @@ -278,6 +278,93 @@ out: lineId: "2" rollbackPolicy: "free" --- +case: move to a choice line stops auto playback and clears the scene timer +in: + - state: + global: + isLineCompleted: true + autoMode: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 900 + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: {} + - id: "2" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + global: + isLineCompleted: false + autoMode: false + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 900 + pendingEffects: + - name: "clearAutoNextTimer" + - name: "clearNextLineConfigTimer" + - name: "handleLineActions" + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: {} + - id: "2" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" +--- case: move to next line resets singleLine applyMode config in: - state: @@ -506,6 +593,290 @@ out: sectionId: "section1" lineId: "2" --- +case: complete a visible choice line in place before it can advance +in: + - state: + global: + isLineCompleted: false + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + global: + isLineCompleted: false + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +--- +case: do not advance an untagged next line on a completed choice line +in: + - state: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + - {} +out: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +--- +case: allow tagged next line on a completed choice line +in: + - state: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + - _interactionSource: "choice" +out: + global: + isLineCompleted: false + nextLineConfig: + manual: + enabled: true + pendingEffects: + - name: "handleLineActions" + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" +--- +case: allow next line on a later line when the previous choice is no longer in presentation +in: + - state: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + pendingEffects: [] + viewedRegistry: + sections: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: + dialogue: + content: + - text: "after choice" + - id: "3" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "2" +out: + global: + isLineCompleted: false + nextLineConfig: + manual: + enabled: true + pendingEffects: + - name: "handleLineActions" + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "2" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "2" + actions: + dialogue: + content: + - text: "after choice" + - id: "3" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "3" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + timeline: + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "3" + rollbackPolicy: "free" +--- case: no section found in: - state: diff --git a/spec/system/actions/nextLineFromSystem.spec.yaml b/spec/system/actions/nextLineFromSystem.spec.yaml index f87b8b71..9aedc90f 100644 --- a/spec/system/actions/nextLineFromSystem.spec.yaml +++ b/spec/system/actions/nextLineFromSystem.spec.yaml @@ -190,6 +190,94 @@ out: lineId: "2" rollbackPolicy: "free" --- +case: advance to a choice line stops active playback and clears timers +in: + - state: + global: + isLineCompleted: true + autoMode: true + skipMode: true + variables: + _skipUnseenText: true + viewedRegistry: + sections: [] + nextLineConfig: + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + - id: "2" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + global: + isLineCompleted: false + autoMode: false + skipMode: false + variables: + _skipUnseenText: true + viewedRegistry: + sections: + - sectionId: "section1" + lastLineId: "1" + nextLineConfig: + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + pendingEffects: + - name: "clearAutoNextTimer" + - name: "clearSkipNextTimer" + - name: "clearNextLineConfigTimer" + - name: "handleLineActions" + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + - id: "2" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "2" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section1" + lineId: "2" + rollbackPolicy: "free" +--- case: advance resets singleLine applyMode config and does not re-queue timer in: - state: diff --git a/spec/system/actions/sectionTransition.spec.yaml b/spec/system/actions/sectionTransition.spec.yaml index 899dce0e..b704bd79 100644 --- a/spec/system/actions/sectionTransition.spec.yaml +++ b/spec/system/actions/sectionTransition.spec.yaml @@ -81,6 +81,103 @@ out: lineId: "10" rollbackPolicy: "free" --- +case: transition to a choice line stops playback and clears the scene timer +in: + - state: + global: + isLineCompleted: true + autoMode: true + skipMode: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + isDialogueUIVisible: true + pendingEffects: [] + projectData: + story: + initialSceneId: "scene1" + scenes: + scene1: + initialSectionId: "section1" + sections: + section1: + lines: + - id: "1" + actions: {} + section2: + lines: + - id: "10" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + - sectionId: "section2" +out: + global: + isLineCompleted: false + autoMode: false + skipMode: false + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromStart" + delay: 1500 + isDialogueUIVisible: true + pendingEffects: + - name: "clearAutoNextTimer" + - name: "render" + - name: "clearSkipNextTimer" + - name: "render" + - name: "clearNextLineConfigTimer" + - name: "handleLineActions" + projectData: + story: + initialSceneId: "scene1" + scenes: + scene1: + initialSectionId: "section1" + sections: + section1: + lines: + - id: "1" + actions: {} + section2: + lines: + - id: "10" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section2" + lineId: "10" + rollback: + currentIndex: 1 + isRestoring: false + replayStartIndex: 1 + timeline: + - sectionId: "section1" + lineId: "1" + rollbackPolicy: "free" + - sectionId: "section2" + lineId: "10" + rollbackPolicy: "free" +--- case: section not found - returns unchanged state in: - state: diff --git a/spec/system/actions/setNextLineConfig.spec.yaml b/spec/system/actions/setNextLineConfig.spec.yaml index 97e6d94c..25ddd9bd 100644 --- a/spec/system/actions/setNextLineConfig.spec.yaml +++ b/spec/system/actions/setNextLineConfig.spec.yaml @@ -330,6 +330,70 @@ out: delay: 2000 - name: "render" --- +case: enable auto does not start timer when a choice is visible +in: + - state: + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: false + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + - auto: + enabled: true + trigger: "fromComplete" + delay: 2000 +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + isLineCompleted: true + nextLineConfig: + manual: + enabled: true + auto: + enabled: true + trigger: "fromComplete" + delay: 2000 + pendingEffects: + - name: "render" +--- case: disable auto clears timer in: - state: diff --git a/spec/system/actions/startAutoMode.spec.yaml b/spec/system/actions/startAutoMode.spec.yaml index c7ec0c61..a6ac1c61 100644 --- a/spec/system/actions/startAutoMode.spec.yaml +++ b/spec/system/actions/startAutoMode.spec.yaml @@ -106,3 +106,56 @@ out: payload: delay: 1000 - name: render +--- +case: do not start auto mode when a choice is visible +in: + - state: + global: + autoMode: false + isLineCompleted: true + pendingEffects: [] + variables: + _autoForwardTime: 1000 + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + autoMode: false + isLineCompleted: true + variables: + _autoForwardTime: 1000 + pendingEffects: [] diff --git a/spec/system/actions/startSkipMode.spec.yaml b/spec/system/actions/startSkipMode.spec.yaml index c2e602f6..c9983407 100644 --- a/spec/system/actions/startSkipMode.spec.yaml +++ b/spec/system/actions/startSkipMode.spec.yaml @@ -91,4 +91,53 @@ out: - name: clearAutoNextTimer - name: clearSkipNextTimer - name: startSkipNextTimer - - name: render \ No newline at end of file + - name: render +--- +case: do not start skip mode when a choice is visible +in: + - state: + global: + skipMode: false + autoMode: false + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + skipMode: false + autoMode: false + pendingEffects: [] diff --git a/spec/system/actions/toggleAutoMode.spec.yaml b/spec/system/actions/toggleAutoMode.spec.yaml index 1d6348c5..8a437315 100644 --- a/spec/system/actions/toggleAutoMode.spec.yaml +++ b/spec/system/actions/toggleAutoMode.spec.yaml @@ -195,3 +195,56 @@ out: payload: delay: 1000 - name: render +--- +case: do not toggle auto mode on when a choice is visible +in: + - state: + global: + autoMode: false + isLineCompleted: true + pendingEffects: [] + variables: + _autoForwardTime: 1000 + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + autoMode: false + isLineCompleted: true + variables: + _autoForwardTime: 1000 + pendingEffects: [] diff --git a/spec/system/actions/toggleSkipMode.spec.yaml b/spec/system/actions/toggleSkipMode.spec.yaml index 603721da..f564eed2 100644 --- a/spec/system/actions/toggleSkipMode.spec.yaml +++ b/spec/system/actions/toggleSkipMode.spec.yaml @@ -105,4 +105,53 @@ out: - name: existingEffect data: test - name: clearSkipNextTimer - - name: render \ No newline at end of file + - name: render +--- +case: do not toggle skip mode on when a choice is visible +in: + - state: + global: + skipMode: false + autoMode: false + pendingEffects: [] + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" +out: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "1" + global: + skipMode: false + autoMode: false + pendingEffects: [] diff --git a/spec/system/renderState/addChoices.spec.yaml b/spec/system/renderState/addChoices.spec.yaml index b46d562d..c349c198 100644 --- a/spec/system/renderState/addChoices.spec.yaml +++ b/spec/system/renderState/addChoices.spec.yaml @@ -39,21 +39,114 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - type: "button" id: "choice-button" text: "Select an option" animations: [] --- +case: tags choice click payloads and exposes choice visibility template data +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + - presentationState: + choice: + resourceId: "choiceLayout" + items: + - id: "choice1" + text: "Option 1" + resources: + layouts: + choiceLayout: + elements: + - id: "choice-copy" + type: "text" + content: + $if isChoiceVisible: "Choice shown" + $else: "Choice hidden" + - id: "choice-button" + type: "button" + text: "Continue" + click: + payload: + actions: + nextLine: {} + screen: + width: 1920 + height: 1080 +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - id: "choice-copy" + type: "text" + content: "Choice shown" + - id: "choice-button" + type: "button" + text: "Continue" + click: + payload: + _interactionSource: "choice" + actions: + nextLine: + _interactionSource: "choice" + animations: [] +--- +case: tags non-nextLine choice clicks at the payload level +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + - presentationState: + choice: + resourceId: "choiceLayout" + items: + - id: "choice1" + text: "Option 1" + resources: + layouts: + choiceLayout: + elements: + - id: "choice-button" + type: "button" + text: "Go" + click: + payload: + actions: + sectionTransition: + sectionId: "next-section" + screen: + width: 1920 + height: 1080 +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - id: "choice-button" + type: "button" + text: "Go" + click: + payload: + _interactionSource: "choice" + actions: + sectionTransition: + sectionId: "next-section" + animations: [] +--- case: multiple choice layout elements in: - elements: @@ -94,16 +187,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - type: "container" id: "choices" children: @@ -185,16 +268,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-label" type: "text" content: "Select an option" @@ -242,16 +315,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-panel" type: "rect" width: 500 @@ -294,16 +357,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - id: "choice-image" type: "sprite" src: "choice-frame.png" @@ -367,17 +420,7 @@ out: type: "container" x: 0 y: 0 - children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} + children: [] animations: [] --- case: layout has no elements - return unchanged state @@ -408,17 +451,7 @@ out: type: "container" x: 0 y: 0 - children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} + children: [] animations: [] --- case: no choice items - still add layout elements @@ -450,16 +483,6 @@ out: x: 0 y: 0 children: - - id: "choice-blocker" - type: "rect" - fill: "transparent" - width: 1920 - height: 1080 - x: 0 - y: 0 - click: - payload: - actions: {} - type: "text" content: "No choices available" animations: [] diff --git a/spec/system/selectors/selectIsChoiceVisible.spec.yaml b/spec/system/selectors/selectIsChoiceVisible.spec.yaml new file mode 100644 index 00000000..c07149dd --- /dev/null +++ b/spec/system/selectors/selectIsChoiceVisible.spec.yaml @@ -0,0 +1,160 @@ +file: "../../../src/stores/system.store.js" +group: systemStore selectors +suites: [selectIsChoiceVisible] +--- +suite: selectIsChoiceVisible +exportName: selectIsChoiceVisible +--- +case: false when no choice is active +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line1" +out: false +--- +case: true when current line shows a choice +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: + choice: + resourceId: "choice-ui" + items: [] + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line1" +out: true +--- +case: false when the choice is explicitly cleared on the current line +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "line2" + actions: + choice: {} + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line2" +out: false +--- +case: false when a later line omits choice after an earlier choice line +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "line2" + actions: + dialogue: + content: + - text: "after choice" + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line2" +out: false +--- +case: true when current line only updates choice animations +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "line2" + actions: + choice: + animations: + resourceId: "fade" + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line2" +out: true +--- +case: false when cleanAll clears a previous choice before choice animations +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "line1" + actions: + choice: + resourceId: "choice-ui" + items: [] + - id: "line2" + actions: + cleanAll: true + choice: + animations: + resourceId: "fade" + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "line2" +out: false diff --git a/spec/systemStore.choiceVisibilityCloneSafety.test.js b/spec/systemStore.choiceVisibilityCloneSafety.test.js new file mode 100644 index 00000000..e500efbe --- /dev/null +++ b/spec/systemStore.choiceVisibilityCloneSafety.test.js @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { selectIsChoiceVisible } from "../src/stores/system.store.js"; + +describe("selectIsChoiceVisible clone safety", () => { + it("does not rebuild presentation state for choice visibility checks", () => { + const state = { + global: {}, + projectData: { + story: { + scenes: { + scene1: { + sections: { + section1: { + lines: [ + { + id: "line1", + actions: { + background: { + resourceId: "bg-main", + view: globalThis, + }, + }, + }, + { + id: "line2", + actions: { + choice: { + resourceId: "choice-layout", + items: [], + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + contexts: [ + { + currentPointerMode: "read", + pointers: { + read: { + sectionId: "section1", + lineId: "line2", + }, + }, + }, + ], + }; + + expect(() => selectIsChoiceVisible({ state })).not.toThrow(); + expect(selectIsChoiceVisible({ state })).toBe(true); + }); +}); diff --git a/src/RouteEngine.js b/src/RouteEngine.js index 5539b3dc..7bcb4824 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -78,6 +78,10 @@ export default function createRouteEngine(options) { return _systemStore.selectAutoMode(); }; + const selectIsChoiceVisible = () => { + return _systemStore.selectIsChoiceVisible(); + }; + const handleAction = (actionType, payload) => { if (!_systemStore[actionType]) { return; @@ -151,6 +155,7 @@ export default function createRouteEngine(options) { selectSaveSlot, selectSaveSlotPage, selectSaveSlots: selectSaveSlotMap, + selectIsChoiceVisible, handleLineActions, }; } diff --git a/src/createEffectsHandler.js b/src/createEffectsHandler.js index b757d974..36d81c09 100644 --- a/src/createEffectsHandler.js +++ b/src/createEffectsHandler.js @@ -318,14 +318,54 @@ const createEffectsHandler = ({ return true; }; + const isChoiceInteractionPayload = (payload = {}) => { + if (payload?._interactionSource === "choice") { + return true; + } + + const actions = payload?.actions; + if (!actions || typeof actions !== "object" || Array.isArray(actions)) { + return false; + } + + return Object.values(actions).some( + (actionPayload) => actionPayload?._interactionSource === "choice", + ); + }; + + const shouldBlockChoiceActions = (engine, payload = {}) => { + if (!payload?.actions) { + return false; + } + + if (typeof engine?.selectIsChoiceVisible !== "function") { + return false; + } + + if (!engine.selectIsChoiceVisible()) { + return false; + } + + return !isChoiceInteractionPayload(payload); + }; + const createRouteGraphicsEventHandler = ({ preprocessPayload, onEvent, } = {}) => { return async (eventName, payload = {}) => { + const engine = getEngine(); + if (shouldBlockChoiceActions(engine, payload)) { + return onEvent?.(eventName, payload); + } + const nextPayload = (await preprocessPayload?.(eventName, payload)) ?? payload; + if (shouldBlockChoiceActions(engine, nextPayload)) { + return onEvent?.(eventName, nextPayload); + } + handleRouteGraphicsEvent(eventName, nextPayload); if (nextPayload?.actions) { @@ -335,7 +375,6 @@ const createEffectsHandler = ({ ? { _event: nextPayload.event } : undefined; - const engine = getEngine(); engine.handleActions(nextPayload.actions, eventContext); } diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index d430fdd2..cd66212d 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -844,6 +844,7 @@ const createLayoutTemplateData = ({ isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, confirmDialog, historyDialogue = [], @@ -855,6 +856,7 @@ const createLayoutTemplateData = ({ isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, confirmDialog, historyDialogue, @@ -956,6 +958,61 @@ const createFullscreenClickBlocker = ({ }, }); +const tagChoiceInteractionSource = (node) => { + if (Array.isArray(node)) { + return node.map(tagChoiceInteractionSource); + } + + if (!node || typeof node !== "object") { + return node; + } + + const taggedNode = {}; + for (const [key, value] of Object.entries(node)) { + taggedNode[key] = tagChoiceInteractionSource(value); + } + + const clickPayload = taggedNode.click?.payload; + if ( + !clickPayload || + typeof clickPayload !== "object" || + Array.isArray(clickPayload) + ) { + return taggedNode; + } + + const actions = clickPayload.actions; + const nextLineAction = + actions && + typeof actions === "object" && + !Array.isArray(actions) && + Object.prototype.hasOwnProperty.call(actions, "nextLine") + ? { + ...actions.nextLine, + _interactionSource: "choice", + } + : undefined; + + return { + ...taggedNode, + click: { + ...taggedNode.click, + payload: { + ...clickPayload, + _interactionSource: "choice", + ...(nextLineAction + ? { + actions: { + ...actions, + nextLine: nextLineAction, + }, + } + : {}), + }, + }, + }; +}; + const createHistoryDialogueTemplateData = ( dialogueHistory = [], characters = {}, @@ -1213,6 +1270,7 @@ export const addBackgroundOrCg = ( variables, autoMode, skipMode, + isChoiceVisible, canRollback, saveSlots = [], }, @@ -1275,6 +1333,7 @@ export const addBackgroundOrCg = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, }), { functions: jemplFunctions }, @@ -1472,6 +1531,7 @@ export const addVisuals = ( variables, autoMode, skipMode, + isChoiceVisible, canRollback, saveSlots = [], }, @@ -1582,6 +1642,7 @@ export const addVisuals = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, }), { functions: jemplFunctions }, @@ -1639,6 +1700,7 @@ export const addDialogue = ( dialogueUIHidden, autoMode, skipMode, + isChoiceVisible, canRollback, skipOnlyViewedLines, isLineCompleted, @@ -1714,6 +1776,7 @@ export const addDialogue = ( variables, autoMode, skipMode, + isChoiceVisible, canRollback, skipOnlyViewedLines, isLineCompleted, @@ -1801,12 +1864,13 @@ export const addChoices = ( presentationState, previousPresentationState, resources, + screen, isLineCompleted, skipTransitionsAndAnimations, - screen, variables, autoMode, skipMode, + isChoiceVisible, canRollback, saveSlots = [], }, @@ -1817,21 +1881,6 @@ export const addChoices = ( const storyContainer = getStoryContainer(elements); if (!storyContainer) return state; - storyContainer.children.push({ - id: "choice-blocker", - type: "rect", - fill: "transparent", - width: screen.width, - height: screen.height, - x: 0, - y: 0, - click: { - payload: { - actions: {}, - }, - }, - }); - const layout = resources?.layouts[presentationState.choice.resourceId]; if (layout && layout.elements) { const wrappedTemplate = { elements: layout.elements }; @@ -1844,6 +1893,7 @@ export const addChoices = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible: isChoiceVisible ?? !!presentationState.choice, canRollback, }), choice: { @@ -1854,13 +1904,15 @@ export const addChoices = ( functions: jemplFunctions, }, ); - const choiceElements = resolveLayoutResourceIds( - settleTextRevealIfCompleted(result?.elements, { - isLineCompleted, - skipMode, - skipTransitionsAndAnimations, - }), - resources, + const choiceElements = tagChoiceInteractionSource( + resolveLayoutResourceIds( + settleTextRevealIfCompleted(result?.elements, { + isLineCompleted, + skipMode, + skipTransitionsAndAnimations, + }), + resources, + ), ); if (Array.isArray(choiceElements)) { @@ -1907,6 +1959,7 @@ export const addControl = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, saveSlots = [], skipTransitionsAndAnimations, @@ -1953,6 +2006,7 @@ export const addControl = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, }), isLineCompleted, @@ -2048,6 +2102,7 @@ export const addLayout = ( variables, autoMode, skipMode, + isChoiceVisible, canRollback, saveSlots = [], isLineCompleted, @@ -2097,6 +2152,7 @@ export const addLayout = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, }), isLineCompleted, @@ -2142,6 +2198,7 @@ export const addLayeredViews = ( variables, autoMode, skipMode, + isChoiceVisible, canRollback, layeredViews = [], dialogueHistory = [], @@ -2199,6 +2256,7 @@ export const addLayeredViews = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, historyDialogue: historyDialogueWithNames, characters: resources.characters || {}, @@ -2247,6 +2305,7 @@ export const addConfirmDialog = ( saveSlots = [], autoMode, skipMode, + isChoiceVisible, canRollback, confirmDialog, dialogueHistory = [], @@ -2303,6 +2362,7 @@ export const addConfirmDialog = ( isLineCompleted, autoMode, skipMode, + isChoiceVisible, canRollback, confirmDialog, historyDialogue: historyDialogueWithNames, diff --git a/src/stores/system.store.js b/src/stores/system.store.js index f7a58abd..ed5e23cb 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -1225,6 +1225,57 @@ export const selectNextLineConfig = ({ state }) => { return state.global.nextLineConfig; }; +const selectVisibleChoiceResourceId = ({ + state, + pointer: targetPointer, +} = {}) => { + const pointer = targetPointer ?? selectCurrentPointer({ state })?.pointer; + if (!pointer) { + return undefined; + } + + const sectionId = pointer?.sectionId; + const lineId = pointer?.lineId; + const section = selectSection({ state }, { sectionId }); + const lines = section?.lines || []; + const currentLineIndex = lines.findIndex((line) => line.id === lineId); + + if (currentLineIndex < 0) { + return undefined; + } + + let visibleChoiceResourceId; + for (const line of lines.slice(0, currentLineIndex + 1)) { + const actions = line?.actions; + if (actions?.cleanAll) { + visibleChoiceResourceId = undefined; + } + + if (!actions || !Object.prototype.hasOwnProperty.call(actions, "choice")) { + visibleChoiceResourceId = undefined; + continue; + } + + const choice = actions.choice; + if (choice?.resourceId) { + visibleChoiceResourceId = choice.resourceId; + continue; + } + + // `choice: { animations: ... }` should preserve the previous choice state, + // while `choice: {}` explicitly clears it. + if (!choice?.animations) { + visibleChoiceResourceId = undefined; + } + } + + return visibleChoiceResourceId; +}; + +export const selectIsChoiceVisible = ({ state }) => { + return !!selectVisibleChoiceResourceId({ state }); +}; + export const selectSystemState = ({ state }) => { return structuredClone(state); }; @@ -1303,44 +1354,6 @@ export const selectCurrentLine = ({ state }) => { return section.lines.find((line) => line.id === lineId); }; -const selectVisibleChoiceResourceId = ({ state }) => { - const pointer = selectCurrentPointer({ state })?.pointer; - const sectionId = pointer?.sectionId; - const lineId = pointer?.lineId; - const section = selectSection({ state }, { sectionId }); - const lines = section?.lines || []; - const currentLineIndex = lines.findIndex((line) => line.id === lineId); - - if (currentLineIndex < 0) { - return undefined; - } - - let visibleChoiceResourceId; - const currentLines = lines.slice(0, currentLineIndex + 1); - currentLines.forEach((line) => { - if ( - !line?.actions || - !Object.prototype.hasOwnProperty.call(line.actions, "choice") - ) { - return; - } - - const choice = line.actions.choice; - if (!choice?.resourceId) { - // `choice: {}` clears the currently visible choice. - // Choice animations without resourceId should not change visibility. - if (!choice?.animations) { - visibleChoiceResourceId = undefined; - } - return; - } - - visibleChoiceResourceId = choice.resourceId; - }); - - return visibleChoiceResourceId; -}; - export const selectPresentationState = ({ state }) => { const { sectionId, lineId } = selectCurrentPointer({ state }).pointer; const section = selectSection({ state }, { sectionId }); @@ -1576,6 +1589,7 @@ export const selectRenderState = ({ state }) => { dialogueUIHidden: state.global.dialogueUIHidden, autoMode: state.global.autoMode, skipMode: state.global.skipMode, + isChoiceVisible: selectIsChoiceVisible({ state }), canRollback: selectCanRollback({ state }), skipOnlyViewedLines: !allVariables._skipUnseenText, isLineCompleted: state.global.isLineCompleted, @@ -1641,7 +1655,50 @@ export const clearLayeredViews = ({ state }) => { return state; }; +const stopPlaybackForEnteredChoiceLine = (state) => { + if (state.global.autoMode) { + state.global.autoMode = false; + state.global.pendingEffects.push({ + name: "clearAutoNextTimer", + }); + } + + if (state.global.skipMode) { + state.global.skipMode = false; + state.global.pendingEffects.push({ + name: "clearSkipNextTimer", + }); + } + + if (state.global.nextLineConfig?.auto?.enabled) { + state.global.pendingEffects.push({ + name: "clearNextLineConfigTimer", + }); + } +}; + +const queueEnteredLineEffects = (state, pointer) => { + state.global.isLineCompleted = false; + + const isChoiceVisible = !!selectVisibleChoiceResourceId({ state, pointer }); + if (isChoiceVisible) { + stopPlaybackForEnteredChoiceLine(state); + } + + state.global.pendingEffects.push({ + name: "handleLineActions", + }); + + return { + isChoiceVisible, + }; +}; + export const startAutoMode = ({ state }) => { + if (selectIsChoiceVisible({ state })) { + return state; + } + if (state.global.skipMode) { state.global.skipMode = false; state.global.pendingEffects.push({ @@ -1682,6 +1739,10 @@ export const stopAutoMode = ({ state }) => { export const toggleAutoMode = ({ state }) => { const autoMode = state.global.autoMode; + if (selectIsChoiceVisible({ state }) && !autoMode) { + return state; + } + if (autoMode) { stopAutoMode({ state }); } else { @@ -1695,6 +1756,10 @@ export const startSkipMode = ({ state }) => { // return state; // } + if (selectIsChoiceVisible({ state })) { + return state; + } + if (state.global.autoMode) { state.global.autoMode = false; state.global.pendingEffects.push({ @@ -1734,6 +1799,10 @@ export const toggleSkipMode = ({ state }) => { // } const skipMode = selectSkipMode({ state }); + if (selectIsChoiceVisible({ state }) && !skipMode) { + return state; + } + if (skipMode) { stopSkipMode({ state }); } else { @@ -1922,10 +1991,12 @@ export const setNextLineConfig = ({ state }, payload) => { } const currentAutoEnabled = state.global.nextLineConfig.auto?.enabled; + const isChoiceVisible = selectIsChoiceVisible({ state }); // If auto.enabled state has changed, dispatch timer effects if ( !isRollbackRestoring && + !isChoiceVisible && currentAutoEnabled === true && !previousAutoEnabled ) { @@ -2136,18 +2207,12 @@ export const jumpToLine = ({ state }, payload) => { setActiveRollbackBatchCheckpoint(lastContext.rollback.currentIndex); } - // Reset line completion state - state.global.isLineCompleted = false; - - // Add appropriate pending effects - state.global.pendingEffects.push({ - name: "handleLineActions", - }); + queueEnteredLineEffects(state, lastContext.pointers.read); return state; }; -export const nextLine = ({ state }) => { +export const nextLine = ({ state }, payload) => { //const isAutoOrSkip = state.global.autoMode || state.global.skipMode; if (!state.global.nextLineConfig.manual.enabled) { @@ -2159,6 +2224,13 @@ export const nextLine = ({ state }) => { return state; } + if ( + selectIsChoiceVisible({ state }) && + payload?._interactionSource !== "choice" + ) { + return state; + } + // If line is not completed, complete it instantly instead of advancing if (!state.global.isLineCompleted) { state.global.isLineCompleted = true; @@ -2246,21 +2318,19 @@ export const nextLine = ({ state }) => { }; } - state.global.isLineCompleted = false; - appendRollbackCheckpoint(state, { sectionId, lineId: nextLine.id, }); resetNextLineConfigIfSingleLine(state); - - state.global.pendingEffects.push({ - name: "handleLineActions", + const { isChoiceVisible } = queueEnteredLineEffects(state, { + sectionId, + lineId: nextLine.id, }); // Keep scene auto mode running after manual advances (e.g. choice click -> nextLine). const nextLineConfig = state.global.nextLineConfig; - if (nextLineConfig?.auto?.enabled) { + if (nextLineConfig?.auto?.enabled && !isChoiceVisible) { const trigger = nextLineConfig.auto.trigger; if (trigger === "fromStart") { state.global.pendingEffects.push({ @@ -2295,9 +2365,10 @@ export const markLineCompleted = ({ state }) => { return state; } state.global.isLineCompleted = true; + const isChoiceVisible = selectIsChoiceVisible({ state }); // If auto mode is on, start the delay timer to advance after completion - if (state.global.autoMode) { + if (state.global.autoMode && !isChoiceVisible) { const autoForwardTime = state.global.variables._autoForwardTime ?? 1000; state.global.pendingEffects.push({ name: "startAutoNextTimer", @@ -2314,7 +2385,7 @@ export const markLineCompleted = ({ state }) => { // If nextLineConfig.auto is enabled with fromComplete trigger, start the timer const nextLineConfig = state.global.nextLineConfig; - if (nextLineConfig?.auto?.enabled) { + if (nextLineConfig?.auto?.enabled && !isChoiceVisible) { const trigger = nextLineConfig.auto.trigger; // Default trigger is "fromComplete", so start timer if not explicitly "fromStart" if (trigger !== "fromStart") { @@ -2482,13 +2553,7 @@ export const sectionTransition = ({ state }, payload) => { } } - // Reset line completion state - state.global.isLineCompleted = false; - - // Add appropriate pending effects - state.global.pendingEffects.push({ - name: "handleLineActions", - }); + queueEnteredLineEffects(state, lastContext?.pointers?.read); return state; }; @@ -2500,7 +2565,7 @@ export const nextLineFromSystem = ({ state }) => { } // Auto/skip/scene timers should pause when an interactive choice is visible. - if (selectVisibleChoiceResourceId({ state })) { + if (selectIsChoiceVisible({ state })) { return state; } @@ -2552,21 +2617,19 @@ export const nextLineFromSystem = ({ state }) => { }; } - state.global.isLineCompleted = false; - appendRollbackCheckpoint(state, { sectionId, lineId: nextLine.id, }); resetNextLineConfigIfSingleLine(state); - - state.global.pendingEffects.push({ - name: "handleLineActions", + const { isChoiceVisible } = queueEnteredLineEffects(state, { + sectionId, + lineId: nextLine.id, }); // Only start timer immediately if trigger is "fromStart" // For "fromComplete" trigger, markLineCompleted will start it when renderComplete fires - if (state.global.nextLineConfig.auto?.enabled) { + if (state.global.nextLineConfig.auto?.enabled && !isChoiceVisible) { const trigger = state.global.nextLineConfig.auto.trigger; if (trigger === "fromStart") { state.global.pendingEffects.push({ @@ -2827,6 +2890,7 @@ export const createSystemStore = (initialState) => { selectPendingEffects, selectSkipMode, selectAutoMode, + selectIsChoiceVisible, selectDialogueUIHidden, selectDialogueHistory, selectConfirmDialog, diff --git a/vt/specs/choice/blocked-non-choice-actions.yaml b/vt/specs/choice/blocked-non-choice-actions.yaml new file mode 100644 index 00000000..e3420ea1 --- /dev/null +++ b/vt/specs/choice/blocked-non-choice-actions.yaml @@ -0,0 +1,173 @@ +--- +title: Choice Blocks Non-Choice Actions +description: Non-choice clicks must be dropped before they can dispatch actions while a choice is visible. +specs: + - the marker stays idle when a non-choice control is clicked while a choice is visible +skipInitialScreenshot: true +viewport: + id: capture + width: 1280 + height: 720 +steps: + - action: wait + ms: 200 + - action: screenshot + - action: click + x: 410 + y: 285 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1280 + height: 720 + backgroundColor: "#151515" +resources: + variables: + blockedMarker: + type: string + scope: context + default: idle + controls: + backgroundControl: + elements: + - id: panel-hit + type: rect + x: 24 + y: 24 + width: 540 + height: 250 + colorId: panelColor + click: + payload: + actions: + updateVariable: + id: markerbg + operations: + - variableId: blockedMarker + op: set + value: background + - id: marker-label + type: text + x: 40 + y: 58 + content: "MARKER: ${variables.blockedMarker}" + textStyleId: statusText + - id: choice-label-on + $when: isChoiceVisible + type: text + x: 40 + y: 106 + content: "CHOICE: ON" + textStyleId: statusText + - id: choice-label-off + $when: "!isChoiceVisible" + type: text + x: 40 + y: 106 + content: "CHOICE: OFF" + textStyleId: statusText + - id: helper-copy + type: text + x: 40 + y: 158 + width: 480 + content: This panel click should be ignored while the choice is visible, then work again after the choice advances. + textStyleId: helperText + layouts: + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 40 + y: 520 + width: 1200 + height: 150 + colorId: dialogueColor + - id: dialogue-text + type: text + x: 72 + y: 566 + width: 1140 + content: ${dialogue.content[0].text} + textStyleId: dialogueText + choiceLayout: + elements: + - id: choice-button + type: rect + x: 680 + y: 180 + width: 520 + height: 84 + colorId: choiceColor + click: + payload: + actions: + nextLine: {} + - id: choice-button-text + type: text + x: 722 + y: 208 + content: ${choice.items[0].content} + textStyleId: statusText + fonts: + fontDefault: + fileId: Arial + colors: + textColor: + hex: "#F3EEDD" + backgroundColor: + hex: "#151515" + dialogueColor: + hex: "#272727" + panelColor: + hex: "#1F3A3D" + choiceColor: + hex: "#7F5539" + textStyles: + statusText: + fontId: fontDefault + colorId: textColor + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + helperText: + fontId: fontDefault + colorId: textColor + fontSize: 22 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.35 + dialogueText: + fontId: fontDefault + colorId: textColor + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.25 +story: + initialSceneId: choiceBlocks + scenes: + choiceBlocks: + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + control: + resourceId: backgroundControl + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Line 1: the choice is visible now, so the panel click must not dispatch its updateVariable action." + choice: + resourceId: choiceLayout + items: + - id: continue + content: Continue diff --git a/vt/specs/choice/interaction-guards.yaml b/vt/specs/choice/interaction-guards.yaml new file mode 100644 index 00000000..3a057b96 --- /dev/null +++ b/vt/specs/choice/interaction-guards.yaml @@ -0,0 +1,274 @@ +--- +title: Choice Interaction Guards +description: Choice visibility stops active playback, blocks nextLine and auto/skip toggles, and still allows the choice to advance. +specs: + - auto mode is visibly on before the choice line is entered + - entering the choice line forces auto mode off and exposes the choice state + - auto, skip, and background clicks do not change the state while the choice is visible + - the choice button still advances to the next line + - the line after the choice can advance normally even without an explicit choice clear action +skipInitialScreenshot: true +viewport: + id: capture + width: 1280 + height: 720 +steps: + - action: wait + ms: 200 + - action: screenshot + - action: click + x: 395 + y: 380 + - action: wait + ms: 150 + - action: screenshot + - action: click + x: 395 + y: 217 + - action: wait + ms: 120 + - action: screenshot + - action: click + x: 395 + y: 260 + - action: wait + ms: 120 + - action: screenshot + - action: click + x: 395 + y: 380 + - action: wait + ms: 120 + - action: screenshot + - action: click + x: 790 + y: 291 + - action: wait + ms: 150 + - action: screenshot + - action: click + x: 395 + y: 380 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1280 + height: 720 + backgroundColor: "#101010" +resources: + variables: + _autoForwardTime: + type: number + scope: global-device + default: 5000 + controls: + guardControls: + elements: + - id: bg-hit + type: rect + x: 0 + y: 0 + width: 1280 + height: 720 + click: + payload: + actions: + nextLine: {} + colorId: backgroundColor + - id: auto-button + type: rect + x: 40 + y: 40 + width: 220 + height: 68 + click: + payload: + actions: + toggleAutoMode: {} + colorId: buttonColor + - id: auto-label + type: text + x: 96 + y: 62 + content: AUTO + textStyleId: statusText + - id: skip-button + type: rect + x: 40 + y: 126 + width: 220 + height: 68 + click: + payload: + actions: + toggleSkipMode: {} + colorId: buttonColor + - id: skip-label + type: text + x: 100 + y: 148 + content: SKIP + textStyleId: statusText + - id: auto-status-on + $when: autoMode + type: text + x: 300 + y: 52 + content: "AUTO: ON" + textStyleId: statusText + - id: auto-status-off + $when: "!autoMode" + type: text + x: 300 + y: 52 + content: "AUTO: OFF" + textStyleId: statusText + - id: skip-status-on + $when: skipMode + type: text + x: 300 + y: 138 + content: "SKIP: ON" + textStyleId: statusText + - id: skip-status-off + $when: "!skipMode" + type: text + x: 300 + y: 138 + content: "SKIP: OFF" + textStyleId: statusText + - id: choice-status-on + $when: isChoiceVisible + type: text + x: 300 + y: 224 + content: "CHOICE: ON" + textStyleId: statusText + - id: choice-status-off + $when: "!isChoiceVisible" + type: text + x: 300 + y: 224 + content: "CHOICE: OFF" + textStyleId: statusText + - id: helper-copy + type: text + x: 40 + y: 300 + width: 520 + content: Background nextLine advances when allowed. Auto and skip toggles must do nothing while a choice is visible. + textStyleId: helperText + layouts: + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 40 + y: 520 + width: 1200 + height: 150 + colorId: dialogueColor + - id: dialogue-text + type: text + x: 72 + y: 566 + width: 1140 + content: ${dialogue.content[0].text} + textStyleId: dialogueText + choiceLayout: + elements: + - id: choice-button-bg + type: rect + x: 680 + y: 180 + width: 520 + height: 84 + click: + payload: + actions: + nextLine: {} + colorId: choiceColor + - id: choice-button-text + type: text + x: 720 + y: 208 + content: ${choice.items[0].content} + textStyleId: statusText + fonts: + fontDefault: + fileId: Arial + colors: + textColor: + hex: "#F4F1E8" + backgroundColor: + hex: "#171717" + buttonColor: + hex: "#264653" + dialogueColor: + hex: "#2A2A2A" + choiceColor: + hex: "#9C6644" + textStyles: + statusText: + fontId: fontDefault + colorId: textColor + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + helperText: + fontId: fontDefault + colorId: textColor + fontSize: 22 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.35 + dialogueText: + fontId: fontDefault + colorId: textColor + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.25 +story: + initialSceneId: choiceGuards + scenes: + choiceGuards: + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + control: + resourceId: guardControls + startAutoMode: {} + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Line 1: auto mode starts here, then the engine should stop it when the choice line appears." + - id: line2 + actions: + dialogue: + content: + - text: "Line 2: choice is visible. Background nextLine is blocked, auto and skip cannot be turned on, and the choice button should still continue." + choice: + resourceId: choiceLayout + items: + - id: continue + content: Continue from choice + - id: line3 + actions: + dialogue: + content: + - text: "Line 3: reached from the choice button." + - id: line4 + actions: + dialogue: + content: + - text: "Line 4: next-line input works normally after the choice." diff --git a/vt/specs/choice/skip-stops-on-choice.yaml b/vt/specs/choice/skip-stops-on-choice.yaml new file mode 100644 index 00000000..af5980ac --- /dev/null +++ b/vt/specs/choice/skip-stops-on-choice.yaml @@ -0,0 +1,202 @@ +--- +title: Choice Stops Active Skip +description: Skip mode should be turned off as soon as a choice becomes visible, and the choice should remain interactive. +specs: + - skip mode is visibly on before the choice line is entered + - entering the choice line forces skip mode off + - waiting on the choice line does not re-enable skip or advance the story + - the choice button still advances after skip has been stopped +skipInitialScreenshot: true +viewport: + id: capture + width: 1280 + height: 720 +steps: + - action: wait + ms: 200 + - action: click + x: 395 + y: 217 + - action: wait + ms: 40 + - action: screenshot + - action: wait + ms: 220 + - action: screenshot + - action: wait + ms: 220 + - action: screenshot + - action: click + x: 790 + y: 291 + - action: wait + ms: 150 + - action: screenshot +--- +screen: + width: 1280 + height: 720 + backgroundColor: "#101010" +resources: + variables: + _skipUnseenText: + type: boolean + scope: global-device + default: true + controls: + guardControls: + elements: + - id: bg-hit + type: rect + x: 0 + y: 0 + width: 1280 + height: 720 + click: + payload: + actions: + nextLine: {} + colorId: backgroundColor + - id: skip-button + type: rect + x: 40 + y: 40 + width: 220 + height: 68 + click: + payload: + actions: + toggleSkipMode: {} + colorId: choiceColor + - id: skip-label + type: text + x: 100 + y: 62 + content: SKIP + textStyleId: statusText + - id: skip-status-on + $when: skipMode + type: text + x: 300 + y: 56 + content: "SKIP: ON" + textStyleId: statusText + - id: skip-status-off + $when: "!skipMode" + type: text + x: 300 + y: 56 + content: "SKIP: OFF" + textStyleId: statusText + - id: choice-status-on + $when: isChoiceVisible + type: text + x: 300 + y: 102 + content: "CHOICE: ON" + textStyleId: statusText + - id: choice-status-off + $when: "!isChoiceVisible" + type: text + x: 300 + y: 102 + content: "CHOICE: OFF" + textStyleId: statusText + layouts: + dialogueLayout: + mode: adv + elements: + - id: dialogue-box + type: rect + x: 40 + y: 520 + width: 1200 + height: 150 + colorId: dialogueColor + - id: dialogue-text + type: text + x: 72 + y: 566 + width: 1140 + content: ${dialogue.content[0].text} + textStyleId: dialogueText + choiceLayout: + elements: + - id: choice-button-bg + type: rect + x: 680 + y: 180 + width: 520 + height: 84 + click: + payload: + actions: + nextLine: {} + colorId: choiceColor + - id: choice-button-text + type: text + x: 720 + y: 208 + content: ${choice.items[0].content} + textStyleId: statusText + fonts: + fontDefault: + fileId: Arial + colors: + textColor: + hex: "#F4F1E8" + backgroundColor: + hex: "#171717" + dialogueColor: + hex: "#2A2A2A" + choiceColor: + hex: "#9C6644" + textStyles: + statusText: + fontId: fontDefault + colorId: textColor + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.2 + dialogueText: + fontId: fontDefault + colorId: textColor + fontSize: 30 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.25 +story: + initialSceneId: choiceGuards + scenes: + choiceGuards: + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + control: + resourceId: guardControls + dialogue: + mode: adv + ui: + resourceId: dialogueLayout + content: + - text: "Line 1: turn on skip here, then the next choice line should stop it." + - id: line2 + actions: + dialogue: + content: + - text: "Line 2: the choice is visible and skip must already be off." + choice: + resourceId: choiceLayout + items: + - id: continue + content: Continue from choice + - id: line3 + actions: + choice: {} + dialogue: + content: + - text: "Line 3: reached from the choice after skip was stopped." diff --git a/vt/specs/nextLineConfig/choice-stops-auto.yaml b/vt/specs/nextLineConfig/choice-stops-auto.yaml index 0e9d2264..4e4ba851 100644 --- a/vt/specs/nextLineConfig/choice-stops-auto.yaml +++ b/vt/specs/nextLineConfig/choice-stops-auto.yaml @@ -1,6 +1,28 @@ --- title: nextLineConfig with Choice description: Scene auto (fromStart) should pause when a choice is visible +specs: + - scene auto advances from line 1 to line 3 without manual input + - once the choice is visible on line 3, waiting longer than the configured delay does not advance to line 4 + - the first choice button still advances to line 4 when clicked +skipInitialScreenshot: true +viewport: + id: capture + width: 1920 + height: 1080 +steps: + - action: wait + ms: 2300 + - action: screenshot + - action: wait + ms: 1300 + - action: screenshot + - action: click + x: 960 + y: 400 + - action: wait + ms: 150 + - action: screenshot --- screen: width: 1920