diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index 3366115..97d8550 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -406,9 +406,21 @@ Seen-line semantics: ### Save System Actions -| Action | Payload | Description | -| ----------------- | --------------------------------- | ----------------- | -| `replaceSaveSlot` | `{ slotKey, date, image, state }` | Save game to slot | +| Action | Payload | Description | +| -------------- | --------------------------------- | -------------------------- | +| `saveSaveSlot` | `{ slot, thumbnailImage? }` | Save game to a slot | +| `loadSaveSlot` | `{ slot }` | Load game from a slot | + +Save/load design, requirements, and storage boundaries are documented in [SaveLoad.md](./SaveLoad.md). + +Notes: + +- `slot` is the authoritative action field; storage normalizes it to string `slotKey` +- save/load UIs can bind `slot` directly from layout templates such as `${slot.slotNumber}` +- if slot identity comes from event data, use `_event.*` bindings such as `slot: "_event.slot"` +- example save/load UI copy should stay terse; prefer short labels like `Save`, `Load`, `Page 1`, `Saved`, `Empty`, and `Image` +- `thumbnailImage` is integration-provided; the engine does not capture screenshots by itself +- if a save action appears inside a multi-action event payload, the host should prepare/augment the `actions` object and still call `handleActions(...)` once for the whole batch ### Effect Actions diff --git a/docs/SaveLoad.md b/docs/SaveLoad.md new file mode 100644 index 0000000..7c5d0d7 --- /dev/null +++ b/docs/SaveLoad.md @@ -0,0 +1,556 @@ +# Save/Load Design + +This document defines the intended product behavior, engine interfaces, and implementation boundaries for save/load in `route-engine`. + +It is a design and requirements document, not a guarantee that the current implementation already matches every rule below. + +## Purpose + +Save/load lets the player persist a playable story state into a slot and later restore that story state from the slot. + +In `route-engine`, save/load is separate from: + +- rollback +- dialogue history +- persistent global variables +- renderer/transient runtime state + +Save/load should restore the story to a coherent playable point, not resume every temporary UI or timer detail from the moment the save was made. + +## Product Summary + +The product model for save/load is: + +- save slots store story-local runtime state +- save slots restore a coherent playable reading position +- rollback timeline is part of saved story state and must survive save/load +- viewed/seen registry is saved and restored +- persistent global variables are not part of slot state +- transient runtime globals are not part of slot state +- loading a slot must reinitialize transient runtime globals instead of inheriting stale values from the pre-load session +- loading a missing or malformed slot must fail safely +- persistence to browser storage is a side effect handled outside the store + +## Terminology + +### Save Slot + +A save slot is a named/indexed container with: + +- slot metadata for UI +- saved story state for restoration + +### Saved Story State + +Saved story state is the subset of runtime state needed to resume the story coherently. + +For the current model, that means: + +- `contexts` +- `viewedRegistry` + +Rollback data lives inside context state and is therefore part of saved story state. + +### Persistent Global Variables + +Persistent global variables are variables with scope: + +- `global-device` +- `global-account` + +These are not story-local and should not be stored inside save slot state. + +They persist through their own storage path. + +### Transient Runtime State + +Transient runtime state is temporary engine/UI state that should be recreated or reset on load rather than serialized into a slot. + +Examples: + +- `autoMode` +- `skipMode` +- `dialogueUIHidden` +- `isDialogueHistoryShowing` +- `nextLineConfig` +- `layeredViews` +- `isLineCompleted` +- `pendingEffects` +- live timer callbacks and in-flight timing state + +## User-Facing Requirements + +### Saving + +When the player saves: + +- the current story position is captured +- the current story-local variables are captured +- the current rollback timeline/cursor is captured +- the viewed registry is captured +- the slot thumbnail/preview metadata is stored +- the slot becomes available immediately in save/load UI + +### Loading + +When the player loads: + +- the engine returns to the saved story position +- saved story-local variables are restored +- saved rollback ability is restored +- seen/viewed registry is restored from the slot +- transient runtime state is reinitialized to clean defaults +- the result is a coherent playable state, not a hybrid of pre-load and post-load runtime state + +## Product Decisions + +### What save slots must include + +Save slots must include: + +- current `contexts` +- `global.viewedRegistry` +- rollback timeline/cursor inside each saved context +- slot metadata: + - `slotKey` + - `date` + - `image` + +### What save slots must not include + +Save slots must not include: + +- `projectData` +- persistent/global variables +- current render/presentation snapshots +- transient runtime globals +- pending effects +- live timer state + +Rationale: + +- `projectData` is application input, not runtime save state +- persistent globals have their own lifetime and persistence rules +- transient runtime globals should not leak across load boundaries +- renderer state should be reconstructed from restored story state + +### Load must reinitialize transient runtime globals + +Loading a slot must explicitly reset transient globals to the same clean runtime baseline expected for a playable state. + +That includes resetting: + +- `autoMode` +- `skipMode` +- `dialogueUIHidden` +- `isDialogueHistoryShowing` +- `nextLineConfig` +- `layeredViews` +- `isLineCompleted` + +It also includes clearing runtime timers/effects that belong to the prior live session. + +This is a product decision, not an implementation detail. + +### Persistent globals stay outside save slots + +`global-device` and `global-account` variables are intentionally not saved into slots. + +Load should not roll them back or replace them from slot data. + +Rationale: + +- they are not local branch state +- they are meant to persist across saves, playthroughs, and sessions + +### Rollback must survive save/load + +Rollback is part of the player's current story state. + +Therefore: + +- save must serialize rollback timeline/cursor inside context state +- load must restore that rollback data +- older saves without rollback data may be normalized into a minimal rollback timeline anchored at the loaded pointer + +See [Rollback.md](./Rollback.md). + +### Missing or malformed saves must fail safely + +If a requested slot is missing: + +- load should leave state unchanged + +If slot data is malformed: + +- load must not partially corrupt the engine state +- load should either: + - leave state unchanged, or + - normalize the data into a minimal valid playable state + +Partial application into an invalid runtime shape is not acceptable. + +## Interfaces + +### Store Actions + +Current store actions are: + +- `saveSaveSlot({ slot, thumbnailImage? })` +- `loadSaveSlot({ slot })` + +Notes: + +- the naming is awkward but currently authoritative in the runtime +- `thumbnailImage` is UI/host-provided preview data +- `slot` is the authoritative action field and is normalized to string `slotKey` in storage + +### Store Selectors + +Current save/load-related selectors are: + +- `selectSaveSlots()` +- `selectSaveSlot({ slotKey })` +- `selectCurrentPageSlots({ slotsPerPage? })` + +`selectCurrentPageSlots` is a UI helper for paginated save/load screens. It flattens the current page into slot UI items based on the `loadPage` variable. + +### Effects + +The save/load path crosses the store boundary through effects: + +- `saveSlots` +- `saveGlobalDeviceVariables` +- `saveGlobalAccountVariables` + +Current behavior: + +- `saveSaveSlot` mutates `state.global.saveSlots` +- then it emits a `saveSlots` effect +- the effect handler persists the full slot map to `localStorage` + +Load is different: + +- `loadSaveSlot` only restores in-memory engine state from `state.global.saveSlots` +- it does not read `localStorage` itself + +### Dynamic Slot Selection + +The engine contract is based on action payload `slot`. + +The store converts that to string `slotKey` internally, but authored/integration payloads should target `slot`. + +Current supported patterns: + +```yaml +# Static slot binding +click: + payload: + actions: + saveSaveSlot: + slot: 1 +``` + +```yaml +# Template-time slot binding from saveSlots selector data +click: + payload: + actions: + loadSaveSlot: + slot: ${slot.slotNumber} +``` + +```yaml +# Event-time slot binding through Route Graphics event data +click: + payload: + _event: + slot: 3 + actions: + saveSaveSlot: + slot: "_event.slot" +``` + +Important details: + +- `_event` is the only supported event-context key at the engine/template layer +- `"_event.*"` bindings are resolved before jempl interpolation +- unresolved `"_event.*"` bindings fail fast +- the Route Graphics bridge also accepts `event` and normalizes it into `_event` + +Current recommendation: + +- if the slot identity is known when the element is rendered, bind it directly in the payload +- use `_event` only when the event source itself determines the slot dynamically +- keep example UI copy terse; prefer short labels such as `Save`, `Load`, `Page 1`, `Saved`, `Empty`, and `Image` + +For save/load grids rendered from `saveSlots`, direct template binding is the clearer default: + +- `slot: ${slot.slotNumber}` + +Open design note: + +- whether save/load UI should standardize on direct/template slot binding or formalize `_event`-driven slot routing as a first-class pattern is still an explicit product decision to revisit + +### Thumbnail Capture From Environment + +The core engine does not capture screenshots itself. + +`saveSaveSlot` simply accepts `thumbnailImage` if the host/integration provides one and stores it as slot `image`. + +Current VT/browser harness behavior: + +1. intercept Route Graphics event payloads before action dispatch +2. detect `payload.actions.saveSaveSlot` +3. call `routeGraphics.extractBase64("story")` +4. inject the result into `payload.actions.saveSaveSlot.thumbnailImage` +5. also register the captured image as a Route Graphics asset under `saveThumbnailImage:${slot}` + +The engine-facing contract is only step 4. + +The asset registration step is harness-specific behavior and is not required by the core save/load store API unless a UI explicitly relies on that asset-id convention. + +Current recommendation: + +- keep thumbnail capture outside the store +- let the host/integration obtain the screenshot from the active renderer/environment +- pass the final image string into `saveSaveSlot` + +Important constraint: + +- a single UI event may contain multiple authored actions +- in that case, the host should still dispatch one `handleActions(...)` call for the whole batch +- do not split save into a separate `handleAction("saveSaveSlot", ...)` call just because it needs a screenshot + +Rationale: + +- authored action order should stay intact +- rollback action batching should stay intact +- save plus other authored actions should continue to behave as one logical interaction + +Current simple shape: + +```js +if (payload?.actions?.saveSaveSlot) { + const thumbnailImage = await routeGraphics.extractBase64("story"); + payload.actions.saveSaveSlot.thumbnailImage = thumbnailImage; +} +``` + +Preferred general integration shape: + +```js +async function prepareActionsForDispatch(actions, routeGraphics) { + if (!actions?.saveSaveSlot) { + return actions; + } + + const nextActions = structuredClone(actions); + + if (!nextActions.saveSaveSlot.thumbnailImage) { + nextActions.saveSaveSlot.thumbnailImage = + await routeGraphics.extractBase64("story"); + } + + return nextActions; +} + +const nextActions = await prepareActionsForDispatch( + payload.actions, + routeGraphics, +); + +engine.handleActions( + nextActions, + payload._event ? { _event: payload._event } : undefined, +); +``` + +Why this is preferred: + +- no in-place mutation of the original event payload +- preserves one multi-action dispatch +- keeps screenshot capture in the host/integration layer +- avoids changing authored action semantics + +Dedicated helpers such as `saveToSlot(slot)` are still viable for UIs where a click means only one save action, but they should not replace the batched `handleActions` path for generic event payloads that may include multiple actions. + +### Host App Responsibilities + +The host app is responsible for: + +- hydrating `initialState.global.saveSlots` from durable storage before engine init +- hydrating persistent global variables before engine init +- providing thumbnail image payloads when a save action wants one +- mapping dynamic UI/event data into the action `slot` field when save/load is triggered from generated UI +- executing storage effects emitted by the engine + +The system store itself does not own browser storage reads. + +## Data Contract + +The effective slot structure is: + +```js +{ + slotKey: "1", + date: 1700000000000, + image: "data:image/webp;base64,...", // or null/undefined + state: { + viewedRegistry: { + sections: [...], + resources: [...], + }, + contexts: [ + { + currentPointerMode: "read", + pointers: { + read: { sectionId: "section1", lineId: "3" }, + history: { ... }, + }, + historySequence: [...], + configuration: { ... }, + views: [...], + bgm: { ... }, + variables: { ... }, + rollback: { + currentIndex: 2, + isRestoring: false, + replayStartIndex: 0, + timeline: [...], + }, + }, + ], + }, +} +``` + +Important constraints: + +- `state.contexts` is authoritative for story restoration +- `state.viewedRegistry` is authoritative for seen-state restoration +- runtime-only globals are not part of this slot payload + +## How It Works Today + +### Initialization + +At initialization: + +- `createInitialState` receives `payload.global.saveSlots` +- `createInitialState` also receives preloaded persistent global variables +- those become part of initial in-memory system state + +This means startup hydration is split: + +- slot map comes from the host app into store initialization +- persistent globals come from the host app into store initialization + +### Save Flow + +Current save flow: + +1. clone current `contexts` +2. strip legacy rollback-only compatibility fields from cloned contexts +3. clone `global.viewedRegistry` +4. write `{ slotKey, date, image, state }` into `state.global.saveSlots` +5. append `saveSlots` effect +6. append `render` effect + +The store writes to the in-memory slot map first. + +Persistence to `localStorage` happens later through the effect handler. + +### Load Flow + +Current load flow: + +1. look up `state.global.saveSlots[slotKey]` +2. if missing, leave state unchanged +3. clone `slotData.state.viewedRegistry` +4. clone `slotData.state.contexts` +5. normalize rollback state on loaded contexts +6. append `render` effect + +Current implementation note: + +- load restores `contexts` and `viewedRegistry` +- load does not currently rebuild all transient globals from a clean baseline + +That gap should be fixed to match the product rules above. + +## Relationship to Rollback + +Rollback state is part of context state and therefore part of slot state. + +Required behavior: + +- saving must preserve rollback timeline/cursor +- loading must preserve rollback ability from the loaded point +- old saves without rollback state may be upgraded to a minimal rollback state +- rollback restore start state is recomputed from project defaults, not from saved baseline snapshots + +## Validation and Compatibility Rules + +### Validation + +The save/load path should validate enough to guarantee a coherent playable state. + +At minimum: + +- `slotKey` should be stringifiable +- `state.contexts` should be an array with at least one valid context +- each loaded context should have a valid read pointer +- `viewedRegistry` should be normalized to a safe shape + +### Compatibility + +Older save formats may exist. + +Compatibility rules should be explicit: + +- older saves without rollback state may be normalized +- legacy rollback-only compatibility fields should be ignored/stripped +- malformed save data should not be partially applied into live state + +If compatibility is intentionally broken in the future, that should be documented clearly. + +## Required Specs + +The save/load test surface should cover: + +- save writes slot metadata and state into `saveSlots` +- save emits `saveSlots` effect +- save overwrites an existing slot deterministically +- save preserves rollback timeline/cursor +- save/load works against live Immer drafts +- load from existing slot restores contexts and viewed registry +- load from missing slot leaves state unchanged +- load restores rollback timeline/cursor from slot data +- load initializes a minimal rollback timeline for older saves without rollback data +- load reinitializes transient runtime globals to defaults +- load clears prior auto/skip/next-line timers +- load does not replace persistent global variables from slot data +- load rejects or safely normalizes malformed slot payloads + +## Current Gaps To Improve + +Areas that should be improved next: + +- load should reset transient runtime globals instead of inheriting them from the pre-load session +- load should clear runtime timers from the pre-load session +- malformed slot data should be handled safely and explicitly +- action/effect schemas should match the actual save/load runtime interfaces +- dynamic slot-event and thumbnail-capture integration rules should be reflected more explicitly in schemas or host-layer helpers +- if screenshot capture remains preprocess-based, prefer cloning/augmenting authored actions before dispatch instead of mutating the original event payload in place +- stale public docs should be aligned with the real `saveSaveSlot` / `loadSaveSlot` naming + +## Non-Goals + +This document does not define: + +- cloud sync +- multi-device account sync +- save slot thumbnails generation strategy +- save slot UI layout design +- exact storage backend beyond current local effect semantics diff --git a/spec/createEffectsHandler.routeGraphicsEvents.test.js b/spec/createEffectsHandler.routeGraphicsEvents.test.js index a20b1f1..9042370 100644 --- a/spec/createEffectsHandler.routeGraphicsEvents.test.js +++ b/spec/createEffectsHandler.routeGraphicsEvents.test.js @@ -124,4 +124,59 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { }, ); }); + + it("forwards preprocessPayload action changes into handleActions", async () => { + const engine = { + selectRenderState: vi.fn(() => ({ id: "render-1" })), + handleAction: vi.fn(), + handleActions: vi.fn(), + }; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics: { + render: vi.fn(), + }, + ticker: createTicker(), + }); + + const eventHandler = effectsHandler.createRouteGraphicsEventHandler({ + preprocessPayload: async (eventName, payload) => ({ + ...payload, + actions: { + ...payload.actions, + saveSaveSlot: { + ...payload.actions.saveSaveSlot, + thumbnailImage: "data:image/png;base64,updated", + date: 1701234567890, + }, + }, + }), + }); + + await eventHandler("click", { + actions: { + saveSaveSlot: { + slot: 1, + }, + }, + _event: { + id: "slot_1_box", + }, + }); + + expect(engine.handleActions).toHaveBeenCalledWith( + { + saveSaveSlot: { + slot: 1, + thumbnailImage: "data:image/png;base64,updated", + date: 1701234567890, + }, + }, + { + _event: { + id: "slot_1_box", + }, + }, + ); + }); }); diff --git a/spec/system/actions/saveSaveSlot.spec.yaml b/spec/system/actions/saveSaveSlot.spec.yaml index 0fd0c94..1826a0e 100644 --- a/spec/system/actions/saveSaveSlot.spec.yaml +++ b/spec/system/actions/saveSaveSlot.spec.yaml @@ -146,6 +146,52 @@ out: - pointers: read: { sectionId: "sec1", lineId: "line1" } --- +case: save with explicit timestamp override +in: + - state: + global: + saveSlots: {} + pendingEffects: [] + projectData: + story: { initialSceneId: "test" } + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + - slot: 1 + thumbnailImage: "custom_image.png" + date: 1701234567890 +out: + global: + saveSlots: + "1": + slotKey: "1" + date: 1701234567890 + image: "custom_image.png" + state: + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + viewedRegistry: __undefined__ + pendingEffects: + - name: "saveSlots" + payload: + saveSlots: + "1": + slotKey: "1" + date: 1701234567890 + image: "custom_image.png" + state: + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + viewedRegistry: __undefined__ + - name: "render" + projectData: + story: { initialSceneId: "test" } + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } +--- case: save to slot with existing pending effects in: - state: diff --git a/src/schemas/systemActions.yaml b/src/schemas/systemActions.yaml index 609d45b..b8d3996 100644 --- a/src/schemas/systemActions.yaml +++ b/src/schemas/systemActions.yaml @@ -176,6 +176,12 @@ properties: type: number description: Save slot index to save to minimum: 0 + thumbnailImage: + type: [string, "null"] + description: Optional thumbnail image as a data URL or external image source + date: + type: number + description: Optional save timestamp override. Mainly useful for deterministic integrations and tests. required: [slot] additionalProperties: false diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 8d393a7..851c274 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -1368,7 +1368,7 @@ export const setNextLineConfig = ({ state }, payload) => { * @returns {Object} Updated state object */ export const saveSaveSlot = ({ state }, payload) => { - const { slot, thumbnailImage } = payload; + const { slot, thumbnailImage, date } = payload; const slotKey = String(slot); const contexts = cloneStateValue(state.contexts); contexts?.forEach((context) => { @@ -1382,7 +1382,7 @@ export const saveSaveSlot = ({ state }, payload) => { const saveData = { slotKey, - date: Date.now(), + date: typeof date === "number" ? date : Date.now(), image: thumbnailImage, state: currentState, }; diff --git a/vt/reference/save/dynamic-slot-selector-test-01.webp b/vt/reference/save/dynamic-slot-selector-test-01.webp index 00f9b70..5ee9b2d 100644 --- a/vt/reference/save/dynamic-slot-selector-test-01.webp +++ b/vt/reference/save/dynamic-slot-selector-test-01.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04975a1d20661cd154d2dd94848ad0f8340f1c5ab31f923753c4629511dbd047 -size 9912 +oid sha256:a113770e0861bb9b754c38db33b0209f136670e7ae5e3ea64572f8bac205db71 +size 2492 diff --git a/vt/reference/save/multi-slot-test-01.webp b/vt/reference/save/multi-slot-test-01.webp deleted file mode 100644 index b87e2c1..0000000 --- a/vt/reference/save/multi-slot-test-01.webp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4414cd32d95ea716fe6cc1bc366923df923fa5269934434675f03acac2aa064d -size 20070 diff --git a/vt/specs/save/dynamic-slot-selector-test.yaml b/vt/specs/save/dynamic-slot-selector-test.yaml index 62e3b06..562a181 100644 --- a/vt/specs/save/dynamic-slot-selector-test.yaml +++ b/vt/specs/save/dynamic-slot-selector-test.yaml @@ -6,131 +6,280 @@ screen: height: 1080 backgroundColor: "#000000" resources: + images: + savePlaceholder: + fileId: 94lkj289 + width: 280 + height: 148 variables: loadPage: type: number scope: global-device default: 1 - controls: - base1: + previewFrame: + type: string + scope: context + default: "01 / 20" + previewTitle: + type: string + scope: context + default: "Rain" + previewDetail: + type: string + scope: context + default: "Station window." + characters: + narrator: + name: "" + layouts: + readingLayout: + name: Story Screen elements: - - id: clickArea + - id: background type: rect width: 1920 height: 1080 + x: 0 + y: 0 click: payload: actions: - contextNextLine: {} + nextLine: {} colorId: layoutColor1 - layouts: - dynamicSlotsTestUI: - name: Dynamic Slots Grid Test + - id: scene-card + type: rect + width: 1080 + height: 260 + x: 120 + y: 120 + click: + payload: + actions: + nextLine: {} + colorId: layoutColor2 + - id: scene-title + type: text + content: ${variables.previewFrame} + x: 170 + y: 165 + textStyleId: textStyle1 + - id: scene-subtitle + type: text + content: ${variables.previewTitle} + x: 172 + y: 228 + textStyleId: textStyle10 + - id: scene-detail + type: text + content: ${variables.previewDetail} + x: 174 + y: 286 + textStyleId: textStyle2 + readingDialogueLayout: + name: Reading Dialogue + mode: adv elements: + - id: save-button + type: rect + x: 1670 + y: 40 + width: 160 + height: 64 + click: + payload: + actions: + pushLayeredView: + resourceId: dynamicSaveMenuLayout + resourceType: layout + colorId: layoutColor3 + - id: save-button-text + type: text + x: 1720 + y: 58 + content: Save + click: + payload: + actions: + pushLayeredView: + resourceId: dynamicSaveMenuLayout + resourceType: layout + textStyleId: textStyle3 + - id: dialogue-box + type: rect + x: 100 + y: 836 + width: 1720 + height: 164 + click: + payload: + actions: + nextLine: {} + colorId: layoutColor5 + - id: dialogue-text + type: text + x: 150 + y: 872 + content: ${dialogue.content[0].text} + textStyleId: textStyle11 + dynamicSaveMenuLayout: + name: Dynamic Save Menu + elements: + - id: save-menu-overlay + type: rect + width: 1920 + height: 1080 + x: 0 + y: 0 + opacity: 0.78 + colorId: layoutColor4 + - id: save-menu-bg + type: rect + width: 1360 + height: 760 + x: 280 + y: 160 + colorId: layoutColor5 - id: title type: text - content: Dynamic Save Slots - Selector Test - x: 960 - y: 50 - anchorX: 0.5 - textStyleId: textStyle1 + content: Slots + x: 380 + y: 220 + textStyleId: textStyle4 - id: subtitle type: text content: Page ${variables.loadPage} - x: 960 - y: 100 - anchorX: 0.5 - textStyleId: textStyle2 - - id: pageNav - type: container - x: 960 - y: 180 - anchorX: 0.5 - children: - - id: btnPrevPage - type: text - content: ← Previous Page - x: -150 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle4 - click: - payload: - actions: - updateVariable: - id: uv1 - operations: - - variableId: loadPage - op: decrement - textStyleId: textStyle3 - - id: btnNextPage - type: text - content: Next Page → - x: 150 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle4 - click: - payload: - actions: - updateVariable: - id: uv2 - operations: - - variableId: loadPage - op: increment - textStyleId: textStyle3 + x: 380 + y: 280 + textStyleId: textStyle5 + - id: close-btn + type: rect + width: 140 + height: 56 + x: 1460 + y: 210 + click: + payload: + actions: + clearLayeredViews: {} + colorId: layoutColor3 + - id: close-btn-text + type: text + content: Close + x: 1502 + y: 226 + click: + payload: + actions: + clearLayeredViews: {} + textStyleId: textStyle3 + - id: btnPrevPage + type: text + content: ← Previous Page + x: 400 + y: 350 + hover: + textStyleId: textStyle7 + click: + payload: + actions: + updateVariable: + id: prevPage + operations: + - variableId: loadPage + op: decrement + textStyleId: textStyle6 + - id: btnNextPage + type: text + content: Next Page → + x: 1230 + y: 350 + hover: + textStyleId: textStyle7 + click: + payload: + actions: + updateVariable: + id: nextPage + operations: + - variableId: loadPage + op: increment + textStyleId: textStyle6 - id: slotsGrid type: container direction: horizontal - gap: 30 - width: 720 - x: 960 - y: 300 - anchorX: 0.5 + gap: 28 + width: 1080 + x: 360 + y: 420 children: - $for slot in saveSlots: id: slot_${slot.slotNumber}_container type: container - direction: vertical - gap: 10 + width: 320 + height: 250 children: - id: slot_${slot.slotNumber}_box type: rect - width: 200 - height: 150 - hover: - colorId: layoutColor2 + x: 0 + y: 0 + width: 320 + height: 250 click: payload: actions: saveSaveSlot: slot: ${slot.slotNumber} colorId: layoutColor2 - - id: slot_${slot.slotNumber}_label + - id: slot_${slot.slotNumber}_title type: text content: Slot ${slot.slotNumber} - x: 100 - y: 60 - anchorX: 0.5 - anchorY: 0.5 - textStyleId: textStyle5 + x: 20 + y: 18 + click: + payload: + actions: + saveSaveSlot: + slot: ${slot.slotNumber} + textStyleId: textStyle8 + - id: slot_${slot.slotNumber}_preview_empty + $when: "!slot.image" + type: sprite + imageId: savePlaceholder + x: 20 + y: 58 + width: 280 + height: 148 + click: + payload: + actions: + saveSaveSlot: + slot: ${slot.slotNumber} + - id: slot_${slot.slotNumber}_preview_saved_${slot.date} + $when: slot.image + type: sprite + imageId: "saveThumbnailImage:${slot.slotNumber}:${slot.date}" + x: 20 + y: 58 + width: 280 + height: 148 + click: + payload: + actions: + saveSaveSlot: + slot: ${slot.slotNumber} - id: slot_${slot.slotNumber}_status type: text - content: $if{slot.date:Saved:Empty} - x: 100 - y: 90 - anchorX: 0.5 - anchorY: 0.5 - textStyleId: textStyle6 - - id: instructions - type: text - content: Use Previous/Next to change pages. Click slots to save. - x: 960 - y: 650 - anchorX: 0.5 - textStyleId: textStyle7 + x: 20 + y: 216 + content: + $if slot.image: Saved + $else: Empty + click: + payload: + actions: + saveSaveSlot: + slot: ${slot.slotNumber} + textStyleId: textStyle9 fonts: fontDefault: fileId: Arial @@ -138,65 +287,101 @@ resources: color1: hex: "#FFFFFF" color2: - hex: "#737373" + hex: "#C8C8C8" color3: - hex: "#D9D9D9" + hex: "#B78A3D" color4: - hex: "#A6A6A6" + hex: "#787878" + color5: + hex: "#A3A3A3" layoutColor1: - hex: "#000000" + hex: "#111111" layoutColor2: - hex: "#4D4D4D" + hex: "#2A2A2A" + layoutColor3: + hex: "#4A4A4A" + layoutColor4: + hex: "#050505" + layoutColor5: + hex: "#1B1B1B" textStyles: textStyle1: fontId: fontDefault colorId: color1 - fontSize: 42 + fontSize: 50 fontWeight: bold fontStyle: normal lineHeight: 1.2 textStyle2: fontId: fontDefault colorId: color2 - fontSize: 32 - fontWeight: bold + fontSize: 26 + fontWeight: "400" fontStyle: normal - lineHeight: 1.2 + lineHeight: 1.3 textStyle3: fontId: fontDefault colorId: color1 fontSize: 24 - fontWeight: "400" + fontWeight: bold fontStyle: normal lineHeight: 1.2 textStyle4: fontId: fontDefault - colorId: color2 - fontSize: 24 - fontWeight: "400" + colorId: color1 + fontSize: 46 + fontWeight: bold fontStyle: normal lineHeight: 1.2 textStyle5: fontId: fontDefault - colorId: color3 - fontSize: 20 + colorId: color2 + fontSize: 30 fontWeight: "400" fontStyle: normal lineHeight: 1.2 textStyle6: fontId: fontDefault - colorId: color4 - fontSize: 16 - fontWeight: "400" + colorId: color1 + fontSize: 26 + fontWeight: bold fontStyle: normal lineHeight: 1.2 textStyle7: fontId: fontDefault - colorId: color4 - fontSize: 20 + colorId: color3 + fontSize: 26 + fontWeight: bold + fontStyle: normal + lineHeight: 1.2 + textStyle8: + fontId: fontDefault + colorId: color1 + fontSize: 28 + fontWeight: bold + fontStyle: normal + lineHeight: 1.2 + textStyle9: + fontId: fontDefault + colorId: color2 + fontSize: 22 fontWeight: "400" fontStyle: normal lineHeight: 1.2 + textStyle10: + fontId: fontDefault + colorId: color1 + fontSize: 36 + fontWeight: bold + fontStyle: normal + lineHeight: 1.2 + textStyle11: + fontId: fontDefault + colorId: color1 + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.3 story: initialSceneId: main scenes: @@ -209,7 +394,441 @@ story: lines: - id: line1 actions: - control: - resourceId: base1 - layout: - resourceId: dynamicSlotsTestUI + background: + resourceId: readingLayout + updateVariable: + id: line1Preview + operations: + - variableId: previewFrame + op: set + value: "01 / 20" + - variableId: previewTitle + op: set + value: "Rain" + - variableId: previewDetail + op: set + value: "Cold glass. Wet platform." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 01. Rain." + - id: line2 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line2Preview + operations: + - variableId: previewFrame + op: set + value: "02 / 20" + - variableId: previewTitle + op: set + value: "Orange Tram" + - variableId: previewDetail + op: set + value: "Warm light cuts across the room." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 02. A bright orange tram glides past the window and throws warm color across the room." + - id: line3 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line3Preview + operations: + - variableId: previewFrame + op: set + value: "03 / 20" + - variableId: previewTitle + op: set + value: "Lamp Flicker" + - variableId: previewDetail + op: set + value: "Yellow pulse. Short shadow." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 03. Lamp flicker." + - id: line4 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line4Preview + operations: + - variableId: previewFrame + op: set + value: "04 / 20" + - variableId: previewTitle + op: set + value: "Silver Key" + - variableId: previewDetail + op: set + value: "A single key points straight at the menu." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 04. Someone left a silver key beside the notebook, angled exactly toward the save button." + - id: line5 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line5Preview + operations: + - variableId: previewFrame + op: set + value: "05 / 20" + - variableId: previewTitle + op: set + value: "Red Warning" + - variableId: previewDetail + op: set + value: "Panel glow. Sharp contrast." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 05. Red warning." + - id: line6 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line6Preview + operations: + - variableId: previewFrame + op: set + value: "06 / 20" + - variableId: previewTitle + op: set + value: "Tea Steam" + - variableId: previewDetail + op: set + value: "Steam drifts upward and softens the city lights." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 06. Steam curls off the tea cup and briefly fogs the glass in front of the city." + - id: line7 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line7Preview + operations: + - variableId: previewFrame + op: set + value: "07 / 20" + - variableId: previewTitle + op: set + value: "Route Map" + - variableId: previewDetail + op: set + value: "Blue line. Left corner." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 07. Route map." + - id: line8 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line8Preview + operations: + - variableId: previewFrame + op: set + value: "08 / 20" + - variableId: previewTitle + op: set + value: "Platform Eight" + - variableId: previewDetail + op: set + value: "Intercom static repeats the platform call." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 08. The intercom crackles, then repeats a clipped message about platform eight." + - id: line9 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line9Preview + operations: + - variableId: previewFrame + op: set + value: "09 / 20" + - variableId: previewTitle + op: set + value: "Neon Streaks" + - variableId: previewDetail + op: set + value: "Pink line. Wet street." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 09. Neon streaks." + - id: line10 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line10Preview + operations: + - variableId: previewFrame + op: set + value: "10 / 20" + - variableId: previewTitle + op: set + value: "Archive Drawer" + - variableId: previewDetail + value: "Half-open drawer, stamped envelopes, hard shadow." + op: set + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 10. The archive drawer jams halfway open, revealing a stack of stamped envelopes." + - id: line11 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line11Preview + operations: + - variableId: previewFrame + op: set + value: "11 / 20" + - variableId: previewTitle + op: set + value: "Violet Scarf" + - variableId: previewDetail + op: set + value: "Soft fabric. Quiet corner." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 11. Violet scarf." + - id: line12 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line12Preview + operations: + - variableId: previewFrame + op: set + value: "12 / 20" + - variableId: previewTitle + op: set + value: "Skipped Minutes" + - variableId: previewDetail + op: set + value: "The clock jumps forward as if part of the scene was cut." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 12. The clock skips from 11:58 to 12:04 as if six minutes were cut from the scene." + - id: line13 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line13Preview + operations: + - variableId: previewFrame + op: set + value: "13 / 20" + - variableId: previewTitle + op: set + value: "Blueprint" + - variableId: previewDetail + op: set + value: "One folded sheet slips free." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 13. Blueprint slips free." + - id: line14 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line14Preview + operations: + - variableId: previewFrame + op: set + value: "14 / 20" + - variableId: previewTitle + op: set + value: "Low Hum" + - variableId: previewDetail + op: set + value: "Every page turn gets louder when the air goes quiet." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 14. The air conditioner hum drops low enough that every page turn sounds louder." + - id: line15 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line15Preview + operations: + - variableId: previewFrame + op: set + value: "15 / 20" + - variableId: previewTitle + op: set + value: "Drone Lights" + - variableId: previewDetail + op: set + value: "Three white dots. Far ceiling." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 15. Drone lights." + - id: line16 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line16Preview + operations: + - variableId: previewFrame + op: set + value: "16 / 20" + - variableId: previewTitle + op: set + value: "Speaker Blink" + - variableId: previewDetail + op: set + value: "One small icon flashes, then the whole room goes still." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 16. The speaker icon blinks once on the console, then the room falls completely still." + - id: line17 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line17Preview + operations: + - variableId: previewFrame + op: set + value: "17 / 20" + - variableId: previewTitle + op: set + value: "Turn Left" + - variableId: previewDetail + op: set + value: "Painted arrow. Plain wall." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 17. Turn left." + - id: line18 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line18Preview + operations: + - variableId: previewFrame + op: set + value: "18 / 20" + - variableId: previewTitle + op: set + value: "Projector Dust" + - variableId: previewDetail + op: set + value: "Particles drift through the beam and turn the corner hazy." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 18. Dust lifts in the projector beam and makes the whole corner look underwater." + - id: line19 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line19Preview + operations: + - variableId: previewFrame + op: set + value: "19 / 20" + - variableId: previewTitle + op: set + value: "Exit Sign" + - variableId: previewDetail + op: set + value: "Green edge. Dark hall." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 19. Exit sign." + - id: line20 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line20Preview + operations: + - variableId: previewFrame + op: set + value: "20 / 20" + - variableId: previewTitle + op: set + value: "Final Checkpoint" + - variableId: previewDetail + op: set + value: "Quiet room. Ready slot. Last frame." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 20. Final checkpoint. The room is quiet, the save slot is ready, and the frame should look different." diff --git a/vt/specs/save/multi-slot-test.yaml b/vt/specs/save/multi-slot-test.yaml deleted file mode 100644 index 54c5a13..0000000 --- a/vt/specs/save/multi-slot-test.yaml +++ /dev/null @@ -1,554 +0,0 @@ ---- -title: Multi-Slot Save Test ---- -screen: - width: 1920 - height: 1080 - backgroundColor: "#000000" -resources: - variables: - playerName: - type: string - scope: context - default: New Player - playerLevel: - type: number - scope: context - default: 1 - playerGold: - type: number - scope: context - default: 0 - questCompleted: - type: boolean - scope: context - default: false - controls: - base1: - elements: - - id: clickArea - type: rect - width: 1920 - height: 1080 - click: - payload: - actions: - contextNextLine: {} - colorId: layoutColor1 - layouts: - multiSlotTestUI: - name: Multi-Slot Save Test - elements: - - id: title - type: text - content: Multi-Slot Save Test - Runtime Variables - x: 960 - y: 50 - anchorX: 0.5 - textStyleId: textStyle1 - - id: subtitle - type: text - content: Each save slot stores different context variables - x: 960 - y: 100 - anchorX: 0.5 - textStyleId: textStyle2 - - id: currentStateLabel - type: text - content: CURRENT STATE - x: 960 - y: 180 - anchorX: 0.5 - textStyleId: textStyle3 - - id: nameDisplay - type: text - content: "Name: ${variables.playerName}" - x: 300 - y: 240 - textStyleId: textStyle4 - - id: levelDisplay - type: text - content: "Level: ${variables.playerLevel}" - x: 700 - y: 240 - textStyleId: textStyle4 - - id: goldDisplay - type: text - content: "Gold: ${variables.playerGold}" - x: 1000 - y: 240 - textStyleId: textStyle4 - - id: questDisplay - type: text - content: "Quest Done: ${variables.questCompleted}" - x: 1400 - y: 240 - textStyleId: textStyle4 - - id: modifyLabel - type: text - content: MODIFY CURRENT STATE - x: 960 - y: 320 - anchorX: 0.5 - textStyleId: textStyle3 - - id: nameButtons - type: container - x: 300 - y: 380 - children: - - id: nameLabel - type: text - content: "Set Name:" - x: 0 - y: 0 - textStyleId: textStyle5 - - id: btnAlice - type: text - content: Alice - x: 120 - y: 0 - hover: - textStyleId: textStyle7 - click: - payload: - actions: - updateVariable: - id: uv1 - operations: - - variableId: playerName - op: set - value: Alice - textStyleId: textStyle6 - - id: btnBob - type: text - content: Bob - x: 200 - y: 0 - hover: - textStyleId: textStyle7 - click: - payload: - actions: - updateVariable: - id: uv2 - operations: - - variableId: playerName - op: set - value: Bob - textStyleId: textStyle6 - - id: btnCharlie - type: text - content: Charlie - x: 270 - y: 0 - hover: - textStyleId: textStyle7 - click: - payload: - actions: - updateVariable: - id: uv3 - operations: - - variableId: playerName - op: set - value: Charlie - textStyleId: textStyle6 - - id: levelButtons - type: container - x: 700 - y: 380 - children: - - id: levelLabel - type: text - content: "Level:" - x: 0 - y: 0 - textStyleId: textStyle5 - - id: btnLevelUp - type: text - content: "+1" - x: 80 - y: 0 - hover: - textStyleId: textStyle8 - click: - payload: - actions: - updateVariable: - id: uv4 - operations: - - variableId: playerLevel - op: increment - textStyleId: textStyle7 - - id: btnLevelDown - type: text - content: "-1" - x: 130 - y: 0 - hover: - textStyleId: textStyle8 - click: - payload: - actions: - updateVariable: - id: uv5 - operations: - - variableId: playerLevel - op: decrement - textStyleId: textStyle7 - - id: goldButtons - type: container - x: 1000 - y: 380 - children: - - id: goldLabel - type: text - content: "Gold:" - x: 0 - y: 0 - textStyleId: textStyle5 - - id: btnGoldAdd - type: text - content: "+100" - x: 70 - y: 0 - hover: - textStyleId: textStyle9 - click: - payload: - actions: - updateVariable: - id: uv6 - operations: - - variableId: playerGold - op: increment - value: 100 - textStyleId: textStyle7 - - id: btnGoldReset - type: text - content: Reset - x: 140 - y: 0 - hover: - textStyleId: textStyle9 - click: - payload: - actions: - updateVariable: - id: uv7 - operations: - - variableId: playerGold - op: set - value: 0 - textStyleId: textStyle9 - - id: questButton - type: container - x: 1400 - y: 380 - children: - - id: questLabel - type: text - content: "Quest:" - x: 0 - y: 0 - textStyleId: textStyle5 - - id: btnQuestToggle - type: text - content: Toggle - x: 80 - y: 0 - hover: - textStyleId: textStyle8 - click: - payload: - actions: - updateVariable: - id: uv8 - operations: - - variableId: questCompleted - op: toggle - textStyleId: textStyle7 - - id: saveLabel - type: text - content: SAVE TO SLOT - x: 960 - y: 480 - anchorX: 0.5 - textStyleId: textStyle3 - - id: saveSlotButtons - type: container - x: 960 - y: 540 - anchorX: 0.5 - children: - - id: btnSaveSlot1 - type: text - content: Save Slot 1 - x: -300 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - saveSaveSlot: - slot: 1 - textStyleId: textStyle10 - - id: btnSaveSlot2 - type: text - content: Save Slot 2 - x: -100 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - saveSaveSlot: - slot: 2 - textStyleId: textStyle10 - - id: btnSaveSlot3 - type: text - content: Save Slot 3 - x: 100 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - saveSaveSlot: - slot: 3 - textStyleId: textStyle10 - - id: btnSaveSlot4 - type: text - content: Save Slot 4 - x: 300 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - saveSaveSlot: - slot: 4 - textStyleId: textStyle10 - - id: loadLabel - type: text - content: LOAD FROM SLOT - x: 960 - y: 640 - anchorX: 0.5 - textStyleId: textStyle3 - - id: loadSlotButtons - type: container - x: 960 - y: 700 - anchorX: 0.5 - children: - - id: btnLoadSlot1 - type: text - content: Load Slot 1 - x: -300 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - loadSaveSlot: - slot: 1 - textStyleId: textStyle10 - - id: btnLoadSlot2 - type: text - content: Load Slot 2 - x: -100 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - loadSaveSlot: - slot: 2 - textStyleId: textStyle10 - - id: btnLoadSlot3 - type: text - content: Load Slot 3 - x: 100 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - loadSaveSlot: - slot: 3 - textStyleId: textStyle10 - - id: btnLoadSlot4 - type: text - content: Load Slot 4 - x: 300 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - loadSaveSlot: - slot: 4 - textStyleId: textStyle10 - - id: instructions - type: text - content: |- - 1. Modify the current state (name, level, gold, quest) - 2. Save to a slot - 3. Modify state again differently - 4. Save to another slot - 5. Load different slots to see each has its own context variables! - x: 960 - y: 850 - anchorX: 0.5 - textStyleId: textStyle12 - - id: devToolsHint - type: text - content: "Open DevTools Console and type: - JSON.parse(localStorage.getItem('saveSlots'))" - x: 960 - y: 1000 - anchorX: 0.5 - textStyleId: textStyle13 - fonts: - fontDefault: - fileId: Arial - colors: - color1: - hex: "#FFFFFF" - color2: - hex: "#A6A6A6" - color3: - hex: "#737373" - color4: - hex: "#D9D9D9" - color5: - hex: "#4D4D4D" - layoutColor1: - hex: "#000000" - textStyles: - textStyle1: - fontId: fontDefault - colorId: color1 - fontSize: 42 - fontWeight: bold - fontStyle: normal - lineHeight: 1.2 - textStyle2: - fontId: fontDefault - colorId: color2 - fontSize: 24 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle3: - fontId: fontDefault - colorId: color3 - fontSize: 28 - fontWeight: bold - fontStyle: normal - lineHeight: 1.2 - textStyle4: - fontId: fontDefault - colorId: color4 - fontSize: 26 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle5: - fontId: fontDefault - colorId: color2 - fontSize: 22 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle6: - fontId: fontDefault - colorId: color1 - fontSize: 20 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle7: - fontId: fontDefault - colorId: color3 - fontSize: 20 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle8: - fontId: fontDefault - colorId: color5 - fontSize: 20 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle9: - fontId: fontDefault - colorId: color2 - fontSize: 20 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle10: - fontId: fontDefault - colorId: color1 - fontSize: 24 - fontWeight: bold - fontStyle: normal - lineHeight: 1.2 - textStyle11: - fontId: fontDefault - colorId: color3 - fontSize: 24 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle12: - fontId: fontDefault - colorId: color2 - fontSize: 20 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - align: center - textStyle13: - fontId: fontDefault - colorId: color5 - fontSize: 18 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - align: center -story: - initialSceneId: main - scenes: - main: - name: Multi-Slot Save Scene - initialSectionId: section1 - sections: - section1: - name: Multi-Slot Test Section - lines: - - id: line1 - actions: - control: - resourceId: base1 - layout: - resourceId: multiSlotTestUI diff --git a/vt/static/main.js b/vt/static/main.js index 7aab199..e3f5089 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -143,7 +143,6 @@ const init = async () => { const assetBufferMap = assetBufferManager.getBufferMap(); const routeGraphics = createRouteGraphics(); - window.takeVtScreenshotBase64 = async (label) => { if (label) { return await routeGraphics.extractBase64(label); @@ -202,15 +201,37 @@ const init = async () => { effectsHandler.createRouteGraphicsEventHandler({ preprocessPayload: async (eventName, payload) => { if (payload?.actions?.saveSaveSlot) { - const url = await routeGraphics.extractBase64("story"); + const saveTimestamp = Date.now(); + let url; + + try { + // Capture only the story container so the save menu itself does not + // become the slot thumbnail. + url = await routeGraphics.extractBase64("story"); + } catch { + url = routeGraphics.canvas.toDataURL("image/png"); + } const assets = { - [`saveThumbnailImage:${payload.actions.saveSaveSlot.slot}`]: { + [ + `saveThumbnailImage:${payload.actions.saveSaveSlot.slot}:${saveTimestamp}` + ]: { buffer: base64ToArrayBuffer(url), type: "image/png", }, }; await routeGraphics.loadAssets(assets); - payload.actions.saveSaveSlot.thumbnailImage = url; + + return { + ...payload, + actions: { + ...payload.actions, + saveSaveSlot: { + ...payload.actions.saveSaveSlot, + thumbnailImage: url, + date: saveTimestamp, + }, + }, + }; } return payload; @@ -220,6 +241,8 @@ const init = async () => { }, }); + window.__vtHandleRouteGraphicsEvent = routeGraphicsEventHandler; + await routeGraphics.init({ width: screenWidth, height: screenHeight, @@ -253,6 +276,8 @@ const init = async () => { }, }); + window.__vtEngine = engine; + window.addEventListener("vt:nextLine", () => { engine.handleActions({ nextLine: {},