From e6fb0889b88b702690b16c00010015e5121ac96f Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Thu, 25 Dec 2025 04:33:48 +0000 Subject: [PATCH 01/11] wip --- docs/FileGuide.md | 381 +++++++++++++++++++++++++++++++++ src/RouteEngine.js | 5 + vt/specs/save/basic.yaml | 449 +++++++++++++++++++++++++++++++++++++++ vt/static/main.js | 3 + 4 files changed, 838 insertions(+) create mode 100644 docs/FileGuide.md create mode 100644 vt/specs/save/basic.yaml diff --git a/docs/FileGuide.md b/docs/FileGuide.md new file mode 100644 index 00000000..706e6c24 --- /dev/null +++ b/docs/FileGuide.md @@ -0,0 +1,381 @@ +# File Guide + +This guide explains each file in the Route Engine codebase, when to use it, and why. + +## Quick Reference + +| File | Purpose | Use When... | +|------|---------|-------------| +| `src/index.js` | Package entry point | Importing the engine | +| `src/RouteEngine.js` | Engine factory | Creating engine instances | +| `src/util.js` | Store & action utilities | Building custom state management | +| `src/createTimer.js` | Timer system | Scheduling delayed actions | +| `src/stores/system.store.js` | Core state store | Reading/managing game state | +| `src/stores/constructPresentationState.js` | Presentation builder | Converting actions to display data | +| `src/stores/constructRenderState.js` | Render builder | Converting presentation to renderer output | +| `src/stores/effectHandlers.js` | Side effect handlers | Processing game events (save, render, timers) | + +--- + +## Core Files + +### `src/index.js` +**Main entry point** - Exports `createRouteEngine` factory function. + +```js +import createRouteEngine from 'route-engine-js'; +``` + +**Why use it:** This is the only file you need to import to use the engine. + +--- + +### `src/RouteEngine.js` +**Engine factory** - Creates RouteEngine instances with effect handling. + +```js +const engine = createRouteEngine({ + handlePendingEffects: (effects) => { + effects.forEach(effect => { + if (effect.name === 'render') { + // Update your renderer + } + }); + } +}); + +engine.init({ initialState }); +``` + +**API Methods:** +| Method | Purpose | +|--------|---------| +| `init({ initialState })` | Initialize engine with project data | +| `handleAction(type, payload)` | Dispatch a single action | +| `handleActions(actions)` | Dispatch multiple actions at once | +| `selectPresentationState()` | Get current presentation state | +| `selectRenderState()` | Get renderer-ready state | +| `handleLineActions()` | Process current line's actions | + +**Why use it:** Creates isolated engine instances for multiple games or contexts. + +--- + +### `src/util.js` +**State management utilities** - Store builders and action executors. + +#### `createStore(initialState, selectorsAndActions, options)` +Creates a store with selectors and actions from a single object. + +```js +const store = createStore(initialState, { + selectCount: (state) => state.count, + increment: (state) => { state.count++; } +}); + +store.increment(); +console.log(store.selectCount()); +``` + +**Functions starting with `select`** become selectors (read state). +**All other functions** become actions (mutate state via Immer). + +**Why use it:** +- Build custom stores for game-specific state +- Leverage Immer for immutable updates +- Automatic selector/action separation + +#### `createSequentialActionsExecutor(createInitialState, actions)` +Applies all actions to each payload sequentially. + +```js +const executor = createSequentialActionsExecutor( + () => ({ items: [], total: 0 }), + [ + (state, item) => { state.items.push(item); }, + (state, item) => { state.total += item.value; } + ] +); + +const result = executor([{ id: 1, value: 10 }, { id: 2, value: 20 }]); +// Result: { items: [...], total: 30 } +``` + +**Why use it:** +- Processing presentation actions that accumulate (dialogue history) +- Building derived state from a sequence of payloads + +#### `createSelectiveActionsExecutor(deps, actions, createInitialState)` +Applies only specified actions with their payloads. + +```js +const executor = createSelectiveActionsExecutor( + { api: myApi }, + { + setUser: (state, deps, payload) => { state.user = payload; }, + setTheme: (state, deps, payload) => { state.theme = payload; } + }, + () => ({ user: null, theme: 'light' }) +); + +const result = executor({ + setUser: { name: 'Alice' }, + // setTheme not called - no payload provided +}); +// Result: { user: { name: 'Alice' }, theme: 'light' } +``` + +**Why use it:** +- System action handling (only run specified actions) +- Batch state updates with independent changes + +--- + +### `src/createTimer.js` +**Timer system** - Creates timers backed by PixiJS Ticker. + +```js +import { Ticker } from 'pixi.js'; +import { createTimer } from 'route-engine-js/util'; + +const ticker = new Ticker(); +const timer = createTimer(ticker); + +timer.start({ + timerId: 'auto-advance', + delay: 1000, + onComplete: () => engine.handleAction('nextLine') +}); + +timer.clear('auto-advance'); +``` + +**Why use it:** +- Auto-advance delays after dialogue completes +- Skip mode fast-forward timing +- Any game logic needing precise timing synced to render loop + +--- + +## Store Files + +### `src/stores/system.store.js` +**Core state management** - The heart of the engine (1031 lines). + +**Exports:** +- `createSystemStore(initialState)` - Creates the main store + +**Selectors** (30+): +| Selector | Returns | +|----------|---------| +| `selectPresentationState()` | Current presentation state | +| `selectRenderState()` | Renderer-ready state | +| `selectCurrentLine()` | Current line data | +| `selectCurrentPointer()` | Current story position | +| `selectViewedRegistry()` | Tracking for skip/gallery | +| `selectSaveSlots()` | All save data | +| `selectVariables()` | Game variables | +| `selectIsInAutoMode()` / `selectIsInSkipMode()` | Playback state | + +**Actions** (30+): +| Action | Purpose | +|--------|---------| +| `nextLine` / `prevLine` | Navigate story | +| `jumpToLine({ sectionId, lineId })` | Jump to position | +| `sectionTransition({ sectionId })` | Change sections | +| `toggleAutoMode` / `toggleSkipMode` | Playback controls | +| `markLineCompleted` | Track animation finish | +| `addViewedLine` / `addViewedResource` | Registry updates | +| `replaceSaveSlot` | Save/load games | +| `pushLayeredView` / `popLayeredView` | UI overlays | +| `appendPendingEffect` | Queue side effects | + +**Why use it:** +- Access all game state through selectors +- Control game flow through actions +- Understand engine behavior + +### `src/stores/constructPresentationState.js` +**Builds presentation state** from line actions. + +**Exports:** +- `constructPresentationState(projectData, systemState, presentations)` + +**What it does:** +1. Iterates through all lines from section start to current line +2. Applies each line's presentation actions in sequence +3. Handles action overrides (later actions replace earlier ones) +4. Accumulates dialogue content for NVL mode + +**Presentation Actions Handled:** +- `base` - Layout configuration +- `background` - Background images with animations +- `dialogue` - Speaker, text, mode (ADV/NVL) +- `character` - Sprite placement with transforms +- `visual` - Overlay images +- `bgm` / `sfx` / `voice` - Audio +- `choice` - Branching options +- `animation` - Active tweens +- `layout` - UI layouts + +**Why use it:** +- Understand how actions become display data +- Debug presentation issues +- Build custom presentation logic + +### `src/stores/constructRenderState.js` +**Builds render state** from presentation state. + +**Exports:** +- `constructRenderState(projectData, systemState, presentationState)` + +**What it does:** +1. Resolves resource IDs to file paths +2. Applies localization translations +3. Creates element tree (containers, sprites, text) +4. Converts animations to tween keyframes +5. Builds audio playback instructions + +**Why use it:** +- Bridge between engine data and renderer +- Debug rendering issues +- Integrate with custom renderers + +### `src/stores/effectHandlers.js` +**Side effect handlers** - Pure functions for processing effects. + +**Exports:** +- `handleEffect(effect, context)` - Main handler + +**Effects Handled:** +| Effect | Handler | Purpose | +|--------|---------|---------| +| `render` | - | Trigger re-render (handled externally) | +| `handleLineActions` | - | Process current line actions | +| `saveVnData` | `handleSaveVnDataEffect` | Save game with screenshot | +| `saveVariables` | `handleSaveVariablesEffect` | Save device variables | +| `startAutoNextTimer` | `handleStartAutoNextTimer` | Schedule auto-advance | +| `clearAutoNextTimer` | `handleClearAutoNextTimer` | Cancel auto-advance | +| `startSkipNextTimer` | `handleStartSkipNextTimer` | Schedule skip advance | +| `clearSkipNextTimer` | `handleClearSkipNextTimer` | Cancel skip advance | +| `startTimer` | `handleStartTimer` | Custom timer | + +**Why use it:** +- Understand side effect lifecycle +- Add custom effect types +- Debug save/timer issues + +--- + +## Schema Files + +Located in `src/schemas/`, these YAML files define the shape of data. + +### `schemas/projectData/` +| File | Defines | +|------|---------| +| `projectData.yaml` | Overall project structure | +| `story.yaml` | Scenes, sections, lines structure | +| `resources.yaml` | Images, sounds, characters, transforms, etc. | +| `i18n.yaml` | Localization package format | +| `mode.yaml` | Display mode configuration | + +### `schemas/systemState/` +| File | Defines | +|------|---------| +| `systemState.yaml` | System state structure | +| `configuration.yaml` | Config options | +| `effects.yaml` | Effect definitions | + +### Action Schemas +| File | Defines | +|------|---------| +| `systemActions.yaml` | All system action schemas | +| `presentationActions.yaml` | All presentation action schemas | + +**Why use them:** +- Validate project data before loading +- Understand expected data shapes +- Generate TypeScript types +- IDE autocomplete/integration + +--- + +## Common Patterns + +### Creating a Game + +```js +import createRouteEngine from 'route-engine-js'; +import projectData from './game/project.yaml'; + +const engine = createRouteEngine({ + handlePendingEffects: (effects) => { + effects.forEach(effect => { + switch (effect.name) { + case 'render': + const renderState = engine.selectRenderState(); + renderer.render(renderState); + break; + case 'saveVnData': + saveGame(effect.payload.slotKey, effect.payload.state); + break; + case 'startAutoNextTimer': + timer.start(effect.payload); + break; + } + }); + } +}); + +engine.init({ + initialState: { + global: { currentLocalizationPackageId: 'en' }, + projectData + } +}); +``` + +### Adding Custom Actions + +Extend the system store with your own actions: + +```js +import { createSystemStore } from 'route-engine-js/stores/system.store.js'; + +const baseStore = createSystemStore(initialState); + +const customStore = { + ...baseStore, + setPlayerHealth: (health) => { + // Your custom logic + } +}; +``` + +### Building Custom Selectors + +```js +import { createStore } from 'route-engine-js/util'; + +const gameState = createStore( + initialState, + { + selectCurrentHP: (state) => state.variables.hp, + selectIsDead: (state) => state.variables.hp <= 0, + selectHasItem: (state, itemId) => + state.variables.inventory.includes(itemId) + } +); +``` + +--- + +## Summary + +- **Use `src/index.js`** to import the engine +- **Use `src/RouteEngine.js`** to create engine instances +- **Use `src/util.js`** for custom state management +- **Use `src/createTimer.js`** for timed events +- **Read `src/stores/`** to understand engine internals +- **Read `src/schemas/`** to understand data structures \ No newline at end of file diff --git a/src/RouteEngine.js b/src/RouteEngine.js index 10dbcff8..e998789c 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -23,6 +23,10 @@ export default function createRouteEngine(options) { return _systemStore.selectRenderState(); } + const selectSaveSlots = () => { + return _systemStore.selectSaveSlots(); + } + const handleAction = (actionType, payload) => { if (!_systemStore[actionType]) { return; @@ -51,6 +55,7 @@ export default function createRouteEngine(options) { handleActions, selectRenderState, selectPresentationState, + selectSaveSlots, handleLineActions }; } diff --git a/vt/specs/save/basic.yaml b/vt/specs/save/basic.yaml new file mode 100644 index 00000000..999eb1a4 --- /dev/null +++ b/vt/specs/save/basic.yaml @@ -0,0 +1,449 @@ +--- +title: "Save and Load Test" +--- +l10n: + packages: + eklekfjwalefj: + label: English + lang: en + keys: + layoutSaveMenuTitle: "Save Game" + layoutLoadMenuTitle: "Load Game" + layoutSaveMenuSlot1: "Slot 1" + layoutSaveMenuSlot2: "Slot 2" + layoutSaveMenuSlot3: "Slot 3" + layoutSaveMenuBack: "Back" + sceneSection1Line1: "This is line 1 of the story." + sceneSection1Line2: "This is line 2 - you can save here." + sceneSection1Line3: "This is line 3 - progress continues." + sceneSection2Line1: "This is Section 2, Line 1." + sceneSection2Line2: "If you loaded correctly, you should see this!" + sceneSection2Line3: "The save/load system is working!" + +screen: + width: 1920 + height: 1080 + backgroundColor: "#000000" + +resources: + variables: + currentSaveSlot: + type: number + default: -1 + + layouts: + base1: + elements: + - id: af32 + type: rect + fill: "#000000" + width: 1920 + height: 1080 + click: + actionPayload: + actions: + nextLine: {} + + # Save Menu Layout + saveMenuLayout: + name: Save Menu + elements: + - id: save-menu-bg + type: rect + fill: "#1a1a2e" + width: 1920 + height: 1080 + x: 0 + y: 0 + - id: save-menu-title + type: text + content: ${l10n.keys.layoutSaveMenuTitle} + x: 800 + y: 100 + textStyle: + fontSize: 64 + fill: "#ffffff" + # Save Slot 1 + - id: save-slot-1-btn + type: rect + fill: "#4CAF50" + width: 600 + height: 150 + x: 100 + y: 300 + click: + actionPayload: + actions: + saveVnData: + slotIndex: 1 + - id: save-slot-1-text + type: text + content: ${l10n.keys.layoutSaveMenuSlot1} + x: 300 + y: 360 + textStyle: + fontSize: 36 + fill: "#ffffff" + # Save Slot 2 + - id: save-slot-2-btn + type: rect + fill: "#2196F3" + width: 600 + height: 150 + x: 100 + y: 500 + click: + actionPayload: + actions: + saveVnData: + slotIndex: 2 + - id: save-slot-2-text + type: text + content: ${l10n.keys.layoutSaveMenuSlot2} + x: 300 + y: 560 + textStyle: + fontSize: 36 + fill: "#ffffff" + # Save Slot 3 + - id: save-slot-3-btn + type: rect + fill: "#FF9800" + width: 600 + height: 150 + x: 100 + y: 700 + click: + actionPayload: + actions: + saveVnData: + slotIndex: 3 + - id: save-slot-3-text + type: text + content: ${l10n.keys.layoutSaveMenuSlot3} + x: 300 + y: 760 + textStyle: + fontSize: 36 + fill: "#ffffff" + # Back Button + - id: save-menu-back-btn + type: rect + fill: "#F44336" + width: 300 + height: 100 + x: 1200 + y: 800 + eventName: system + eventPayload: + actions: + popViewLayer: {} + - id: save-menu-back-text + type: text + content: ${l10n.keys.layoutSaveMenuBack} + x: 1270 + y: 850 + textStyle: + fontSize: 32 + fill: "#ffffff" + + # Load Menu Layout + loadMenuLayout: + name: Load Menu + elements: + - id: load-menu-bg + type: rect + fill: "#1a1a2e" + width: 1920 + height: 1080 + x: 0 + y: 0 + - id: load-menu-title + type: text + content: ${l10n.keys.layoutLoadMenuTitle} + x: 800 + y: 100 + textStyle: + fontSize: 64 + fill: "#ffffff" + # Load Slot 1 + - id: load-slot-1-btn + type: rect + fill: "#9C27B0" + width: 600 + height: 150 + x: 100 + y: 300 + click: + actionPayload: + actions: + loadVnData: + slotIndex: 1 + - id: load-slot-1-text + type: text + content: ${l10n.keys.layoutSaveMenuSlot1} + x: 300 + y: 360 + textStyle: + fontSize: 36 + fill: "#ffffff" + # Load Slot 2 + - id: load-slot-2-btn + type: rect + fill: "#E91E63" + width: 600 + height: 150 + x: 100 + y: 500 + click: + actionPayload: + actions: + loadVnData: + slotIndex: 2 + - id: load-slot-2-text + type: text + content: ${l10n.keys.layoutSaveMenuSlot2} + x: 300 + y: 560 + textStyle: + fontSize: 36 + fill: "#ffffff" + # Load Slot 3 + - id: load-slot-3-btn + type: rect + fill: "#673AB7" + width: 600 + height: 150 + x: 100 + y: 700 + click: + actionPayload: + actions: + loadVnData: + slotIndex: 3 + - id: load-slot-3-text + type: text + content: ${l10n.keys.layoutSaveMenuSlot3} + x: 300 + y: 760 + textStyle: + fontSize: 36 + fill: "#ffffff" + # Back Button + - id: load-menu-back-btn + type: rect + fill: "#F44336" + width: 300 + height: 100 + x: 1200 + y: 800 + eventName: system + eventPayload: + actions: + popViewLayer: {} + - id: load-menu-back-text + type: text + content: ${l10n.keys.layoutSaveMenuBack} + x: 1270 + y: 850 + textStyle: + fontSize: 32 + fill: "#ffffff" + + # Main Menu with Save/Load Options + mainMenuLayout: + name: Main Menu + elements: + - id: main-menu-bg + type: rect + fill: "#16213e" + width: 1920 + height: 1080 + x: 0 + y: 0 + - id: main-menu-title + type: text + content: "Save/Load Test" + x: 700 + y: 200 + textStyle: + fontSize: 72 + fill: "#ffffff" + # Start Button + - id: start-btn + type: rect + fill: "#4CAF50" + width: 400 + height: 80 + x: 760 + y: 350 + eventName: system + eventPayload: + actions: + sectionTransition: + sectionId: section1 + - id: start-text + type: text + content: "Start Game" + x: 870 + y: 390 + textStyle: + fontSize: 36 + fill: "#ffffff" + # Save Button + - id: save-btn + type: rect + fill: "#2196F3" + width: 400 + height: 80 + x: 760 + y: 470 + eventName: system + eventPayload: + actions: + pushViewLayer: + resourceId: saveMenuLayout + - id: save-text + type: text + content: "Save Game" + x: 870 + y: 510 + textStyle: + fontSize: 36 + fill: "#ffffff" + # Load Button + - id: load-btn + type: rect + fill: "#FF9800" + width: 400 + height: 80 + x: 760 + y: 590 + eventName: system + eventPayload: + actions: + pushViewLayer: + resourceId: loadMenuLayout + - id: load-text + type: text + content: "Load Game" + x: 870 + y: 630 + textStyle: + fontSize: 36 + fill: "#ffffff" + + dialogueLayout: + name: Dialogue + mode: adv + elements: + - id: dialogue-container + type: container + x: 50 + y: 300 + children: + - id: "dialogue-bg" + type: sprite + url: 3kda832 + width: 1400 + height: 300 + x: 0 + y: 0 + - id: dialogue-character-name + type: text + content: "${dialogue.character.name}" + x: 20 + y: 40 + textStyle: + fontSize: 24 + fill: "white" + - id: dialogue-text + type: text + x: 20 + y: 100 + content: "${dialogue.content[0].text}" + textStyle: + fontSize: 24 + fill: "white" + + characters: + ajf34a: + name: Narrator + +story: + initialSceneId: testScene + scenes: + testScene: + name: Test Scene + initialSectionId: mainMenu + sections: + mainMenu: + name: Main Menu + lines: + - id: line1 + actions: + layout: + resourceId: mainMenuLayout + + section1: + name: Section 1 + lines: + - id: line1 + actions: + base: + resourceId: base1 + dialogue: + gui: + resourceId: dialogueLayout + mode: adv + content: + - text: ${l10n.keys.sceneSection1Line1} + characterId: ajf34a + - id: line2 + actions: + dialogue: + content: + - text: ${l10n.keys.sceneSection1Line2} + characterId: ajf34a + - id: line3 + actions: + dialogue: + content: + - text: ${l10n.keys.sceneSection1Line3} + characterId: ajf34a + # Automatically show save menu at end of section + updateVariable: + operations: + - variableId: currentSaveSlot + op: set + value: 1 + + section2: + name: Section 2 (After Loading) + lines: + - id: line1 + actions: + base: + resourceId: base1 + dialogue: + gui: + resourceId: dialogueLayout + mode: adv + content: + - text: ${l10n.keys.sceneSection2Line1} + characterId: ajf34a + - id: line2 + actions: + dialogue: + content: + - text: ${l10n.keys.sceneSection2Line2} + characterId: ajf34a + - id: line3 + actions: + dialogue: + content: + - text: ${l10n.keys.sceneSection2Line3} + characterId: ajf34a + # Loop back to main menu + sectionTransition: + sectionId: mainMenu \ No newline at end of file diff --git a/vt/static/main.js b/vt/static/main.js index 9ef9546c..682df7e9 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -165,6 +165,9 @@ const init = async () => { let skipModeElapsed = 0; let skipModeCallback = null; + // Save data storage + const saveSlots = {}; + return (effects) => { // Deduplicate effects by name, keeping only the last occurrence const deduplicatedEffects = effects.reduce((acc, effect) => { From f6aba868254cddb6dc0baf83cc027489b8a50ddb Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Thu, 25 Dec 2025 08:01:27 +0000 Subject: [PATCH 02/11] WIP for saving --- spec/system/actions/globalLoadStory.spec.yaml | 87 +++++++++++ spec/system/actions/globalSaveStory.spec.yaml | 146 ++++++++++++++++++ src/schemas/systemState/effects.yaml | 16 ++ src/schemas/systemState/systemState.yaml | 19 ++- src/stores/system.store.js | 52 +++++++ vt/specs/save/basic.yaml | 60 +++---- vt/static/main.js | 3 + 7 files changed, 345 insertions(+), 38 deletions(-) create mode 100644 spec/system/actions/globalLoadStory.spec.yaml create mode 100644 spec/system/actions/globalSaveStory.spec.yaml diff --git a/spec/system/actions/globalLoadStory.spec.yaml b/spec/system/actions/globalLoadStory.spec.yaml new file mode 100644 index 00000000..bba30b20 --- /dev/null +++ b/spec/system/actions/globalLoadStory.spec.yaml @@ -0,0 +1,87 @@ +file: "../../../src/stores/system.store.js" +group: systemStore.globalLoadStory +suites: [globalLoadStory] +--- +suite: globalLoadStory +exportName: globalLoadStory +--- +case: load from existing slot +in: + - state: + global: + saveSlots: + "1": + slotKey: "1" + date: 1704110400 + image: "save.png" + state: + projectData: { story: { initialSceneId: "loaded" } } + global: + currentLocalizationPackageId: "en" + contexts: + - pointers: + read: { sectionId: "sec2", lineId: "line2" } + pendingEffects: [] + - slot: 1 +out: + global: + pendingEffects: + - name: "loadGame" + options: + initialState: + projectData: { story: { initialSceneId: "loaded" } } +--- +case: load from non-existent slot +in: + - state: + global: + saveSlots: {} + pendingEffects: [] + - slot: 99 +out: + global: + pendingEffects: [] +--- +case: load from slot 2 +in: + - state: + global: + saveSlots: + "2": + slotKey: "2" + date: 1704110500 + image: "save2.png" + state: + level: 10 + pendingEffects: [] + - slot: 2 +out: + global: + pendingEffects: + - name: "loadGame" + options: + initialState: + level: 10 +--- +case: load with existing pending effects +in: + - state: + global: + saveSlots: + "1": + slotKey: "1" + date: 1704110400 + image: "save.png" + state: + level: 5 + pendingEffects: + - name: "existingEffect" + - slot: 1 +out: + global: + pendingEffects: + - name: "existingEffect" + - name: "loadGame" + options: + initialState: + level: 5 diff --git a/spec/system/actions/globalSaveStory.spec.yaml b/spec/system/actions/globalSaveStory.spec.yaml new file mode 100644 index 00000000..debbb82d --- /dev/null +++ b/spec/system/actions/globalSaveStory.spec.yaml @@ -0,0 +1,146 @@ +file: "../../../src/stores/system.store.js" +group: systemStore.globalSaveStory +suites: [globalSaveStory] +--- +suite: globalSaveStory +exportName: globalSaveStory +--- +case: save to slot 1 +in: + - state: + global: + saveSlots: {} + pendingEffects: [] + projectData: + story: { initialSceneId: "test" } + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + - slot: 1 +out: + global: + saveSlots: + "1": + slotKey: "1" + date: __ignore__ + image: null + state: + projectData: + story: { initialSceneId: "test" } + global: + pendingEffects: [] + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + pendingEffects: + - name: "render" + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } +--- +case: save to slot 2 +in: + - state: + global: + saveSlots: {} + pendingEffects: [] + projectData: + story: { initialSceneId: "test" } + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + - slot: 2 +out: + global: + saveSlots: + "2": + slotKey: "2" + date: __ignore__ + image: null + state: + projectData: + story: { initialSceneId: "test" } + global: + pendingEffects: [] + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + pendingEffects: + - name: "render" + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } +--- +case: overwrite existing save slot +in: + - state: + global: + saveSlots: + "1": + slotKey: "1" + date: 1704110400 + image: "old_image.png" + state: + level: 5 + pendingEffects: [] + projectData: + story: { initialSceneId: "test" } + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + - slot: 1 +out: + global: + saveSlots: + "1": + slotKey: "1" + date: __ignore__ + image: null + state: + projectData: + story: { initialSceneId: "test" } + global: + pendingEffects: [] + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + pendingEffects: + - name: "render" + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } +--- +case: save to slot with existing pending effects +in: + - state: + global: + saveSlots: {} + pendingEffects: + - name: "existingEffect" + projectData: + story: { initialSceneId: "test" } + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + - slot: 1 +out: + global: + saveSlots: + "1": + slotKey: "1" + date: __ignore__ + image: null + state: + projectData: + story: { initialSceneId: "test" } + global: + pendingEffects: [] + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + pendingEffects: + - name: "existingEffect" + - name: "render" + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } diff --git a/src/schemas/systemState/effects.yaml b/src/schemas/systemState/effects.yaml index 4b742323..e3d374fb 100644 --- a/src/schemas/systemState/effects.yaml +++ b/src/schemas/systemState/effects.yaml @@ -125,3 +125,19 @@ oneOf: required: [type, timerId, payload, delay] additionalProperties: false + - title: Load game effect + description: Loads a saved game state + type: object + properties: + type: + const: loadGame + initialState: + type: object + description: The saved game state to restore + priority: + type: integer + description: Priority of the effect (lower numbers execute first) + default: 0 + required: [type, initialState] + additionalProperties: false + diff --git a/src/schemas/systemState/systemState.yaml b/src/schemas/systemState/systemState.yaml index f1a34624..57f818f0 100644 --- a/src/schemas/systemState/systemState.yaml +++ b/src/schemas/systemState/systemState.yaml @@ -45,22 +45,25 @@ properties: additionalProperties: true default: {} - saveData: + saveSlots: type: object - description: Additional save game data + description: Save game slots indexed by slotKey properties: - "^d+$": # slot number + "^.+$": # slot key (string like "1", "autosave", etc.) + type: object properties: - date: + slotKey: type: string - description: date + date: + type: integer + description: Unix timestamp image: type: string - description: base64 representation of the image + description: Base64 screenshot (null if none) state: type: object - description: full state - additionalProperties: true + description: Full system state + required: [slotKey, date, state] default: {} # TODO: don't remember why we need this for diff --git a/src/stores/system.store.js b/src/stores/system.store.js index c9edc808..2e8d0c3c 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -610,6 +610,56 @@ export const replaceSaveSlot = ({ state }, payload) => { return state; }; +/** + * Saves current game state to a slot + * @param {Object} state - Current state object + * @param {Object} payload - Action payload + * @param {number} payload.slot - Save slot number + * @returns {Object} Updated state object + */ +export const globalSaveStory = ({ state }, payload) => { + const { slot } = payload; + const slotKey = String(slot); + + const { saveSlots, ...globalWithoutSlots } = state.global; + const currentState = { + projectData: state.projectData, + global: { ...globalWithoutSlots, pendingEffects: [] }, + contexts: structuredClone(state.contexts) + }; + + state.global.saveSlots[slotKey] = { + slotKey, + date: Date.now(), + image: null, + state: currentState + }; + + state.global.pendingEffects.push({ name: 'render' }); + return state; +}; + +/** + * Loads game state from a save slot + * @param {Object} state - Current state object + * @param {Object} payload - Action payload + * @param {number} payload.slot - Save slot number + * @returns {Object} Updated state object + */ +export const globalLoadStory = ({ state }, payload) => { + const { slot } = payload; + const slotKey = String(slot); + const slotData = state.global.saveSlots[slotKey]; + + if (slotData) { + state.global.pendingEffects.push({ + name: 'loadGame', + options: { initialState: slotData.state } + }); + } + return state; +}; + /** * Updates the entire projectData with new data * @param {Object} state - Current state object @@ -1004,6 +1054,8 @@ export const createSystemStore = (initialState) => { addViewedResource, setNextLineConfig, replaceSaveSlot, + globalSaveStory, + globalLoadStory, updateProjectData, sectionTransition, jumpToLine, diff --git a/vt/specs/save/basic.yaml b/vt/specs/save/basic.yaml index 999eb1a4..2ae07352 100644 --- a/vt/specs/save/basic.yaml +++ b/vt/specs/save/basic.yaml @@ -71,11 +71,11 @@ resources: height: 150 x: 100 y: 300 - click: - actionPayload: - actions: - saveVnData: - slotIndex: 1 + eventName: system + eventPayload: + actions: + globalSaveStory: + slot: 1 - id: save-slot-1-text type: text content: ${l10n.keys.layoutSaveMenuSlot1} @@ -92,11 +92,11 @@ resources: height: 150 x: 100 y: 500 - click: - actionPayload: - actions: - saveVnData: - slotIndex: 2 + eventName: system + eventPayload: + actions: + globalSaveStory: + slot: 2 - id: save-slot-2-text type: text content: ${l10n.keys.layoutSaveMenuSlot2} @@ -113,11 +113,11 @@ resources: height: 150 x: 100 y: 700 - click: - actionPayload: - actions: - saveVnData: - slotIndex: 3 + eventName: system + eventPayload: + actions: + globalSaveStory: + slot: 3 - id: save-slot-3-text type: text content: ${l10n.keys.layoutSaveMenuSlot3} @@ -174,11 +174,11 @@ resources: height: 150 x: 100 y: 300 - click: - actionPayload: - actions: - loadVnData: - slotIndex: 1 + eventName: system + eventPayload: + actions: + globalLoadStory: + slot: 1 - id: load-slot-1-text type: text content: ${l10n.keys.layoutSaveMenuSlot1} @@ -195,11 +195,11 @@ resources: height: 150 x: 100 y: 500 - click: - actionPayload: - actions: - loadVnData: - slotIndex: 2 + eventName: system + eventPayload: + actions: + globalLoadStory: + slot: 2 - id: load-slot-2-text type: text content: ${l10n.keys.layoutSaveMenuSlot2} @@ -216,11 +216,11 @@ resources: height: 150 x: 100 y: 700 - click: - actionPayload: - actions: - loadVnData: - slotIndex: 3 + eventName: system + eventPayload: + actions: + globalLoadStory: + slot: 3 - id: load-slot-3-text type: text content: ${l10n.keys.layoutSaveMenuSlot3} diff --git a/vt/static/main.js b/vt/static/main.js index 682df7e9..4b82303f 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -243,6 +243,9 @@ const init = async () => { skipModeCallback = null; } skipModeElapsed = 0; + } else if (effect.name === 'loadGame') { + const { initialState } = effect.options; + engine.init({ initialState }); } } }; From e009bcc5698758b023f8d8484d7ecc159b856439 Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Fri, 26 Dec 2025 04:57:53 +0000 Subject: [PATCH 03/11] Update --- rettangoli.config.yaml | 2 + src/schemas/systemState/effects.yaml | 33 +- src/stores/effectHandlers.js | 21 +- src/stores/system.store.js | 41 +- vt/specs/save/basic.yaml | 627 ++++++++++++++------------- vt/static/main.js | 11 +- 6 files changed, 380 insertions(+), 355 deletions(-) diff --git a/rettangoli.config.yaml b/rettangoli.config.yaml index 8f71d4e0..9c3cc7af 100644 --- a/rettangoli.config.yaml +++ b/rettangoli.config.yaml @@ -36,3 +36,5 @@ vt: files: "bgm" - title: "SFX" files: "sfx" + - title: "Save" + files: "save" \ No newline at end of file diff --git a/src/schemas/systemState/effects.yaml b/src/schemas/systemState/effects.yaml index e3d374fb..7bd3190d 100644 --- a/src/schemas/systemState/effects.yaml +++ b/src/schemas/systemState/effects.yaml @@ -16,24 +16,37 @@ oneOf: additionalProperties: false - title: Save VN data effect - description: Saves visual novel data to a slot + description: Saves visual novel data to localStorage saveSlots type: object properties: - type: + name: const: saveVnData - saveData: + options: type: object - description: Save game data for saving - additionalProperties: true - slotIndex: - type: integer - description: Save slot index - minimum: 0 + properties: + saveData: + type: object + description: Save game data + properties: + date: + type: integer + description: Save timestamp + screenshot: + type: string + description: Screenshot in base64 format + state: + type: object + description: Game state including sectionId, lineId, history, and settings + additionalProperties: true + slotIndex: + type: integer + description: Save slot index + minimum: 0 priority: type: integer description: Priority of the effect (lower numbers execute first) default: 0 - required: [type, saveData, slotIndex] + required: [name] additionalProperties: false - title: Save variables effect diff --git a/src/stores/effectHandlers.js b/src/stores/effectHandlers.js index c8149c44..eb996b1f 100644 --- a/src/stores/effectHandlers.js +++ b/src/stores/effectHandlers.js @@ -9,25 +9,14 @@ export const render = ({ processAndRender }) => { }; export const saveVnData = async ( - { timer, localStorage, captureElement, loadAssets }, + { timer, localStorage }, effect, ) => { - const { saveData: _saveData, slotIndex } = effect.options; - const saveData = structuredClone(_saveData); - const url = await captureElement("story"); - console.log("saveData", saveData); - console.log("slotindex", slotIndex); - saveData[slotIndex].image = url; - const assets = { - [`saveImage:${slotIndex}`]: { - buffer: base64ToArrayBuffer(url), - type: "image/png", - }, - }; - await loadAssets(assets); - localStorage.setItem("saveData", JSON.stringify(saveData)); + const { saveSlots } = effect.options; + + localStorage.setItem("saveSlots", JSON.stringify(saveSlots)); timer.setTimeout( - "saveData", + "saveSlots", { render: {}, }, diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 2e8d0c3c..0093c961 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -19,6 +19,8 @@ export const createInitialState = (payload) => { .lines[0].id, } + const saveSlots = JSON.parse(localStorage.getItem("saveSlots")) || {}; + const state = { projectData, global: { @@ -42,7 +44,7 @@ export const createInitialState = (payload) => { enabled: false, } }, - saveSlots: {}, + saveSlots, layeredViews: [], }, contexts: [{ @@ -62,6 +64,7 @@ export const createInitialState = (payload) => { variables: {}, }] }; + console.log('Initial State:', state); return state; }; @@ -621,21 +624,37 @@ export const globalSaveStory = ({ state }, payload) => { const { slot } = payload; const slotKey = String(slot); - const { saveSlots, ...globalWithoutSlots } = state.global; const currentState = { projectData: state.projectData, - global: { ...globalWithoutSlots, pendingEffects: [] }, - contexts: structuredClone(state.contexts) + contexts: [...state.contexts] }; - state.global.saveSlots[slotKey] = { + // const url = await captureElement("story"); + // console.log("slotindex", slotIndex); + // saveData[slotIndex].image = url; + // const assets = { + // [`saveImage:${slotIndex}`]: { + // buffer: base64ToArrayBuffer(url), + // type: "image/png", + // }, + // }; + // await loadAssets(assets); + + const saveData = { slotKey, date: Date.now(), image: null, state: currentState }; - state.global.pendingEffects.push({ name: 'render' }); + state.global.saveSlots[slotKey] = saveData; + + localStorage.setItem("saveSlots", JSON.stringify(state.global.saveSlots)); + + + state.global.pendingEffects.push( + { name: 'render' }, + ); return state; }; @@ -650,12 +669,12 @@ export const globalLoadStory = ({ state }, payload) => { const { slot } = payload; const slotKey = String(slot); const slotData = state.global.saveSlots[slotKey]; - if (slotData) { - state.global.pendingEffects.push({ - name: 'loadGame', - options: { initialState: slotData.state } - }); + state.projectData = slotData.state.projectData; + state.contexts = slotData.state.contexts; + state.global.pendingEffects.push( + { name: 'render' }, + ); } return state; }; diff --git a/vt/specs/save/basic.yaml b/vt/specs/save/basic.yaml index 2ae07352..b411b5d1 100644 --- a/vt/specs/save/basic.yaml +++ b/vt/specs/save/basic.yaml @@ -1,5 +1,5 @@ --- -title: "Save and Load Test" +title: "Save/Load Test" --- l10n: packages: @@ -7,18 +7,17 @@ l10n: label: English lang: en keys: - layoutSaveMenuTitle: "Save Game" - layoutLoadMenuTitle: "Load Game" - layoutSaveMenuSlot1: "Slot 1" - layoutSaveMenuSlot2: "Slot 2" - layoutSaveMenuSlot3: "Slot 3" - layoutSaveMenuBack: "Back" - sceneSection1Line1: "This is line 1 of the story." - sceneSection1Line2: "This is line 2 - you can save here." - sceneSection1Line3: "This is line 3 - progress continues." - sceneSection2Line1: "This is Section 2, Line 1." - sceneSection2Line2: "If you loaded correctly, you should see this!" - sceneSection2Line3: "The save/load system is working!" + line1: "Line 1: Welcome to Save/Load test" + line2: "Line 2: Click Save in top right to save your game" + line3: "Line 3: Choose a slot, then click X to close" + line4: "Line 4: Click Load to load a saved game" + line5: "Line 5: Try saving now, then continue to next section" + line6: "Line 6: This is section 2 - loading returns you to section 1" + saveButton: "Save" + loadButton: "Load" + saveTitle: "Save Game" + loadTitle: "Load Game" + closeHint: "Click X to close" screen: width: 1920 @@ -26,424 +25,428 @@ screen: backgroundColor: "#000000" resources: - variables: - currentSaveSlot: - type: number - default: -1 + characters: + narrator: + name: "" layouts: - base1: + baseLayout: elements: - - id: af32 + - id: background type: rect - fill: "#000000" + x: 0 + y: 0 width: 1920 height: 1080 + fill: "#000000" click: actionPayload: actions: nextLine: {} - # Save Menu Layout + dialogueLayout: + name: Dialogue Screen + mode: adv + elements: + - id: dialogue-box + type: rect + x: 100 + y: 800 + width: 1720 + height: 200 + fill: "#333333" + + - id: dialogue-text + type: text + x: 150 + y: 850 + content: ${dialogue.content[0].text} + textStyle: + fontSize: 32 + fill: "white" + + - id: save-button + type: rect + x: 1780 + y: 20 + width: 100 + height: 50 + fill: "#4CAF50" + hover: + fill: "#66BB6A" + click: + actionPayload: + actions: + pushLayeredView: + resourceId: saveMenuLayout + resourceType: layout + + - id: save-button-text + type: text + x: 1810 + y: 45 + content: ${l10n.keys.saveButton} + textStyle: + fontSize: 24 + fill: "white" + + - id: load-button + type: rect + x: 1660 + y: 20 + width: 100 + height: 50 + fill: "#2196F3" + hover: + fill: "#42A5F5" + click: + actionPayload: + actions: + pushLayeredView: + resourceId: loadMenuLayout + resourceType: layout + + - id: load-button-text + type: text + x: 1690 + y: 45 + content: ${l10n.keys.loadButton} + textStyle: + fontSize: 24 + fill: "white" + saveMenuLayout: - name: Save Menu elements: - - id: save-menu-bg + - id: save-menu-overlay type: rect - fill: "#1a1a2e" + fill: "rgba(0,0,0,0.7)" width: 1920 height: 1080 x: 0 y: 0 + + - id: save-menu-bg + type: rect + fill: "#1a1a2e" + width: 800 + height: 500 + x: 560 + y: 290 + - id: save-menu-title type: text - content: ${l10n.keys.layoutSaveMenuTitle} - x: 800 - y: 100 + content: ${l10n.keys.saveTitle} + x: 900 + y: 340 textStyle: - fontSize: 64 + fontSize: 48 fill: "#ffffff" - # Save Slot 1 + + - id: close-btn + type: rect + fill: "#F44336" + width: 50 + height: 50 + x: 1290 + y: 300 + hover: + fill: "#EF5350" + click: + actionPayload: + actions: + clearLayeredViews: {} + + - id: close-text + type: text + content: "X" + x: 1305 + y: 330 + textStyle: + fontSize: 32 + fill: "#ffffff" + - id: save-slot-1-btn type: rect fill: "#4CAF50" width: 600 - height: 150 - x: 100 - y: 300 - eventName: system - eventPayload: - actions: - globalSaveStory: - slot: 1 + height: 70 + x: 660 + y: 400 + hover: + fill: "#66BB6A" + click: + actionPayload: + actions: + globalSaveStory: + slot: 1 + - id: save-slot-1-text type: text - content: ${l10n.keys.layoutSaveMenuSlot1} - x: 300 - y: 360 + content: "Slot 1" + x: 920 + y: 445 textStyle: - fontSize: 36 + fontSize: 32 fill: "#ffffff" - # Save Slot 2 + - id: save-slot-2-btn type: rect fill: "#2196F3" width: 600 - height: 150 - x: 100 - y: 500 - eventName: system - eventPayload: - actions: - globalSaveStory: - slot: 2 + height: 70 + x: 660 + y: 490 + hover: + fill: "#42A5F5" + click: + actionPayload: + actions: + globalSaveStory: + slot: 2 + - id: save-slot-2-text type: text - content: ${l10n.keys.layoutSaveMenuSlot2} - x: 300 - y: 560 + content: "Slot 2" + x: 920 + y: 535 textStyle: - fontSize: 36 + fontSize: 32 fill: "#ffffff" - # Save Slot 3 + - id: save-slot-3-btn type: rect fill: "#FF9800" width: 600 - height: 150 - x: 100 - y: 700 - eventName: system - eventPayload: - actions: - globalSaveStory: - slot: 3 + height: 70 + x: 660 + y: 580 + hover: + fill: "#FFB74D" + click: + actionPayload: + actions: + globalSaveStory: + slot: 3 + - id: save-slot-3-text type: text - content: ${l10n.keys.layoutSaveMenuSlot3} - x: 300 - y: 760 + content: "Slot 3" + x: 920 + y: 625 textStyle: - fontSize: 36 + fontSize: 32 fill: "#ffffff" - # Back Button - - id: save-menu-back-btn - type: rect - fill: "#F44336" - width: 300 - height: 100 - x: 1200 - y: 800 - eventName: system - eventPayload: - actions: - popViewLayer: {} - - id: save-menu-back-text + + - id: save-menu-info type: text - content: ${l10n.keys.layoutSaveMenuBack} - x: 1270 - y: 850 + content: ${l10n.keys.closeHint} + x: 880 + y: 730 textStyle: - fontSize: 32 - fill: "#ffffff" + fontSize: 20 + fill: "#aaaaaa" - # Load Menu Layout loadMenuLayout: - name: Load Menu elements: - - id: load-menu-bg + - id: load-menu-overlay type: rect - fill: "#1a1a2e" + fill: "rgba(0,0,0,0.7)" width: 1920 height: 1080 x: 0 y: 0 + + - id: load-menu-bg + type: rect + fill: "#1a1a2e" + width: 800 + height: 500 + x: 560 + y: 290 + - id: load-menu-title type: text - content: ${l10n.keys.layoutLoadMenuTitle} - x: 800 - y: 100 + content: ${l10n.keys.loadTitle} + x: 900 + y: 340 + textStyle: + fontSize: 48 + fill: "#ffffff" + + - id: load-close-btn + type: rect + fill: "#F44336" + width: 50 + height: 50 + x: 1290 + y: 300 + hover: + fill: "#EF5350" + click: + actionPayload: + actions: + clearLayeredViews: {} + + - id: load-close-text + type: text + content: "X" + x: 1305 + y: 330 textStyle: - fontSize: 64 + fontSize: 32 fill: "#ffffff" - # Load Slot 1 + - id: load-slot-1-btn type: rect fill: "#9C27B0" width: 600 - height: 150 - x: 100 - y: 300 - eventName: system - eventPayload: - actions: - globalLoadStory: - slot: 1 + height: 70 + x: 660 + y: 400 + hover: + fill: "#AB47BC" + click: + actionPayload: + actions: + globalLoadStory: + slot: 1 + - id: load-slot-1-text type: text - content: ${l10n.keys.layoutSaveMenuSlot1} - x: 300 - y: 360 + content: "Slot 1" + x: 920 + y: 445 textStyle: - fontSize: 36 + fontSize: 32 fill: "#ffffff" - # Load Slot 2 + - id: load-slot-2-btn type: rect fill: "#E91E63" width: 600 - height: 150 - x: 100 - y: 500 - eventName: system - eventPayload: - actions: - globalLoadStory: - slot: 2 + height: 70 + x: 660 + y: 490 + hover: + fill: "#EC407A" + click: + actionPayload: + actions: + globalLoadStory: + slot: 2 + - id: load-slot-2-text type: text - content: ${l10n.keys.layoutSaveMenuSlot2} - x: 300 - y: 560 + content: "Slot 2" + x: 920 + y: 535 textStyle: - fontSize: 36 + fontSize: 32 fill: "#ffffff" - # Load Slot 3 + - id: load-slot-3-btn type: rect fill: "#673AB7" width: 600 - height: 150 - x: 100 - y: 700 - eventName: system - eventPayload: - actions: - globalLoadStory: - slot: 3 + height: 70 + x: 660 + y: 580 + hover: + fill: "#7E57C2" + click: + actionPayload: + actions: + globalLoadStory: + slot: 3 + - id: load-slot-3-text type: text - content: ${l10n.keys.layoutSaveMenuSlot3} - x: 300 - y: 760 - textStyle: - fontSize: 36 - fill: "#ffffff" - # Back Button - - id: load-menu-back-btn - type: rect - fill: "#F44336" - width: 300 - height: 100 - x: 1200 - y: 800 - eventName: system - eventPayload: - actions: - popViewLayer: {} - - id: load-menu-back-text - type: text - content: ${l10n.keys.layoutSaveMenuBack} - x: 1270 - y: 850 + content: "Slot 3" + x: 920 + y: 625 textStyle: fontSize: 32 fill: "#ffffff" - # Main Menu with Save/Load Options - mainMenuLayout: - name: Main Menu - elements: - - id: main-menu-bg - type: rect - fill: "#16213e" - width: 1920 - height: 1080 - x: 0 - y: 0 - - id: main-menu-title + - id: load-menu-info type: text - content: "Save/Load Test" - x: 700 - y: 200 + content: ${l10n.keys.closeHint} + x: 880 + y: 730 textStyle: - fontSize: 72 - fill: "#ffffff" - # Start Button - - id: start-btn - type: rect - fill: "#4CAF50" - width: 400 - height: 80 - x: 760 - y: 350 - eventName: system - eventPayload: - actions: - sectionTransition: - sectionId: section1 - - id: start-text - type: text - content: "Start Game" - x: 870 - y: 390 - textStyle: - fontSize: 36 - fill: "#ffffff" - # Save Button - - id: save-btn - type: rect - fill: "#2196F3" - width: 400 - height: 80 - x: 760 - y: 470 - eventName: system - eventPayload: - actions: - pushViewLayer: - resourceId: saveMenuLayout - - id: save-text - type: text - content: "Save Game" - x: 870 - y: 510 - textStyle: - fontSize: 36 - fill: "#ffffff" - # Load Button - - id: load-btn - type: rect - fill: "#FF9800" - width: 400 - height: 80 - x: 760 - y: 590 - eventName: system - eventPayload: - actions: - pushViewLayer: - resourceId: loadMenuLayout - - id: load-text - type: text - content: "Load Game" - x: 870 - y: 630 - textStyle: - fontSize: 36 - fill: "#ffffff" - - dialogueLayout: - name: Dialogue - mode: adv - elements: - - id: dialogue-container - type: container - x: 50 - y: 300 - children: - - id: "dialogue-bg" - type: sprite - url: 3kda832 - width: 1400 - height: 300 - x: 0 - y: 0 - - id: dialogue-character-name - type: text - content: "${dialogue.character.name}" - x: 20 - y: 40 - textStyle: - fontSize: 24 - fill: "white" - - id: dialogue-text - type: text - x: 20 - y: 100 - content: "${dialogue.content[0].text}" - textStyle: - fontSize: 24 - fill: "white" - - characters: - ajf34a: - name: Narrator + fontSize: 20 + fill: "#aaaaaa" story: - initialSceneId: testScene + initialSceneId: test_scene scenes: - testScene: - name: Test Scene - initialSectionId: mainMenu + test_scene: + initialSectionId: test_section sections: - mainMenu: - name: Main Menu - lines: - - id: line1 - actions: - layout: - resourceId: mainMenuLayout - - section1: - name: Section 1 + test_section: lines: - id: line1 actions: base: - resourceId: base1 + resourceId: baseLayout dialogue: + mode: adv gui: resourceId: dialogueLayout - mode: adv content: - - text: ${l10n.keys.sceneSection1Line1} - characterId: ajf34a + - text: ${l10n.keys.line1} + characterId: narrator + - id: line2 actions: dialogue: content: - - text: ${l10n.keys.sceneSection1Line2} - characterId: ajf34a + - text: ${l10n.keys.line2} + characterId: narrator + - id: line3 actions: dialogue: content: - - text: ${l10n.keys.sceneSection1Line3} - characterId: ajf34a - # Automatically show save menu at end of section - updateVariable: - operations: - - variableId: currentSaveSlot - op: set - value: 1 + - text: ${l10n.keys.line3} + characterId: narrator + + - id: line4 + actions: + dialogue: + content: + - text: ${l10n.keys.line4} + characterId: narrator + + - id: line5 + actions: + dialogue: + content: + - text: ${l10n.keys.line5} + characterId: narrator + + - id: line6 + actions: + sectionTransition: + sectionId: section2 section2: - name: Section 2 (After Loading) lines: - id: line1 actions: - base: - resourceId: base1 dialogue: - gui: - resourceId: dialogueLayout - mode: adv content: - - text: ${l10n.keys.sceneSection2Line1} - characterId: ajf34a + - text: ${l10n.keys.line6} + characterId: narrator + - id: line2 actions: dialogue: content: - - text: ${l10n.keys.sceneSection2Line2} - characterId: ajf34a + - text: ${l10n.keys.line1} + characterId: narrator + - id: line3 actions: - dialogue: - content: - - text: ${l10n.keys.sceneSection2Line3} - characterId: ajf34a - # Loop back to main menu sectionTransition: - sectionId: mainMenu \ No newline at end of file + sectionId: test_section \ No newline at end of file diff --git a/vt/static/main.js b/vt/static/main.js index 4b82303f..1155834e 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -165,8 +165,6 @@ const init = async () => { let skipModeElapsed = 0; let skipModeCallback = null; - // Save data storage - const saveSlots = {}; return (effects) => { // Deduplicate effects by name, keeping only the last occurrence @@ -243,10 +241,11 @@ const init = async () => { skipModeCallback = null; } skipModeElapsed = 0; - } else if (effect.name === 'loadGame') { - const { initialState } = effect.options; - engine.init({ initialState }); - } + } + // else if (effect.name === 'loadGame') { + // const { initialState } = effect.options; + // engine.init({ initialState }); + // } } }; }; From d79d545fa550b2d71065c32b26b3eff5bf3f4305 Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Fri, 26 Dec 2025 05:06:53 +0000 Subject: [PATCH 04/11] Update --- docs/FileGuide.md | 381 ---------------------------------------------- vt/static/main.js | 97 +----------- 2 files changed, 1 insertion(+), 477 deletions(-) delete mode 100644 docs/FileGuide.md diff --git a/docs/FileGuide.md b/docs/FileGuide.md deleted file mode 100644 index 706e6c24..00000000 --- a/docs/FileGuide.md +++ /dev/null @@ -1,381 +0,0 @@ -# File Guide - -This guide explains each file in the Route Engine codebase, when to use it, and why. - -## Quick Reference - -| File | Purpose | Use When... | -|------|---------|-------------| -| `src/index.js` | Package entry point | Importing the engine | -| `src/RouteEngine.js` | Engine factory | Creating engine instances | -| `src/util.js` | Store & action utilities | Building custom state management | -| `src/createTimer.js` | Timer system | Scheduling delayed actions | -| `src/stores/system.store.js` | Core state store | Reading/managing game state | -| `src/stores/constructPresentationState.js` | Presentation builder | Converting actions to display data | -| `src/stores/constructRenderState.js` | Render builder | Converting presentation to renderer output | -| `src/stores/effectHandlers.js` | Side effect handlers | Processing game events (save, render, timers) | - ---- - -## Core Files - -### `src/index.js` -**Main entry point** - Exports `createRouteEngine` factory function. - -```js -import createRouteEngine from 'route-engine-js'; -``` - -**Why use it:** This is the only file you need to import to use the engine. - ---- - -### `src/RouteEngine.js` -**Engine factory** - Creates RouteEngine instances with effect handling. - -```js -const engine = createRouteEngine({ - handlePendingEffects: (effects) => { - effects.forEach(effect => { - if (effect.name === 'render') { - // Update your renderer - } - }); - } -}); - -engine.init({ initialState }); -``` - -**API Methods:** -| Method | Purpose | -|--------|---------| -| `init({ initialState })` | Initialize engine with project data | -| `handleAction(type, payload)` | Dispatch a single action | -| `handleActions(actions)` | Dispatch multiple actions at once | -| `selectPresentationState()` | Get current presentation state | -| `selectRenderState()` | Get renderer-ready state | -| `handleLineActions()` | Process current line's actions | - -**Why use it:** Creates isolated engine instances for multiple games or contexts. - ---- - -### `src/util.js` -**State management utilities** - Store builders and action executors. - -#### `createStore(initialState, selectorsAndActions, options)` -Creates a store with selectors and actions from a single object. - -```js -const store = createStore(initialState, { - selectCount: (state) => state.count, - increment: (state) => { state.count++; } -}); - -store.increment(); -console.log(store.selectCount()); -``` - -**Functions starting with `select`** become selectors (read state). -**All other functions** become actions (mutate state via Immer). - -**Why use it:** -- Build custom stores for game-specific state -- Leverage Immer for immutable updates -- Automatic selector/action separation - -#### `createSequentialActionsExecutor(createInitialState, actions)` -Applies all actions to each payload sequentially. - -```js -const executor = createSequentialActionsExecutor( - () => ({ items: [], total: 0 }), - [ - (state, item) => { state.items.push(item); }, - (state, item) => { state.total += item.value; } - ] -); - -const result = executor([{ id: 1, value: 10 }, { id: 2, value: 20 }]); -// Result: { items: [...], total: 30 } -``` - -**Why use it:** -- Processing presentation actions that accumulate (dialogue history) -- Building derived state from a sequence of payloads - -#### `createSelectiveActionsExecutor(deps, actions, createInitialState)` -Applies only specified actions with their payloads. - -```js -const executor = createSelectiveActionsExecutor( - { api: myApi }, - { - setUser: (state, deps, payload) => { state.user = payload; }, - setTheme: (state, deps, payload) => { state.theme = payload; } - }, - () => ({ user: null, theme: 'light' }) -); - -const result = executor({ - setUser: { name: 'Alice' }, - // setTheme not called - no payload provided -}); -// Result: { user: { name: 'Alice' }, theme: 'light' } -``` - -**Why use it:** -- System action handling (only run specified actions) -- Batch state updates with independent changes - ---- - -### `src/createTimer.js` -**Timer system** - Creates timers backed by PixiJS Ticker. - -```js -import { Ticker } from 'pixi.js'; -import { createTimer } from 'route-engine-js/util'; - -const ticker = new Ticker(); -const timer = createTimer(ticker); - -timer.start({ - timerId: 'auto-advance', - delay: 1000, - onComplete: () => engine.handleAction('nextLine') -}); - -timer.clear('auto-advance'); -``` - -**Why use it:** -- Auto-advance delays after dialogue completes -- Skip mode fast-forward timing -- Any game logic needing precise timing synced to render loop - ---- - -## Store Files - -### `src/stores/system.store.js` -**Core state management** - The heart of the engine (1031 lines). - -**Exports:** -- `createSystemStore(initialState)` - Creates the main store - -**Selectors** (30+): -| Selector | Returns | -|----------|---------| -| `selectPresentationState()` | Current presentation state | -| `selectRenderState()` | Renderer-ready state | -| `selectCurrentLine()` | Current line data | -| `selectCurrentPointer()` | Current story position | -| `selectViewedRegistry()` | Tracking for skip/gallery | -| `selectSaveSlots()` | All save data | -| `selectVariables()` | Game variables | -| `selectIsInAutoMode()` / `selectIsInSkipMode()` | Playback state | - -**Actions** (30+): -| Action | Purpose | -|--------|---------| -| `nextLine` / `prevLine` | Navigate story | -| `jumpToLine({ sectionId, lineId })` | Jump to position | -| `sectionTransition({ sectionId })` | Change sections | -| `toggleAutoMode` / `toggleSkipMode` | Playback controls | -| `markLineCompleted` | Track animation finish | -| `addViewedLine` / `addViewedResource` | Registry updates | -| `replaceSaveSlot` | Save/load games | -| `pushLayeredView` / `popLayeredView` | UI overlays | -| `appendPendingEffect` | Queue side effects | - -**Why use it:** -- Access all game state through selectors -- Control game flow through actions -- Understand engine behavior - -### `src/stores/constructPresentationState.js` -**Builds presentation state** from line actions. - -**Exports:** -- `constructPresentationState(projectData, systemState, presentations)` - -**What it does:** -1. Iterates through all lines from section start to current line -2. Applies each line's presentation actions in sequence -3. Handles action overrides (later actions replace earlier ones) -4. Accumulates dialogue content for NVL mode - -**Presentation Actions Handled:** -- `base` - Layout configuration -- `background` - Background images with animations -- `dialogue` - Speaker, text, mode (ADV/NVL) -- `character` - Sprite placement with transforms -- `visual` - Overlay images -- `bgm` / `sfx` / `voice` - Audio -- `choice` - Branching options -- `animation` - Active tweens -- `layout` - UI layouts - -**Why use it:** -- Understand how actions become display data -- Debug presentation issues -- Build custom presentation logic - -### `src/stores/constructRenderState.js` -**Builds render state** from presentation state. - -**Exports:** -- `constructRenderState(projectData, systemState, presentationState)` - -**What it does:** -1. Resolves resource IDs to file paths -2. Applies localization translations -3. Creates element tree (containers, sprites, text) -4. Converts animations to tween keyframes -5. Builds audio playback instructions - -**Why use it:** -- Bridge between engine data and renderer -- Debug rendering issues -- Integrate with custom renderers - -### `src/stores/effectHandlers.js` -**Side effect handlers** - Pure functions for processing effects. - -**Exports:** -- `handleEffect(effect, context)` - Main handler - -**Effects Handled:** -| Effect | Handler | Purpose | -|--------|---------|---------| -| `render` | - | Trigger re-render (handled externally) | -| `handleLineActions` | - | Process current line actions | -| `saveVnData` | `handleSaveVnDataEffect` | Save game with screenshot | -| `saveVariables` | `handleSaveVariablesEffect` | Save device variables | -| `startAutoNextTimer` | `handleStartAutoNextTimer` | Schedule auto-advance | -| `clearAutoNextTimer` | `handleClearAutoNextTimer` | Cancel auto-advance | -| `startSkipNextTimer` | `handleStartSkipNextTimer` | Schedule skip advance | -| `clearSkipNextTimer` | `handleClearSkipNextTimer` | Cancel skip advance | -| `startTimer` | `handleStartTimer` | Custom timer | - -**Why use it:** -- Understand side effect lifecycle -- Add custom effect types -- Debug save/timer issues - ---- - -## Schema Files - -Located in `src/schemas/`, these YAML files define the shape of data. - -### `schemas/projectData/` -| File | Defines | -|------|---------| -| `projectData.yaml` | Overall project structure | -| `story.yaml` | Scenes, sections, lines structure | -| `resources.yaml` | Images, sounds, characters, transforms, etc. | -| `i18n.yaml` | Localization package format | -| `mode.yaml` | Display mode configuration | - -### `schemas/systemState/` -| File | Defines | -|------|---------| -| `systemState.yaml` | System state structure | -| `configuration.yaml` | Config options | -| `effects.yaml` | Effect definitions | - -### Action Schemas -| File | Defines | -|------|---------| -| `systemActions.yaml` | All system action schemas | -| `presentationActions.yaml` | All presentation action schemas | - -**Why use them:** -- Validate project data before loading -- Understand expected data shapes -- Generate TypeScript types -- IDE autocomplete/integration - ---- - -## Common Patterns - -### Creating a Game - -```js -import createRouteEngine from 'route-engine-js'; -import projectData from './game/project.yaml'; - -const engine = createRouteEngine({ - handlePendingEffects: (effects) => { - effects.forEach(effect => { - switch (effect.name) { - case 'render': - const renderState = engine.selectRenderState(); - renderer.render(renderState); - break; - case 'saveVnData': - saveGame(effect.payload.slotKey, effect.payload.state); - break; - case 'startAutoNextTimer': - timer.start(effect.payload); - break; - } - }); - } -}); - -engine.init({ - initialState: { - global: { currentLocalizationPackageId: 'en' }, - projectData - } -}); -``` - -### Adding Custom Actions - -Extend the system store with your own actions: - -```js -import { createSystemStore } from 'route-engine-js/stores/system.store.js'; - -const baseStore = createSystemStore(initialState); - -const customStore = { - ...baseStore, - setPlayerHealth: (health) => { - // Your custom logic - } -}; -``` - -### Building Custom Selectors - -```js -import { createStore } from 'route-engine-js/util'; - -const gameState = createStore( - initialState, - { - selectCurrentHP: (state) => state.variables.hp, - selectIsDead: (state) => state.variables.hp <= 0, - selectHasItem: (state, itemId) => - state.variables.inventory.includes(itemId) - } -); -``` - ---- - -## Summary - -- **Use `src/index.js`** to import the engine -- **Use `src/RouteEngine.js`** to create engine instances -- **Use `src/util.js`** for custom state management -- **Use `src/createTimer.js`** for timed events -- **Read `src/stores/`** to understand engine internals -- **Read `src/schemas/`** to understand data structures \ No newline at end of file diff --git a/vt/static/main.js b/vt/static/main.js index 52a053c0..2318dda8 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -155,102 +155,7 @@ const init = async () => { e.preventDefault(); }); - // Create effectsHandler with closure to persist timer state - const createEffectsHandler = () => { - // Auto mode state (persisted across calls via closure) - let autoModeElapsed = 0; - let autoModeCallback = null; - - // Skip mode state (persisted across calls via closure) - let skipModeElapsed = 0; - let skipModeCallback = null; - - - return (effects) => { - // Deduplicate effects by name, keeping only the last occurrence - const deduplicatedEffects = effects.reduce((acc, effect) => { - acc[effect.name] = effect; - return acc; - }, {}); - - // Convert back to array and process deduplicated effects - const uniqueEffects = Object.values(deduplicatedEffects); - - for (const effect of uniqueEffects) { - if (effect.name === 'render') { - const renderState = engine.selectRenderState(); - routeGraphics.render(renderState); - } else if (effect.name === 'handleLineActions') { - engine.handleLineActions(); - } else if (effect.name === 'startAutoNextTimer') { - // Remove old callback if exists - if (autoModeCallback) { - ticker.remove(autoModeCallback); - } - - // Reset elapsed time - autoModeElapsed = 0; - - // Create new ticker callback for auto mode - autoModeCallback = (time) => { - autoModeElapsed += time.deltaMS; - - // Auto advance every 1000ms (1 second) - hardcoded - // TODO: Speed can adjust in the future - if (autoModeElapsed >= 1000) { - autoModeElapsed = 0; - engine.handleAction('nextLine', {}); - } - }; - - // Add to auto ticker - ticker.add(autoModeCallback); - } else if (effect.name === 'clearAutoNextTimer') { - // Remove ticker callback - if (autoModeCallback) { - ticker.remove(autoModeCallback); - autoModeCallback = null; - } - autoModeElapsed = 0; - } else if (effect.name === 'startSkipNextTimer') { - // Remove old callback if exists - if (skipModeCallback) { - ticker.remove(skipModeCallback); - } - - // Reset elapsed time - skipModeElapsed = 0; - - // Create new ticker callback for skip mode - skipModeCallback = (time) => { - skipModeElapsed += time.deltaMS; - - // Skip advance every 30ms - if (skipModeElapsed >= 30) { - skipModeElapsed = 0; - engine.handleAction('nextLine', {}); - } - }; - - // Add to skip ticker - ticker.add(skipModeCallback); - } else if (effect.name === 'clearSkipNextTimer') { - // Remove ticker callback - if (skipModeCallback) { - ticker.remove(skipModeCallback); - skipModeCallback = null; - } - skipModeElapsed = 0; - } - // else if (effect.name === 'loadGame') { - // const { initialState } = effect.options; - // engine.init({ initialState }); - // } - } - }; - }; - - const effectsHandler = createEffectsHandler(); + const effectsHandler = createEffectsHandler({ getEngine: () => engine, routeGraphics, ticker }); const engine = createRouteEngine({ handlePendingEffects: effectsHandler }); engine.init({ From 4c3c9732124b6f50a2ded0e8b17b3463426c84be Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Fri, 26 Dec 2025 05:59:10 +0000 Subject: [PATCH 05/11] Change name and get the saveSlot data from main.js --- src/schemas/systemActions.yaml | 4 ++-- src/stores/system.store.js | 12 +++++------- vt/specs/save/basic.yaml | 32 ++++++++++++++++---------------- vt/static/main.js | 4 +++- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/schemas/systemActions.yaml b/src/schemas/systemActions.yaml index f15de9ae..c3d23ac9 100644 --- a/src/schemas/systemActions.yaml +++ b/src/schemas/systemActions.yaml @@ -159,7 +159,7 @@ properties: # Save and Load ############ # TODO: finalize naming - globalSaveStory: + saveSaveSlot: type: object description: Save current story state. See the side effect for all the data it stores properties: @@ -171,7 +171,7 @@ properties: additionalProperties: false # TODO: finalize naming - globalLoadStory: + loadSaveSlot: type: object description: Load game state from a save slot properties: diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 3ecc5533..f8c2c445 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -4,7 +4,7 @@ import { constructRenderState } from "./constructRenderState.js"; export const createInitialState = (payload) => { const { - global: { currentLocalizationPackageId }, + global: { currentLocalizationPackageId, saveSlots }, // initialPointer, projectData, } = payload; @@ -21,8 +21,6 @@ export const createInitialState = (payload) => { ].lines[0].id, }; - const saveSlots = JSON.parse(localStorage.getItem("saveSlots")) || {}; - const state = { projectData, global: { @@ -691,7 +689,7 @@ export const replaceSaveSlot = ({ state }, payload) => { * @param {number} payload.slot - Save slot number * @returns {Object} Updated state object */ -export const globalSaveStory = ({ state }, payload) => { +export const saveSaveSlot = ({ state }, payload) => { const { slot } = payload; const slotKey = String(slot); @@ -736,7 +734,7 @@ export const globalSaveStory = ({ state }, payload) => { * @param {number} payload.slot - Save slot number * @returns {Object} Updated state object */ -export const globalLoadStory = ({ state }, payload) => { +export const loadSaveSlot = ({ state }, payload) => { const { slot } = payload; const slotKey = String(slot); const slotData = state.global.saveSlots[slotKey]; @@ -1188,8 +1186,8 @@ export const createSystemStore = (initialState) => { addViewedResource, setNextLineConfig, replaceSaveSlot, - globalSaveStory, - globalLoadStory, + saveSaveSlot, + loadSaveSlot, updateProjectData, sectionTransition, jumpToLine, diff --git a/vt/specs/save/basic.yaml b/vt/specs/save/basic.yaml index b411b5d1..146ac2e7 100644 --- a/vt/specs/save/basic.yaml +++ b/vt/specs/save/basic.yaml @@ -84,7 +84,7 @@ resources: - id: save-button-text type: text x: 1810 - y: 45 + y: 35 content: ${l10n.keys.saveButton} textStyle: fontSize: 24 @@ -109,7 +109,7 @@ resources: - id: load-button-text type: text x: 1690 - y: 45 + y: 35 content: ${l10n.keys.loadButton} textStyle: fontSize: 24 @@ -160,7 +160,7 @@ resources: type: text content: "X" x: 1305 - y: 330 + y: 310 textStyle: fontSize: 32 fill: "#ffffff" @@ -177,14 +177,14 @@ resources: click: actionPayload: actions: - globalSaveStory: + saveSaveSlot: slot: 1 - id: save-slot-1-text type: text content: "Slot 1" x: 920 - y: 445 + y: 420 textStyle: fontSize: 32 fill: "#ffffff" @@ -201,14 +201,14 @@ resources: click: actionPayload: actions: - globalSaveStory: + saveSaveSlot: slot: 2 - id: save-slot-2-text type: text content: "Slot 2" x: 920 - y: 535 + y: 510 textStyle: fontSize: 32 fill: "#ffffff" @@ -225,14 +225,14 @@ resources: click: actionPayload: actions: - globalSaveStory: + saveSaveSlot: slot: 3 - id: save-slot-3-text type: text content: "Slot 3" x: 920 - y: 625 + y: 600 textStyle: fontSize: 32 fill: "#ffffff" @@ -291,7 +291,7 @@ resources: type: text content: "X" x: 1305 - y: 330 + y: 310 textStyle: fontSize: 32 fill: "#ffffff" @@ -308,14 +308,14 @@ resources: click: actionPayload: actions: - globalLoadStory: + loadSaveSlot: slot: 1 - id: load-slot-1-text type: text content: "Slot 1" x: 920 - y: 445 + y: 420 textStyle: fontSize: 32 fill: "#ffffff" @@ -332,14 +332,14 @@ resources: click: actionPayload: actions: - globalLoadStory: + loadSaveSlot: slot: 2 - id: load-slot-2-text type: text content: "Slot 2" x: 920 - y: 535 + y: 510 textStyle: fontSize: 32 fill: "#ffffff" @@ -356,14 +356,14 @@ resources: click: actionPayload: actions: - globalLoadStory: + loadSaveSlot: slot: 3 - id: load-slot-3-text type: text content: "Slot 3" x: 920 - y: 625 + y: 600 textStyle: fontSize: 32 fill: "#ffffff" diff --git a/vt/static/main.js b/vt/static/main.js index 2318dda8..07b12d33 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -157,11 +157,13 @@ const init = async () => { const effectsHandler = createEffectsHandler({ getEngine: () => engine, routeGraphics, ticker }); const engine = createRouteEngine({ handlePendingEffects: effectsHandler }); + const saveSlots = JSON.parse(localStorage.getItem("saveSlots")) || {}; engine.init({ initialState: { global: { - currentLocalizationPackageId: 'eklekfjwalefj' + currentLocalizationPackageId: 'eklekfjwalefj', + saveSlots }, projectData } From c46c901321c549fcf88237850af637f0527f601e Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Fri, 26 Dec 2025 07:32:15 +0000 Subject: [PATCH 06/11] Move local storage out to effect --- src/createEffectsHandler.js | 5 +++++ src/stores/system.store.js | 44 +++++-------------------------------- 2 files changed, 10 insertions(+), 39 deletions(-) diff --git a/src/createEffectsHandler.js b/src/createEffectsHandler.js index b6a00e61..c5a01710 100644 --- a/src/createEffectsHandler.js +++ b/src/createEffectsHandler.js @@ -101,8 +101,13 @@ const clearSkipNextTimer = ({ ticker, skipTimer }, payload) => { skipTimer.setElapsed(0); }; +const saveSlots = ({}, payload)=>{ + localStorage.setItem("saveSlots", JSON.stringify(payload.saveSlots)); +} + const effects = { render, + saveSlots, handleLineActions, startAutoNextTimer, clearAutoNextTimer, diff --git a/src/stores/system.store.js b/src/stores/system.store.js index f8c2c445..c86c0e50 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -656,32 +656,6 @@ export const setNextLineConfig = ({ state }, payload) => { return state; }; -/** - * Replaces a save slot with new data - * @param {Object} state - Current state object - * @param {Object} initialState - Action payload - * @param {string} initialState.slotKey - The key identifying the save slot - * @param {number} initialState.date - Unix timestamp for when the save was created - * @param {string} initialState.image - Base64 encoded save image/screenshot - * @param {Object} initialState.state - The game state to be saved - * @returns {Object} Updated state object - */ -export const replaceSaveSlot = ({ state }, payload) => { - const { slotKey, date, image, state: slotState } = payload; - - state.global.saveSlots[slotKey] = { - slotKey, - date, - image, - state: slotState, - }; - - state.global.pendingEffects.push({ - name: "render", - }); - return state; -}; - /** * Saves current game state to a slot * @param {Object} state - Current state object @@ -698,17 +672,6 @@ export const saveSaveSlot = ({ state }, payload) => { contexts: [...state.contexts] }; - // const url = await captureElement("story"); - // console.log("slotindex", slotIndex); - // saveData[slotIndex].image = url; - // const assets = { - // [`saveImage:${slotIndex}`]: { - // buffer: base64ToArrayBuffer(url), - // type: "image/png", - // }, - // }; - // await loadAssets(assets); - const saveData = { slotKey, date: Date.now(), @@ -718,10 +681,13 @@ export const saveSaveSlot = ({ state }, payload) => { state.global.saveSlots[slotKey] = saveData; - localStorage.setItem("saveSlots", JSON.stringify(state.global.saveSlots)); - state.global.pendingEffects.push( + { name: 'saveSlots', + payload: { + saveSlots: {...state.global.saveSlots}, + } + }, { name: 'render' }, ); return state; From 4da13f57844402cc7bd6b7ebfe3c2c7865bd1218 Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Fri, 26 Dec 2025 08:32:03 +0000 Subject: [PATCH 07/11] Update with image --- src/stores/system.store.js | 6 +++--- src/util.js | 12 ------------ vt/static/main.js | 28 ++++++++++++++++++++++++++-- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/stores/system.store.js b/src/stores/system.store.js index c86c0e50..2c527836 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -661,10 +661,11 @@ export const setNextLineConfig = ({ state }, payload) => { * @param {Object} state - Current state object * @param {Object} payload - Action payload * @param {number} payload.slot - Save slot number + * @param {string} payload.thumbnailImage - Base64 thumbnail image * @returns {Object} Updated state object */ export const saveSaveSlot = ({ state }, payload) => { - const { slot } = payload; + const { slot, thumbnailImage } = payload; const slotKey = String(slot); const currentState = { @@ -675,7 +676,7 @@ export const saveSaveSlot = ({ state }, payload) => { const saveData = { slotKey, date: Date.now(), - image: null, + image: thumbnailImage, state: currentState }; @@ -1151,7 +1152,6 @@ export const createSystemStore = (initialState) => { addViewedLine, addViewedResource, setNextLineConfig, - replaceSaveSlot, saveSaveSlot, loadSaveSlot, updateProjectData, diff --git a/src/util.js b/src/util.js index 97814883..f1794de3 100644 --- a/src/util.js +++ b/src/util.js @@ -291,15 +291,3 @@ export const createSelectiveActionsExecutor = ( }); }; }; - -export const base64ToArrayBuffer = (base64) => { - const binaryString = window.atob( - base64.replace(/^data:image\/[a-z]+;base64,/, ""), - ); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; -}; diff --git a/vt/static/main.js b/vt/static/main.js index 07b12d33..323c7e03 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -12,7 +12,7 @@ import createRouteGraphics, { textRevealingPlugin, tweenPlugin, soundPlugin, -} from "https://cdn.jsdelivr.net/npm/route-graphics@0.0.15/+esm" +} from "/RouteGraphics.js" const projectData = parse(window.yamlContent); @@ -129,11 +129,24 @@ const init = async () => { const ticker = new Ticker(); ticker.start(); + const base64ToArrayBuffer = (base64) => { + const binaryString = window.atob( + base64.replace(/^data:image\/[a-z]+;base64,/, ""), + ); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; + }; + + await routeGraphics.init({ width: 1920, height: 1080, plugins, - eventHandler: (eventName, payload) => { + eventHandler: async (eventName, payload) => { if (eventName === 'renderComplete') { if (count >= 2) { return; @@ -143,6 +156,17 @@ const init = async () => { // engine.handleLineActions(); } else { if (payload.actions) { + if(payload.actions.saveSaveSlot){ + const url = await routeGraphics.extractBase64("story"); + const assets = { + [`saveThumbnailImage:${payload.actions.saveSaveSlot.slot}`]: { + buffer: base64ToArrayBuffer(url), + type: "image/png", + }, + }; + await routeGraphics.loadAssets(assets); + payload.actions.saveSaveSlot.thumbnailImage = url; + } engine.handleActions(payload.actions); } } From 0744557a06eca979390aa511bd6cc3767dd30377 Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Fri, 26 Dec 2025 08:40:00 +0000 Subject: [PATCH 08/11] Update to use cdn --- vt/static/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vt/static/main.js b/vt/static/main.js index 323c7e03..3ece4296 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -12,7 +12,7 @@ import createRouteGraphics, { textRevealingPlugin, tweenPlugin, soundPlugin, -} from "/RouteGraphics.js" +} from "https://cdn.jsdelivr.net/npm/route-graphics@0.0.19/+esm" const projectData = parse(window.yamlContent); From 11578f5d12da6984a269aaa864b3536a4afb4ff4 Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Fri, 26 Dec 2025 08:42:57 +0000 Subject: [PATCH 09/11] Update system.store.js --- src/stores/system.store.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 2c527836..be8502db 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -669,8 +669,8 @@ export const saveSaveSlot = ({ state }, payload) => { const slotKey = String(slot); const currentState = { - projectData: state.projectData, - contexts: [...state.contexts] + contexts: [...state.contexts], + viewedRegistry: state.global.viewedRegistry, }; const saveData = { @@ -706,7 +706,7 @@ export const loadSaveSlot = ({ state }, payload) => { const slotKey = String(slot); const slotData = state.global.saveSlots[slotKey]; if (slotData) { - state.projectData = slotData.state.projectData; + state.global.viewedRegistry = slotData.state.viewedRegistry; state.contexts = slotData.state.contexts; state.global.pendingEffects.push( { name: 'render' }, From f228ab8dba0590ea8a8608ebf8200b73616b6f7c Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Fri, 26 Dec 2025 08:52:00 +0000 Subject: [PATCH 10/11] Update spec tests --- spec/system/actions/globalLoadStory.spec.yaml | 8 +- spec/system/actions/globalSaveStory.spec.yaml | 8 +- spec/system/actions/replaceSaveSlot.spec.yaml | 281 ------------------ 3 files changed, 8 insertions(+), 289 deletions(-) delete mode 100644 spec/system/actions/replaceSaveSlot.spec.yaml diff --git a/spec/system/actions/globalLoadStory.spec.yaml b/spec/system/actions/globalLoadStory.spec.yaml index bba30b20..cf9ef762 100644 --- a/spec/system/actions/globalLoadStory.spec.yaml +++ b/spec/system/actions/globalLoadStory.spec.yaml @@ -1,9 +1,9 @@ file: "../../../src/stores/system.store.js" -group: systemStore.globalLoadStory -suites: [globalLoadStory] +group: systemStore.loadSaveSlot +suites: [loadSaveSlot] --- -suite: globalLoadStory -exportName: globalLoadStory +suite: loadSaveSlot +exportName: loadSaveSlot --- case: load from existing slot in: diff --git a/spec/system/actions/globalSaveStory.spec.yaml b/spec/system/actions/globalSaveStory.spec.yaml index debbb82d..03f4294b 100644 --- a/spec/system/actions/globalSaveStory.spec.yaml +++ b/spec/system/actions/globalSaveStory.spec.yaml @@ -1,9 +1,9 @@ file: "../../../src/stores/system.store.js" -group: systemStore.globalSaveStory -suites: [globalSaveStory] +group: systemStore.saveSaveSlot +suites: [saveSaveSlot] --- -suite: globalSaveStory -exportName: globalSaveStory +suite: saveSaveSlot +exportName: saveSaveSlot --- case: save to slot 1 in: diff --git a/spec/system/actions/replaceSaveSlot.spec.yaml b/spec/system/actions/replaceSaveSlot.spec.yaml deleted file mode 100644 index 00958c06..00000000 --- a/spec/system/actions/replaceSaveSlot.spec.yaml +++ /dev/null @@ -1,281 +0,0 @@ -file: "../../../src/stores/system.store.js" -group: systemStore.replaceSaveSlot -suites: [replaceSaveSlot] ---- -suite: replaceSaveSlot -exportName: replaceSaveSlot ---- -case: replace save slot in empty registry -in: - - state: - global: - saveSlots: {} - pendingEffects: [] - - slotKey: "1" - date: 1704110400 - image: "iVBORw0KGgoAAAANSUhEUgAA..." - state: - level: 1 - score: 0 - playerName: "Hero" -out: - global: - saveSlots: - "1": - slotKey: "1" - date: 1704110400 - image: "iVBORw0KGgoAAAANSUhEUgAA..." - state: - level: 1 - score: 0 - playerName: "Hero" - pendingEffects: - - name: "render" ---- -case: replace existing save slot -in: - - state: - global: - saveSlots: - "1": - slotKey: "1" - date: 1704110400 - image: "old_image.png" - state: - level: 5 - score: 1000 - pendingEffects: [] - - slotKey: "1" - date: 1704110500 - image: "new_image.png" - state: - level: 10 - score: 2500 - playerName: "Hero" -out: - global: - saveSlots: - "1": - slotKey: "1" - date: 1704110500 - image: "new_image.png" - state: - level: 10 - score: 2500 - playerName: "Hero" - pendingEffects: - - name: "render" ---- -case: replace save slot with string slotKey -in: - - state: - global: - saveSlots: - "1": - slotKey: "1" - date: 1704110400 - image: "slot1.png" - state: - progress: 25 - "autosave": - slotKey: "autosave" - date: 1704110300 - image: "auto_old.png" - state: - auto: true - pendingEffects: [] - - slotKey: "autosave" - date: 1704110600 - image: "auto_new.png" - state: - auto: true - lastAction: "dialogue" - checkpoint: "chapter_3" -out: - global: - saveSlots: - "1": - slotKey: "1" - date: 1704110400 - image: "slot1.png" - state: - progress: 25 - "autosave": - slotKey: "autosave" - date: 1704110600 - image: "auto_new.png" - state: - auto: true - lastAction: "dialogue" - checkpoint: "chapter_3" - pendingEffects: - - name: "render" ---- -case: replace save slot with existing pending effects -in: - - state: - global: - saveSlots: - "2": - slotKey: "2" - date: 1704110400 - image: "save2.png" - state: - completed: false - pendingEffects: - - name: "existingEffect" - data: "test" - - slotKey: "2" - date: 1704110700 - image: "save2_updated.png" - state: - completed: true - endTime: 1704110800 -out: - global: - saveSlots: - "2": - slotKey: "2" - date: 1704110700 - image: "save2_updated.png" - state: - completed: true - endTime: 1704110800 - pendingEffects: - - name: "existingEffect" - data: "test" - - name: "render" ---- -case: replace save slot with complex state object -in: - - state: - global: - saveSlots: - "3": - slotKey: "3" - date: 1704110400 - image: "old_save.png" - state: - player: - name: "Alice" - class: "Warrior" - level: 15 - inventory: - - id: "sword_001" - name: "Iron Sword" - - id: "potion_001" - name: "Health Potion" - questLog: - active: - - id: "quest_main" - title: "Save the Kingdom" - progress: 75 - pendingEffects: [] - - slotKey: "3" - date: 1704110800 - image: "new_save.png" - state: - player: - name: "Alice" - class: "Warrior" - level: 20 - stats: - hp: 150 - mp: 50 - strength: 25 - inventory: - - id: "sword_001" - name: "Iron Sword" - - id: "sword_002" - name: "Steel Sword" - - id: "potion_001" - name: "Health Potion" - questLog: - active: - - id: "quest_main" - title: "Save the Kingdom" - progress: 100 - completed: - - id: "quest_side1" - title: "Help the Villagers" -out: - global: - saveSlots: - "3": - slotKey: "3" - date: 1704110800 - image: "new_save.png" - state: - player: - name: "Alice" - class: "Warrior" - level: 20 - stats: - hp: 150 - mp: 50 - strength: 25 - inventory: - - id: "sword_001" - name: "Iron Sword" - - id: "sword_002" - name: "Steel Sword" - - id: "potion_001" - name: "Health Potion" - questLog: - active: - - id: "quest_main" - title: "Save the Kingdom" - progress: 100 - completed: - - id: "quest_side1" - title: "Help the Villagers" - pendingEffects: - - name: "render" ---- -case: replace save slot with other global properties unchanged -in: - - state: - global: - saveSlots: - "1": - slotKey: "1" - date: 1704110400 - image: "old.png" - state: - level: 1 - autoMode: true - skipMode: false - dialogueUIHidden: false - currentLocalizationPackageId: "en" - nextLineConfig: - manual: - enabled: true - requireComplete: false - pendingEffects: [] - - slotKey: "1" - date: 1704110900 - image: "updated.png" - state: - level: 15 - newFeature: true -out: - global: - saveSlots: - "1": - slotKey: "1" - date: 1704110900 - image: "updated.png" - state: - level: 15 - newFeature: true - autoMode: true - skipMode: false - dialogueUIHidden: false - currentLocalizationPackageId: "en" - nextLineConfig: - manual: - enabled: true - requireComplete: false - pendingEffects: - - name: "render" - From ab93541e78e34da6248424c8b196c02cee50edc6 Mon Sep 17 00:00:00 2001 From: NghiaTT200000 Date: Fri, 26 Dec 2025 10:59:17 +0000 Subject: [PATCH 11/11] Change name --- .../{globalLoadStory.spec.yaml => loadSaveSlot.yaml} | 0 .../{globalSaveStory.spec.yaml => saveSaveSlot.yaml} | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) rename spec/system/actions/{globalLoadStory.spec.yaml => loadSaveSlot.yaml} (100%) rename spec/system/actions/{globalSaveStory.spec.yaml => saveSaveSlot.yaml} (97%) diff --git a/spec/system/actions/globalLoadStory.spec.yaml b/spec/system/actions/loadSaveSlot.yaml similarity index 100% rename from spec/system/actions/globalLoadStory.spec.yaml rename to spec/system/actions/loadSaveSlot.yaml diff --git a/spec/system/actions/globalSaveStory.spec.yaml b/spec/system/actions/saveSaveSlot.yaml similarity index 97% rename from spec/system/actions/globalSaveStory.spec.yaml rename to spec/system/actions/saveSaveSlot.yaml index 03f4294b..b7c9c718 100644 --- a/spec/system/actions/globalSaveStory.spec.yaml +++ b/spec/system/actions/saveSaveSlot.yaml @@ -22,7 +22,7 @@ out: saveSlots: "1": slotKey: "1" - date: __ignore__ + date: null image: null state: projectData: @@ -55,7 +55,7 @@ out: saveSlots: "2": slotKey: "2" - date: __ignore__ + date: null image: null state: projectData: @@ -94,7 +94,7 @@ out: saveSlots: "1": slotKey: "1" - date: __ignore__ + date: null image: null state: projectData: @@ -128,7 +128,7 @@ out: saveSlots: "1": slotKey: "1" - date: __ignore__ + date: null image: null state: projectData: