From 4e7cf8f5d83e9ab7fe7f66ca5b0c352710635900 Mon Sep 17 00:00:00 2001 From: Luciano Hanyon Wu Date: Sun, 31 May 2026 18:08:22 +0800 Subject: [PATCH 1/3] feat: add text-backed visuals --- docs/Concepts.md | 3 +- docs/RouteEngine.md | 73 +++- package.json | 2 +- ...sentationState.backgroundTransform.test.js | 134 +++++++ spec/projectDataSchema.test.js | 98 +++++ .../constructPresentationState.spec.yaml | 70 ++++ spec/system/renderState/addVisuals.spec.yaml | 356 ++++++++++++++++++ src/schemas/presentationActions.yaml | 66 ++++ src/stores/constructPresentationState.js | 82 +++- src/stores/constructRenderState.js | 128 ++++++- vt/reference/visual/text-01.webp | 3 + vt/reference/visual/text-02.webp | 3 + vt/reference/visual/text-03.webp | 3 + vt/specs/visual/text.yaml | 200 ++++++++++ 14 files changed, 1191 insertions(+), 30 deletions(-) create mode 100644 spec/constructPresentationState.backgroundTransform.test.js create mode 100644 vt/reference/visual/text-01.webp create mode 100644 vt/reference/visual/text-02.webp create mode 100644 vt/reference/visual/text-03.webp create mode 100644 vt/specs/visual/text.yaml diff --git a/docs/Concepts.md b/docs/Concepts.md index fcc74fa..556996d 100644 --- a/docs/Concepts.md +++ b/docs/Concepts.md @@ -71,6 +71,7 @@ Static, read-only data that defines the visual novel content: - Computed variables are derived read-only values declared under `resources.variables[*].computed`; their authored interface is documented in `docs/ComputedVariables.md` - Voice audio is stored under `resources.voices[sceneId][voiceId]` and line actions reference the scene-local `voiceId` - Layout text elements should reference shared styles with `textStyleId` + - Text-backed visual items should put text-specific data under `text` and reference shared styles with `text.textStyleId` - `resources.colors[*].hex` should be opaque hex only; text fill and stroke transparency should be authored on `resources.textStyles` with `colorAlpha` / `strokeAlpha`, not inside `resources.colors` - Layout sprite elements should reference images with `imageId` and optional `hoverImageId` / `clickImageId` - Layout rect elements should reference shared colors with `colorId` and optional `hover.colorId` / `click.colorId` / `rightClick.colorId` @@ -127,7 +128,7 @@ Presentation state includes: - `colorId` references `resources.colors` for the persistent solid backing color behind the background resource; if omitted, the backing color falls back to `screen.backgroundColor`, then black - `dialogue`: Speaker, layered speaker sprite, text content, mode (ADV/NVL) - `character`: Character sprites and positions -- `visual`: Additional visual elements +- `visual`: Additional visual elements, including resource-backed visuals and text-backed visuals - `bgm` / `sfx` / `voice`: Audio configuration - `animation`: Active animations - `layout`: UI layouts diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index 9e5fd76..98e37b6 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -661,7 +661,7 @@ Actions that can be attached to lines to control presentation: | `background` | `{ resourceId?, colorId?, transformId?, x?, y?, anchorX?, anchorY?, scaleX?, scaleY?, rotation?, originX?, originY?, opacity?, blur?, animations? }` | Set background/CG. Transform fields are renderer pixels/unitless multipliers/degrees; `blur: null` clears background blur | | `dialogue` | `{ characterId?, character?, character.sprite?, persistCharacter?, content, append?, mode?, ui?, clear? }` | Display dialogue | | `character` | `{ items }` | Display character sprites. Each item can set transform overrides, `opacity`, and `blur` | -| `visual` | `{ items }` | Display visual elements. Each item can set `layer`, transform overrides, `opacity`, and `blur` | +| `visual` | `{ items }` | Display visual elements. Each item can set `layer`, transform overrides, `opacity`, `blur`, and animations | | `bgm` | `{ resourceId, volume?, loop?, startDelayMs? }` | Play background music | | `sfx` | `{ items }` | Play sound effects. Each item can include `volume`, `loop`, and `startDelayMs` | | `voice` | `{ resourceId, volume?, loop?, startDelayMs? }` | Play voice audio from `resources.voices[currentSceneId][resourceId]` | @@ -712,6 +712,74 @@ screen transitions, overlay stack entries, and confirm dialogs. JavaScript callers can use the exported `RENDER_LAYER`, `VISUAL_LAYER`, and `DEFAULT_VISUAL_LAYER` constants when generating project data. +### Text Visuals + +Visual items can be backed by text instead of an image, video, spritesheet, or +layout resource. A text-backed visual uses the same visual item placement, +layer, opacity, blur, and animation fields as every other visual item. + +New visual items choose one render subject: + +- `resourceId` for image, video, spritesheet, or layout resources +- `text` for direct RouteGraphics text + +`resourceId` and `text` are mutually exclusive. The `text` object owns only +text-specific fields such as `content`, `textStyleId`, and optional text layout +fields like `width`. It must not contain visual item fields such as `x`, +`layer`, or `animations`. + +```yaml +visual: + items: + - id: chapterTitle + text: + content: "Chapter 1" + textStyleId: title + width: 720 + transformId: titleTop + layer: 70 + opacity: 0.9 + animations: + resourceId: titleFadeIn +``` + +`text.content` can be a plain string or the same rich content run shape used by +RouteGraphics text. Rich runs can override styles with `textStyleId`, and +furigana can use its own nested `textStyleId`. + +```yaml +visual: + items: + - id: locationLabel + text: + content: + - text: "Kanji" + furigana: + text: "reading" + textStyleId: ruby + - text: " label" + textStyleId: emphasis + textStyleId: title + width: 640 + transformId: titleTop + anchorX: 0.5 + anchorY: 0.5 +``` + +After a text visual exists, later lines can patch it by `id` without restating +the whole text config: + +```yaml +visual: + items: + - id: chapterTitle + text: + content: "Chapter 2" +``` + +For a new text visual, `text.content` and `text.textStyleId` are both required. +For a patch to an existing text visual, either field can be supplied alone. + ### Item Transform Overrides `resources.transforms` can define `x`, `y`, `anchorX`, `anchorY`, `scaleX`, @@ -806,7 +874,8 @@ shape as `background.blur` and `screen.blur`; `blur: null` clears the item blur. Character item appearance applies to the whole character container, so every sprite part is faded or blurred together. Visual item appearance applies to the -single visual item container, sprite, video, animated sprite, or layout. +single visual item container, sprite, video, animated sprite, layout, or text +element. ```yaml character: diff --git a/package.json b/package.json index 8e756b1..aaca010 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "1.22.0", + "version": "1.23.0", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git", diff --git a/spec/constructPresentationState.backgroundTransform.test.js b/spec/constructPresentationState.backgroundTransform.test.js new file mode 100644 index 0000000..469c073 --- /dev/null +++ b/spec/constructPresentationState.backgroundTransform.test.js @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { constructPresentationState } from "../src/stores/constructPresentationState.js"; +import { constructRenderState } from "../src/stores/constructRenderState.js"; + +const createResources = () => ({ + images: { + bg: { + fileId: "bg.png", + width: 1920, + height: 1080, + }, + }, + videos: {}, + layouts: {}, + animations: {}, + transforms: { + preset: { + x: 100, + y: 200, + anchorX: 0.5, + anchorY: 0.5, + scaleX: 1, + scaleY: 1, + rotation: 0, + }, + }, + characters: {}, + controls: {}, + colors: {}, + fonts: {}, + sectionTransitions: {}, + sounds: {}, + sprites: {}, + spritesheets: {}, + textStyles: {}, + variables: {}, +}); + +const findBackgroundSprite = (elements = []) => { + const pending = [...elements]; + while (pending.length > 0) { + const element = pending.shift(); + if (element?.id === "bg-cg-background-sprite") { + return element; + } + pending.push(...(element?.children ?? [])); + } + return undefined; +}; + +describe("constructPresentationState background transforms", () => { + it("does not inherit previous inline background transform when transformId is selected", () => { + const presentationState = constructPresentationState([ + { + background: { + resourceId: "bg", + x: 900, + y: 800, + anchorX: 0.5, + anchorY: 0.5, + scaleX: 1.2, + scaleY: 1.2, + rotation: 0, + }, + }, + { + background: { + resourceId: "bg", + transformId: "preset", + }, + }, + ]); + + expect(presentationState.background).toEqual({ + resourceId: "bg", + transformId: "preset", + }); + + const renderState = constructRenderState({ + presentationState, + resources: createResources(), + screen: { + width: 1920, + height: 1080, + }, + }); + + expect(findBackgroundSprite(renderState.elements)).toMatchObject({ + x: 100, + y: 200, + scaleX: 1, + scaleY: 1, + }); + }); + + it("keeps explicit inline overrides supplied with a transformId", () => { + const presentationState = constructPresentationState([ + { + background: { + resourceId: "bg", + x: 900, + y: 800, + }, + }, + { + background: { + resourceId: "bg", + transformId: "preset", + x: 300, + }, + }, + ]); + + expect(presentationState.background).toEqual({ + resourceId: "bg", + transformId: "preset", + x: 300, + }); + + const renderState = constructRenderState({ + presentationState, + resources: createResources(), + screen: { + width: 1920, + height: 1080, + }, + }); + + expect(findBackgroundSprite(renderState.elements)).toMatchObject({ + x: 300, + y: 200, + }); + }); +}); diff --git a/spec/projectDataSchema.test.js b/spec/projectDataSchema.test.js index 4d4e63b..51a314e 100644 --- a/spec/projectDataSchema.test.js +++ b/spec/projectDataSchema.test.js @@ -447,6 +447,104 @@ describe("projectData schema", () => { expect(validatePresentationActions.errors).toBeNull(); }); + it("accepts text-backed visual items with rich text content", () => { + expect( + validatePresentationActions({ + visual: { + items: [ + { + id: "title", + text: { + content: [ + { + text: "Kanji", + furigana: { + text: "furigana", + textStyleId: "ruby", + }, + }, + { + text: " title", + textStyleId: "accent", + }, + ], + textStyleId: "title", + width: 640, + }, + transformId: "titleTop", + layer: 70, + opacity: 0.9, + animations: { + resourceId: "fadeIn", + }, + }, + ], + }, + }), + ).toBe(true); + expect(validatePresentationActions.errors).toBeNull(); + }); + + it("accepts partial text-backed visual item patches", () => { + expect( + validatePresentationActions({ + visual: { + items: [ + { + id: "title", + text: { + content: "Chapter 2", + }, + }, + { + id: "subtitle", + text: { + textStyleId: "subtitleMuted", + }, + }, + ], + }, + }), + ).toBe(true); + expect(validatePresentationActions.errors).toBeNull(); + }); + + it("rejects ambiguous text-backed visual items", () => { + expect( + validatePresentationActions({ + visual: { + items: [ + { + id: "title", + resourceId: "titleImage", + text: { + content: "Chapter 1", + textStyleId: "title", + }, + }, + ], + }, + }), + ).toBe(false); + + expect( + validatePresentationActions({ + visual: { + items: [ + { + id: "title", + text: { + type: "text", + content: "Chapter 1", + textStyleId: "title", + }, + }, + ], + }, + }), + ).toBe(false); + }); + it("requires transformId when character sprites are supplied", () => { expect( validatePresentationActions({ diff --git a/spec/system/constructPresentationState.spec.yaml b/spec/system/constructPresentationState.spec.yaml index 2b608ec..5a748b3 100644 --- a/spec/system/constructPresentationState.spec.yaml +++ b/spec/system/constructPresentationState.spec.yaml @@ -902,6 +902,76 @@ out: originX: 20 originY: 40 --- +case: visual text content-only update keeps previous text style, transform, appearance, and layer +in: + - - visual: + items: + - id: "title" + text: + content: "Chapter 1" + textStyleId: "title" + transformId: "titleTop" + layer: 70 + opacity: 0.8 + - visual: + items: + - id: "title" + text: + content: "Chapter 2" +out: + visual: + items: + - id: "title" + text: + content: "Chapter 2" + textStyleId: "title" + transformId: "titleTop" + layer: 70 + opacity: 0.8 +--- +case: visual text style-only update keeps previous content +in: + - - visual: + items: + - id: "title" + text: + content: "Chapter 1" + textStyleId: "title" + transformId: "titleTop" + - visual: + items: + - id: "title" + text: + textStyleId: "accentTitle" +out: + visual: + items: + - id: "title" + text: + content: "Chapter 1" + textStyleId: "accentTitle" + transformId: "titleTop" +--- +case: new visual text item requires content and textStyleId +in: + - - visual: + items: + - id: "title" + text: + content: "Chapter 1" +throws: 'Visual item "title" text requires content and textStyleId' +--- +case: visual item rejects resource and text together +in: + - - visual: + items: + - id: "title" + resourceId: "title-image" + text: + content: "Chapter 1" + textStyleId: "title" +throws: 'Visual item "title" cannot define both resourceId and text' +--- case: item transform overrides persist across later item updates in: - - visual: diff --git a/spec/system/renderState/addVisuals.spec.yaml b/spec/system/renderState/addVisuals.spec.yaml index 18aa4f6..ce9ad63 100644 --- a/spec/system/renderState/addVisuals.spec.yaml +++ b/spec/system/renderState/addVisuals.spec.yaml @@ -116,6 +116,213 @@ out: animations: [] audio: [] --- +case: add visual text with transform overrides, templated content, opacity, and blur +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + audio: [] + - presentationState: + visual: + items: + - id: "title" + text: + content: "${variables.chapterTitle}" + textStyleId: "title" + width: 640 + transformId: "titleTransform" + x: 960 + y: 140 + anchorX: 0.5 + anchorY: 0.5 + scaleX: 1.1 + scaleY: 1.1 + rotation: -3 + originX: 320 + originY: 40 + opacity: 0.9 + blur: + x: 2 + y: 3 + resources: + textStyles: + title: + fontId: "fontMain" + colorId: "fg" + fontSize: 42 + fontWeight: "700" + fontStyle: "normal" + lineHeight: 1.1 + align: "center" + fonts: + fontMain: + fileId: "Arial" + colors: + fg: + hex: "#FFFFFF" + transforms: + titleTransform: + x: 100 + y: 200 + anchorX: 0 + anchorY: 0 + rotation: 0 + scaleX: 1 + scaleY: 1 + variables: + chapterTitle: "Chapter 1" +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - id: "visual-title" + type: "text" + content: "Chapter 1" + width: 640 + x: 960 + y: 140 + anchorX: 0.5 + anchorY: 0.5 + rotation: -3 + scaleX: 1.1 + scaleY: 1.1 + originX: 320 + originY: 40 + alpha: 0.9 + blur: + x: 2 + y: 3 + textStyle: + fontFamily: "Arial" + fontSize: 42 + fontWeight: "700" + fontStyle: "normal" + lineHeight: 1.1 + fill: "#FFFFFF" + align: "center" + animations: [] + audio: [] +--- +case: add rich visual text content and resolve nested text style ids +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + audio: [] + - presentationState: + visual: + items: + - id: "richTitle" + text: + content: + - text: "Kanji" + furigana: + text: "reading" + textStyleId: "ruby" + - text: " label" + textStyleId: "accent" + textStyleId: "body" + width: 500 + transformId: "titleTransform" + resources: + textStyles: + body: + fontId: "fontMain" + colorId: "fg" + fontSize: 36 + fontWeight: "400" + fontStyle: "normal" + lineHeight: 1.2 + accent: + fontId: "fontMain" + colorId: "accent" + fontSize: 36 + fontWeight: "700" + fontStyle: "normal" + lineHeight: 1.2 + ruby: + fontId: "fontMain" + colorId: "muted" + fontSize: 14 + fontWeight: "400" + fontStyle: "normal" + lineHeight: 1 + fonts: + fontMain: + fileId: "Arial" + colors: + fg: + hex: "#FFFFFF" + accent: + hex: "#FFD166" + muted: + hex: "#BBBBBB" + transforms: + titleTransform: + x: 320 + y: 180 + anchorX: 0 + anchorY: 0 + rotation: 0 + scaleX: 1 + scaleY: 1 +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - id: "visual-richTitle" + type: "text" + content: + - text: "Kanji" + furigana: + text: "reading" + textStyle: + fontFamily: "Arial" + fontSize: 14 + fontWeight: "400" + fontStyle: "normal" + lineHeight: 1 + fill: "#BBBBBB" + - text: " label" + textStyle: + fontFamily: "Arial" + fontSize: 36 + fontWeight: "700" + fontStyle: "normal" + lineHeight: 1.2 + fill: "#FFD166" + width: 500 + x: 320 + y: 180 + anchorX: 0 + anchorY: 0 + rotation: 0 + scaleX: 1 + scaleY: 1 + textStyle: + fontFamily: "Arial" + fontSize: 36 + fontWeight: "400" + fontStyle: "normal" + lineHeight: 1.2 + fill: "#FFFFFF" + animations: [] + audio: [] +--- case: add visual sprite with opacity and blur to story container in: - elements: @@ -290,6 +497,100 @@ in: fileId: "visual.png" throws: 'Visual item "visual1" requires transformId' --- +case: incomplete visual text should throw clear error +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + audio: [] + - presentationState: + visual: + items: + - id: "title" + text: + content: "Chapter 1" + transformId: "titleTransform" + resources: + transforms: + titleTransform: + x: 100 + y: 200 + anchorX: 0 + anchorY: 0 + rotation: 0 + scaleX: 1 + scaleY: 1 +throws: 'Visual item "title" text requires content and textStyleId' +--- +case: visual text cannot be combined with resourceId +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + audio: [] + - presentationState: + visual: + items: + - id: "title" + resourceId: "image1" + text: + content: "Chapter 1" + textStyleId: "title" + transformId: "titleTransform" + resources: + images: + image1: + fileId: "visual.png" + transforms: + titleTransform: + x: 100 + y: 200 + anchorX: 0 + anchorY: 0 + rotation: 0 + scaleX: 1 + scaleY: 1 +throws: 'Visual item "title" cannot define both resourceId and text' +--- +case: visual text rejects visual item fields inside text config +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + audio: [] + - presentationState: + visual: + items: + - id: "title" + text: + content: "Chapter 1" + textStyleId: "title" + type: "text" + transformId: "titleTransform" + resources: + transforms: + titleTransform: + x: 100 + y: 200 + anchorX: 0 + anchorY: 0 + rotation: 0 + scaleX: 1 + scaleY: 1 +throws: 'Visual item "title" text.type is reserved for the visual item' +--- case: unknown transformId should throw clear error in: - elements: @@ -436,6 +737,61 @@ out: value: 0 audio: [] --- +case: add visual text with transition animation on removal +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + audio: [] + - presentationState: + visual: + items: + - id: "title" + animations: + resourceId: "fadeOut" + previousPresentationState: + visual: + items: + - id: "title" + text: + content: "Chapter 1" + textStyleId: "title" + transformId: "titleTransform" + resources: + animations: + fadeOut: + type: transition + prev: + tween: + alpha: + initialValue: 1 + keyframes: + - duration: 300 + value: 0 +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: + - id: "title-animation-out" + type: "transition" + targetId: "visual-title" + prev: + tween: + alpha: + initialValue: 1 + keyframes: + - duration: 300 + value: 0 + audio: [] +--- case: add visual with single transition animation reference (no previous state) in: - elements: diff --git a/src/schemas/presentationActions.yaml b/src/schemas/presentationActions.yaml index 7babe63..cf76657 100644 --- a/src/schemas/presentationActions.yaml +++ b/src/schemas/presentationActions.yaml @@ -63,6 +63,67 @@ definitions: required: [x, y] additionalProperties: false + visualTextContent: + description: RouteGraphics text content. A string renders plain text; an array can contain rich text runs such as per-run styles and furigana. + anyOf: + - type: string + - type: array + items: + type: object + properties: + text: + type: string + textStyleId: + type: string + furigana: + type: object + properties: + text: + type: string + textStyleId: + type: string + additionalProperties: true + additionalProperties: true + + visualText: + type: object + description: Text-backed visual configuration. Placement, layer, opacity, blur, and animations stay on the visual item. + properties: + content: + $ref: "#/definitions/visualTextContent" + textStyleId: + type: string + description: Shared text style resource for the visual text + width: + type: number + description: Optional text layout width passed to the renderer + height: + type: number + description: Optional text layout height passed to the renderer + anyOf: + - required: [content] + - required: [textStyleId] + not: + anyOf: + - required: [id] + - required: [type] + - required: [resourceId] + - required: [transformId] + - required: [x] + - required: [y] + - required: [anchorX] + - required: [anchorY] + - required: [scaleX] + - required: [scaleY] + - required: [rotation] + - required: [originX] + - required: [originY] + - required: [layer] + - required: [opacity] + - required: [blur] + - required: [animations] + additionalProperties: true + properties: cleanAll: type: object @@ -313,6 +374,8 @@ properties: resourceId: type: string description: ID of the resource + text: + $ref: "#/definitions/visualText" transformId: type: string description: ID of the transform where visual appears @@ -362,6 +425,9 @@ properties: animations: $ref: "#/definitions/animationSelection" required: [id] + allOf: + - not: + required: [resourceId, text] additionalProperties: false additionalProperties: false diff --git a/src/stores/constructPresentationState.js b/src/stores/constructPresentationState.js index 4a53f87..78b4427 100644 --- a/src/stores/constructPresentationState.js +++ b/src/stores/constructPresentationState.js @@ -70,6 +70,54 @@ const BACKGROUND_TRANSFORM_FIELDS = [ const hasItemTransform = (item) => ITEM_TRANSFORM_FIELDS.some((field) => hasDefinedProperty(item, field)); +const hasCompleteVisualText = (item) => + item?.text && + hasDefinedProperty(item.text, "content") && + hasDefinedProperty(item.text, "textStyleId"); + +const hasVisualTextPatch = (item) => hasDefinedProperty(item, "text"); + +const hasVisualSubject = (item) => + !!item.resourceId || hasCompleteVisualText(item); + +const mergeVisualItemPatch = (previousItem, item) => { + const mergedItem = { + ...clonePresentationValue(previousItem), + ...item, + }; + + if (item.text && previousItem.text) { + mergedItem.text = { + ...clonePresentationValue(previousItem.text), + ...clonePresentationValue(item.text), + }; + } + + return mergedItem; +}; + +const assertVisualTextPatch = (item, previousItem) => { + if (!hasVisualTextPatch(item)) { + return; + } + + if (item.resourceId) { + throw new Error( + `Visual item "${item.id}" cannot define both resourceId and text`, + ); + } + + if (hasCompleteVisualText(item)) { + return; + } + + if (!previousItem?.text) { + throw new Error( + `Visual item "${item.id}" text requires content and textStyleId`, + ); + } +}; + const hasBackgroundTransform = (background) => BACKGROUND_TRANSFORM_FIELDS.some((field) => hasDefinedProperty(background, field), @@ -80,8 +128,10 @@ const applyPersistentBackgroundTransform = (background, previousBackground) => { return background; } + const shouldInheritTransform = !hasOwnProperty(background, "transformId"); for (const field of BACKGROUND_TRANSFORM_FIELDS) { if ( + shouldInheritTransform && !hasDefinedProperty(background, field) && hasDefinedProperty(previousBackground, field) ) { @@ -160,6 +210,7 @@ const processItemsWithAnimations = ( items, hasResourceFn, previousItems = [], + { hasPatchFn = () => false, mergeItemFn } = {}, ) => { if (!items || items.length === 0) { return { hasValidItems: false, processedItems: [] }; @@ -171,14 +222,21 @@ const processItemsWithAnimations = ( const hasResource = hasResourceFn(item); const hasAppearance = hasItemAppearance(item); const hasTransform = hasItemTransform(item); + const hasPatch = hasPatchFn(item); const hasAnimations = hasOwnProperty(item, "animations"); let processedItem = clonePresentationValue(item); - if (!hasResource && (hasAppearance || hasTransform) && previousItem) { - processedItem = { - ...clonePresentationValue(previousItem), - ...processedItem, - }; + if ( + !hasResource && + (hasAppearance || hasTransform || hasPatch) && + previousItem + ) { + processedItem = mergeItemFn + ? mergeItemFn(previousItem, processedItem) + : { + ...clonePresentationValue(previousItem), + ...processedItem, + }; if (!hasAnimations) { delete processedItem.animations; @@ -679,10 +737,20 @@ export const bgm = (state, presentation) => { */ export const visual = (state, presentation) => { if (presentation.visual) { + const previousItems = state.visual?.items || []; + + presentation.visual.items?.forEach((item, index) => { + assertVisualTextPatch(item, findPreviousItem(previousItems, item, index)); + }); + const { hasValidItems, processedItems } = processItemsWithAnimations( presentation.visual.items, - (item) => !!item.resourceId, - state.visual?.items || [], + hasVisualSubject, + previousItems, + { + hasPatchFn: hasVisualTextPatch, + mergeItemFn: mergeVisualItemPatch, + }, ); if (hasValidItems) { diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 0d7d3b3..88c7060 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -1304,6 +1304,69 @@ const getElementTransform = (transform = {}, item = {}) => { return elementTransform; }; +const VISUAL_TEXT_RESERVED_FIELDS = [ + "id", + "type", + "resourceId", + "transformId", + "x", + "y", + "anchorX", + "anchorY", + "scaleX", + "scaleY", + "rotation", + "originX", + "originY", + "layer", + "opacity", + "blur", + "animations", +]; + +const hasCompleteVisualText = (item = {}) => + item.text && + hasOwnProperty(item.text, "content") && + hasOwnProperty(item.text, "textStyleId"); + +const getVisualSubjectKey = (item = {}) => { + if (item.resourceId) { + return item.resourceId; + } + + if (hasCompleteVisualText(item)) { + return "text"; + } + + return undefined; +}; + +const assertVisualTextConfig = (item) => { + if (!item.text) { + return; + } + + if (item.resourceId) { + throw new Error( + `Visual item "${item.id}" cannot define both resourceId and text`, + ); + } + + if (!hasCompleteVisualText(item)) { + throw new Error( + `Visual item "${item.id}" text requires content and textStyleId`, + ); + } + + for (const fieldName of VISUAL_TEXT_RESERVED_FIELDS) { + if (hasOwnProperty(item.text, fieldName)) { + throw new Error( + `Visual item "${item.id}" text.${fieldName} is reserved for the visual item`, + ); + } + } +}; + const createBackgroundColorElement = ({ resources, background, @@ -2524,6 +2587,21 @@ export const addVisuals = ( const items = presentationState.visual.items; const previousItems = previousPresentationState?.visual?.items || []; + const visualTemplateData = createLayoutTemplateData({ + variables, + runtime, + saveSlots, + dialogueState: presentationState.dialogue, + isLineCompleted, + autoMode, + skipMode, + isChoiceVisible, + isFormVisible, + canRollback, + form, + characters: resources.characters || {}, + skipTransitionsAndAnimations, + }); if (visualLayer !== undefined) { assertVisualLayer(visualLayer, "visualLayer"); @@ -2537,6 +2615,28 @@ export const addVisuals = ( continue; } + assertVisualTextConfig(item); + + if (item.text) { + const transform = getRequiredVisualTransform(resources, item); + const textElement = { + ...structuredClone(item.text), + id: `visual-${item.id}`, + type: "text", + ...getElementTransform(transform, item), + }; + Object.assign(textElement, getItemAppearance(item)); + + storyContainer.children.push( + resolveLayoutResourceIds( + parseAndRender(textElement, visualTemplateData, { + functions: jemplFunctions, + }), + resources, + ), + ); + } + if (item.resourceId) { const { images = {}, videos = {}, spritesheets = {} } = resources; @@ -2617,21 +2717,7 @@ export const addVisuals = ( Object.assign(visualContainer, getItemAppearance(item)); const processedContainer = parseAndRender( visualContainer, - createLayoutTemplateData({ - variables, - runtime, - saveSlots, - dialogueState: presentationState.dialogue, - isLineCompleted, - autoMode, - skipMode, - isChoiceVisible, - isFormVisible, - canRollback, - form, - characters: resources.characters || {}, - skipTransitionsAndAnimations, - }), + visualTemplateData, { functions: jemplFunctions }, ); storyContainer.children.push( @@ -2647,15 +2733,19 @@ export const addVisuals = ( } } + const previousVisualSubjectKey = getVisualSubjectKey(previousItem); + const currentVisualSubjectKey = getVisualSubjectKey(item); const visualAnimationInstances = createAnimationInstances({ animationsDef: item.animations, resources, - previousResourceId: previousItem?.resourceId, - currentResourceId: item.resourceId, - previousTargetId: previousItem?.resourceId + previousResourceId: previousVisualSubjectKey, + currentResourceId: currentVisualSubjectKey, + previousTargetId: previousVisualSubjectKey + ? `visual-${item.id}` + : undefined, + currentTargetId: currentVisualSubjectKey ? `visual-${item.id}` : undefined, - currentTargetId: item.resourceId ? `visual-${item.id}` : undefined, animationPath: `visual.items[${item.id}].animations`, idPrefix: item.id, }); diff --git a/vt/reference/visual/text-01.webp b/vt/reference/visual/text-01.webp new file mode 100644 index 0000000..bebf3c2 --- /dev/null +++ b/vt/reference/visual/text-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1b062fe56d4a976530352abd1e07ff2e68484e38992608ddedf41de8f2b073d +size 4216 diff --git a/vt/reference/visual/text-02.webp b/vt/reference/visual/text-02.webp new file mode 100644 index 0000000..a548462 --- /dev/null +++ b/vt/reference/visual/text-02.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b92b33d7d7a65a08b7a64f9775cf8c2e3e2fe33236895234d51607a00ca195c7 +size 4634 diff --git a/vt/reference/visual/text-03.webp b/vt/reference/visual/text-03.webp new file mode 100644 index 0000000..fefcfd4 --- /dev/null +++ b/vt/reference/visual/text-03.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcec103b4bdbfdef1aba44babbf827819faa0232443e874b256c0565e27ea59a +size 990 diff --git a/vt/specs/visual/text.yaml b/vt/specs/visual/text.yaml new file mode 100644 index 0000000..a8cc399 --- /dev/null +++ b/vt/specs/visual/text.yaml @@ -0,0 +1,200 @@ +--- +title: Visual Text +description: | + Text-backed visual items should render as normal visuals: + - plain string content renders with the shared text style + - rich content with furigana renders through RouteGraphics text + - content/style-only patches keep prior visual state by id + - transform overrides and animated removal use the same visual item path +skipInitialScreenshot: true +steps: + - action: wait + ms: 1000 + - action: screenshot + - action: customEvent + name: vt:nextLine + - action: wait + ms: 150 + - action: screenshot + - action: customEvent + name: vt:nextLine + - action: wait + ms: 520 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#111111" +resources: + controls: + nextLineHitbox: + elements: + - id: next-line-hitbox + type: rect + x: 0 + y: 0 + width: 1920 + height: 1080 + colorId: transparentBlack + alpha: 0.001 + click: + payload: + actions: + nextLine: {} + colors: + transparentBlack: + hex: "#000000" + bg: + hex: "#111111" + title: + hex: "#FFFFFF" + accent: + hex: "#FFD166" + muted: + hex: "#7DD3FC" + ruby: + hex: "#C8CDD4" + fonts: + default: + fileId: Arial + textStyles: + titleText: + fontId: default + colorId: title + fontSize: 72 + fontWeight: "700" + fontStyle: normal + lineHeight: 1.1 + align: center + richText: + fontId: default + colorId: title + fontSize: 58 + fontWeight: "500" + fontStyle: normal + lineHeight: 1.25 + align: center + richAccent: + fontId: default + colorId: accent + fontSize: 58 + fontWeight: "700" + fontStyle: normal + lineHeight: 1.25 + align: center + mutedRich: + fontId: default + colorId: muted + fontSize: 58 + fontWeight: "700" + fontStyle: normal + lineHeight: 1.25 + align: center + rubyText: + fontId: default + colorId: ruby + fontSize: 22 + fontWeight: "400" + fontStyle: normal + lineHeight: 1 + align: center + transforms: + titleTop: + x: 960 + y: 190 + anchorX: 0.5 + anchorY: 0.5 + rotation: 0 + scaleX: 1 + scaleY: 1 + originX: 360 + originY: 50 + richCenter: + x: 960 + y: 390 + anchorX: 0.5 + anchorY: 0.5 + rotation: 0 + scaleX: 1 + scaleY: 1 + originX: 360 + originY: 60 + animations: + fadeOut: + type: transition + prev: + tween: + alpha: + initialValue: 1 + keyframes: + - duration: 400 + value: 0 + easing: linear +story: + initialSceneId: visualTextScene + scenes: + visualTextScene: + name: Visual Text Scene + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + control: + resourceId: nextLineHitbox + background: + colorId: bg + visual: + items: + - id: title + text: + content: "Visual Text" + textStyleId: titleText + width: 720 + transformId: titleTop + layer: 70 + - id: rich + text: + content: + - text: "SKY" + furigana: + text: "above" + textStyleId: rubyText + - text: " LABEL" + textStyleId: richAccent + textStyleId: richText + width: 720 + transformId: richCenter + layer: 70 + - id: line2 + actions: + visual: + items: + - id: title + text: + content: "Patched Text" + x: 960 + y: 610 + anchorX: 0.5 + anchorY: 0.5 + scaleX: 0.9 + scaleY: 0.9 + rotation: -4 + originX: 360 + originY: 50 + - id: rich + text: + textStyleId: mutedRich + y: 325 + - id: line3 + actions: + visual: + items: + - id: title + animations: + resourceId: fadeOut + - id: rich + animations: + resourceId: fadeOut From ce9bc1a76c9a47b7d1990753ffcd40644ca1ba8d Mon Sep 17 00:00:00 2001 From: Luciano Hanyon Wu Date: Sun, 31 May 2026 18:29:56 +0800 Subject: [PATCH 2/3] fix: preserve visual update semantics --- ...sentationState.backgroundTransform.test.js | 47 +++++++++++ spec/system/renderState/addVisuals.spec.yaml | 79 +++++++++++++++++++ src/stores/constructPresentationState.js | 13 ++- src/stores/constructRenderState.js | 3 +- 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/spec/constructPresentationState.backgroundTransform.test.js b/spec/constructPresentationState.backgroundTransform.test.js index 469c073..3213e8b 100644 --- a/spec/constructPresentationState.backgroundTransform.test.js +++ b/spec/constructPresentationState.backgroundTransform.test.js @@ -131,4 +131,51 @@ describe("constructPresentationState background transforms", () => { y: 200, }); }); + + it("keeps previous inline transform overrides on non-transform partial updates", () => { + const presentationState = constructPresentationState([ + { + background: { + resourceId: "bg", + transformId: "preset", + x: 300, + y: 400, + scaleX: 1.3, + scaleY: 1.3, + }, + }, + { + background: { + opacity: 0.5, + }, + }, + ]); + + expect(presentationState.background).toEqual({ + resourceId: "bg", + transformId: "preset", + x: 300, + y: 400, + scaleX: 1.3, + scaleY: 1.3, + opacity: 0.5, + }); + + const renderState = constructRenderState({ + presentationState, + resources: createResources(), + screen: { + width: 1920, + height: 1080, + }, + }); + + expect(findBackgroundSprite(renderState.elements)).toMatchObject({ + x: 300, + y: 400, + scaleX: 1.3, + scaleY: 1.3, + alpha: 0.5, + }); + }); }); diff --git a/spec/system/renderState/addVisuals.spec.yaml b/spec/system/renderState/addVisuals.spec.yaml index ce9ad63..1be5d4c 100644 --- a/spec/system/renderState/addVisuals.spec.yaml +++ b/spec/system/renderState/addVisuals.spec.yaml @@ -2357,6 +2357,85 @@ out: animations: [] audio: [] --- +case: complete text visual defaults to visual layer instead of inheriting previous patch layer +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + audio: [] + - presentationState: + visual: + items: + - id: "title" + text: + content: "New Title" + textStyleId: "title" + transformId: "transform1" + previousPresentationState: + visual: + items: + - id: "title" + resourceId: "oldTitle" + transformId: "transform1" + layer: 90 + resources: + textStyles: + title: + fontId: "fontMain" + colorId: "fg" + fontSize: 42 + fontWeight: "700" + fontStyle: "normal" + lineHeight: 1.1 + align: "center" + fonts: + fontMain: + fileId: "Arial" + colors: + fg: + hex: "#FFFFFF" + transforms: + transform1: + x: 100 + y: 200 + anchorX: 0.5 + anchorY: 0.5 + rotation: 0 + scaleX: 1 + scaleY: 1 + visualLayer: 50 +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - id: "visual-title" + type: "text" + content: "New Title" + x: 100 + y: 200 + anchorX: 0.5 + anchorY: 0.5 + rotation: 0 + scaleX: 1 + scaleY: 1 + textStyle: + fontFamily: "Arial" + fontSize: 42 + fontWeight: "700" + fontStyle: "normal" + lineHeight: 1.1 + fill: "#FFFFFF" + align: "center" + animations: [] + audio: [] +--- case: route animation-only visual removal through previous item layer in: - elements: diff --git a/src/stores/constructPresentationState.js b/src/stores/constructPresentationState.js index 78b4427..0046edb 100644 --- a/src/stores/constructPresentationState.js +++ b/src/stores/constructPresentationState.js @@ -123,15 +123,18 @@ const hasBackgroundTransform = (background) => hasDefinedProperty(background, field), ); -const applyPersistentBackgroundTransform = (background, previousBackground) => { +const applyPersistentBackgroundTransform = ( + background, + previousBackground, + { hasAuthoredTransformId = false } = {}, +) => { if (!previousBackground) { return background; } - const shouldInheritTransform = !hasOwnProperty(background, "transformId"); for (const field of BACKGROUND_TRANSFORM_FIELDS) { if ( - shouldInheritTransform && + !hasAuthoredTransformId && !hasDefinedProperty(background, field) && hasDefinedProperty(previousBackground, field) ) { @@ -457,7 +460,9 @@ export const background = (state, presentation) => { } } - applyPersistentBackgroundTransform(nextBackground, previousBackground); + applyPersistentBackgroundTransform(nextBackground, previousBackground, { + hasAuthoredTransformId: hasTransformId, + }); if (!hasColorId && previousBackground?.colorId) { nextBackground.colorId = previousBackground.colorId; diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 88c7060..9d8cc30 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -2544,9 +2544,10 @@ const assertVisualLayer = (layer, path) => { }; const resolveVisualItemLayer = (item, previousItem) => { + const hasCurrentSubject = !!item.resourceId || hasCompleteVisualText(item); const layer = item.layer ?? - (!item.resourceId ? previousItem?.layer : undefined) ?? + (!hasCurrentSubject ? previousItem?.layer : undefined) ?? DEFAULT_VISUAL_LAYER; assertVisualLayer(layer, `Visual item "${item.id}" layer`); From c5781524c1cf27d402c72c6ce06a7b0ae43dc966 Mon Sep 17 00:00:00 2001 From: Luciano Hanyon Wu Date: Sun, 31 May 2026 19:46:11 +0800 Subject: [PATCH 3/3] fix: preserve full text visual patches --- .../constructPresentationState.spec.yaml | 28 +++++++++++++++++++ src/stores/constructPresentationState.js | 15 ++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/spec/system/constructPresentationState.spec.yaml b/spec/system/constructPresentationState.spec.yaml index 5a748b3..f76ac9f 100644 --- a/spec/system/constructPresentationState.spec.yaml +++ b/spec/system/constructPresentationState.spec.yaml @@ -952,6 +952,34 @@ out: textStyleId: "accentTitle" transformId: "titleTop" --- +case: visual text full update keeps previous transform, appearance, and layer +in: + - - visual: + items: + - id: "title" + text: + content: "Chapter 1" + textStyleId: "title" + transformId: "titleTop" + layer: 70 + opacity: 0.8 + - visual: + items: + - id: "title" + text: + content: "Chapter 2" + textStyleId: "accentTitle" +out: + visual: + items: + - id: "title" + text: + content: "Chapter 2" + textStyleId: "accentTitle" + transformId: "titleTop" + layer: 70 + opacity: 0.8 +--- case: new visual text item requires content and textStyleId in: - - visual: diff --git a/src/stores/constructPresentationState.js b/src/stores/constructPresentationState.js index 0046edb..f208572 100644 --- a/src/stores/constructPresentationState.js +++ b/src/stores/constructPresentationState.js @@ -77,8 +77,17 @@ const hasCompleteVisualText = (item) => const hasVisualTextPatch = (item) => hasDefinedProperty(item, "text"); -const hasVisualSubject = (item) => - !!item.resourceId || hasCompleteVisualText(item); +const hasVisualSubject = (item, previousItem) => { + if (item.resourceId) { + return true; + } + + if (!hasCompleteVisualText(item)) { + return false; + } + + return !previousItem?.text; +}; const mergeVisualItemPatch = (previousItem, item) => { const mergedItem = { @@ -222,7 +231,7 @@ const processItemsWithAnimations = ( const processedItems = items .map((item, index) => { const previousItem = findPreviousItem(previousItems, item, index); - const hasResource = hasResourceFn(item); + const hasResource = hasResourceFn(item, previousItem); const hasAppearance = hasItemAppearance(item); const hasTransform = hasItemTransform(item); const hasPatch = hasPatchFn(item);