diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index 12a4540..9e5fd76 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -655,22 +655,22 @@ Built-in effect handling notes: Actions that can be attached to lines to control presentation: -| Action | Properties | Description | -| ------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `screen` | `{ opacity?, blur?, animations? }` | Set whole-screen appearance or transition. `opacity`/`blur` apply to the composed story frame | -| `background` | `{ resourceId?, colorId?, transformId?, opacity?, blur?, animations? }` | Set background/CG. `opacity` maps to renderer alpha; `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` | -| `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]` | -| `animation` | `{ ... }` | Apply animations | -| `layout` | `{ resourceId }` | Display layout | -| `control` | `{ resourceId }` | Activate control bindings and control UI | -| `choice` | `{ resourceId, items }` | Display choice menu | -| `form` | `{ resourceId, fields, submitActions?, cancelActions? }` | Display a blocking multi-input form | -| `cleanAll` | `true` | Clear all presentation state | +| Action | Properties | Description | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `screen` | `{ opacity?, blur?, animations? }` | Set whole-screen appearance or transition. `opacity`/`blur` apply to the composed story frame | +| `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` | +| `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]` | +| `animation` | `{ ... }` | Apply animations | +| `layout` | `{ resourceId }` | Display layout | +| `control` | `{ resourceId }` | Activate control bindings and control UI | +| `choice` | `{ resourceId, items }` | Display choice menu | +| `form` | `{ resourceId, fields, submitActions?, cancelActions? }` | Display a blocking multi-input form | +| `cleanAll` | `true` | Clear all presentation state | Animation selections use `animations.resourceId` plus optional `animations.playback`. `playback.speed` is a unitless multiplier: `1` is normal, @@ -715,12 +715,55 @@ callers can use the exported `RENDER_LAYER`, `VISUAL_LAYER`, and ### Item Transform Overrides `resources.transforms` can define `x`, `y`, `anchorX`, `anchorY`, `scaleX`, -`scaleY`, `rotation`, `originX`, and `originY`. Character and visual items can -override any of those transform fields for a single item. `originX` and -`originY` are passed through to the renderer as the transform origin fields. -New character sprite items still require `transformId`; after an item exists, a -later line can provide only `id` and transform fields to patch that item without -restating its sprites or visual resource. +`scaleY`, `rotation`, `originX`, and `originY`. `x` and `y` use renderer pixels, +anchors are normalized unitless values, scale fields are multipliers, and +`rotation` is degrees. Character and visual items can override any of those +transform fields for a single item. `originX` and `originY` are passed through +to the renderer as the transform origin fields. New character sprite items still +require `transformId`; after an item exists, a later line can provide only `id` +and transform fields to patch that item without restating its sprites or visual +resource. + +Background actions can also set `x`, `y`, `anchorX`, `anchorY`, `scaleX`, +`scaleY`, `rotation`, `originX`, and `originY` at the top level. These fields +can override selected values from `transformId`, or position the background +without any `transformId`. Image and video backgrounds default to centered +placement, computed as `x = screen.width / 2` and `y = screen.height / 2`, with +`anchorX: 0.5`, `anchorY: 0.5`, `rotation: 0`, `scaleX: 1`, and `scaleY: 1`. +Layout backgrounds use top-left defaults when a background transform is +authored. + +Background transforms support three authoring modes: + +| Mode | Behavior | +| --------------------------------- | ---------------------------------------------------------------------- | +| `transformId` only | Use the complete transform resource | +| `transformId` plus inline fields | Start from the transform resource, then override the provided fields | +| inline fields without transformId | Apply the provided fields over the background type's default transform | + +```yaml +background: + resourceId: bg_school + transformId: fullscreen + +background: + resourceId: bg_school + transformId: fullscreen + scaleX: 1.1 + scaleY: 1.1 + +background: + resourceId: bg_school + x: 960 + y: 540 + anchorX: 0.5 + anchorY: 0.5 + rotation: 0 + scaleX: 1 + scaleY: 1 + originX: 960 + originY: 540 +``` ```yaml character: diff --git a/package.json b/package.json index 1efcb54..8e756b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "1.21.0", + "version": "1.22.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/projectDataSchema.test.js b/spec/projectDataSchema.test.js index 80a5a68..4d4e63b 100644 --- a/spec/projectDataSchema.test.js +++ b/spec/projectDataSchema.test.js @@ -191,7 +191,7 @@ describe("projectData schema", () => { ); }); - it("accepts background transformId in presentation actions", () => { + it("accepts background transformId and inline transform fields in presentation actions", () => { expect( validatePresentationActions({ background: { @@ -202,6 +202,24 @@ describe("projectData schema", () => { ).toBe(true); expect(validatePresentationActions.errors).toBeNull(); + expect( + validatePresentationActions({ + background: { + resourceId: "bg1", + x: 100, + y: 120, + anchorX: 0, + anchorY: 1, + scaleX: 1.2, + scaleY: 0.8, + rotation: -8, + originX: 64, + originY: 128, + }, + }), + ).toBe(true); + expect(validatePresentationActions.errors).toBeNull(); + expect( validatePresentationActions({ character: { diff --git a/spec/system/constructPresentationState.spec.yaml b/spec/system/constructPresentationState.spec.yaml index e51e5ae..2b608ec 100644 --- a/spec/system/constructPresentationState.spec.yaml +++ b/spec/system/constructPresentationState.spec.yaml @@ -119,6 +119,44 @@ out: transformId: "centered" colorId: "backdrop" --- +case: background inline transform update keeps the current background resource +in: + - - background: + resourceId: "mainBackground" + transformId: "centered" + x: 960 + y: 540 + anchorX: 0.5 + anchorY: 0.5 + scaleX: 1 + scaleY: 1 + rotation: 0 + originX: 960 + originY: 540 + - background: + x: 100 + y: 120 + anchorX: 0 + anchorY: 1 + scaleX: 1.2 + scaleY: 0.8 + rotation: -8 + originX: 64 + originY: 128 +out: + background: + resourceId: "mainBackground" + transformId: "centered" + x: 100 + y: 120 + anchorX: 0 + anchorY: 1 + scaleX: 1.2 + scaleY: 0.8 + rotation: -8 + originX: 64 + originY: 128 +--- case: background opacity and blur update keeps the current background resource in: - - background: diff --git a/spec/system/renderState/addBackgroundOrCg.spec.yaml b/spec/system/renderState/addBackgroundOrCg.spec.yaml index d8b5efd..7c0629b 100644 --- a/spec/system/renderState/addBackgroundOrCg.spec.yaml +++ b/spec/system/renderState/addBackgroundOrCg.spec.yaml @@ -422,6 +422,129 @@ out: height: 1080 animations: [] --- +case: add background image with inline transform to story container +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + - presentationState: + background: + resourceId: "bg1" + x: 100 + y: 120 + anchorX: 0 + anchorY: 1 + scaleX: 1.2 + scaleY: 0.8 + rotation: -8 + originX: 64 + originY: 128 + resources: + images: + bg1: + fileId: "image1.png" + width: 1920 + height: 1080 +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - id: "bg-cg-background-color" + type: "rect" + x: 0 + y: 0 + width: 1920 + height: 1080 + fill: "#000000" + - id: "bg-cg-background-sprite" + type: "sprite" + alpha: 1 + anchorX: 0 + anchorY: 1 + rotation: -8 + scaleX: 1.2 + scaleY: 0.8 + originX: 64 + originY: 128 + x: 100 + y: 120 + src: "image1.png" + width: 1920 + height: 1080 + animations: [] +--- +case: background inline transform overrides transform resource fields +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + - presentationState: + background: + resourceId: "bg1" + transformId: "bgTransform" + x: 500 + scaleY: 0.75 + rotation: 22 + originX: 32 + originY: 96 + resources: + images: + bg1: + fileId: "image1.png" + width: 1920 + height: 1080 + transforms: + bgTransform: + x: 200 + y: 300 + anchorX: 0 + anchorY: 1 + scaleX: 1.25 + scaleY: 1 + originX: 64 + originY: 128 +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - id: "bg-cg-background-color" + type: "rect" + x: 0 + y: 0 + width: 1920 + height: 1080 + fill: "#000000" + - id: "bg-cg-background-sprite" + type: "sprite" + alpha: 1 + anchorX: 0 + anchorY: 1 + rotation: 22 + scaleX: 1.25 + scaleY: 0.75 + originX: 32 + originY: 96 + x: 500 + y: 300 + src: "image1.png" + width: 1920 + height: 1080 + animations: [] +--- case: unknown background transformId should throw clear error in: - elements: @@ -1596,6 +1719,72 @@ out: fill: "#000000" animations: [] --- +case: add background layout with inline transform +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + - presentationState: + background: + resourceId: "bgLayout" + x: 400 + y: 250 + anchorX: 0 + anchorY: 0 + rotation: 12 + scaleX: 0.75 + scaleY: 1.1 + originX: 64 + originY: 128 + resources: + layouts: + bgLayout: + elements: + - id: "bg-panel" + type: "rect" + width: 100 + height: 50 + colorId: "panel" + colors: + panel: + hex: "#000000" +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - id: "bg-cg-background-color" + type: "rect" + x: 0 + y: 0 + width: 1920 + height: 1080 + fill: "#000000" + - id: "bg-cg-background-container" + type: "container" + x: 400 + y: 250 + anchorX: 0 + anchorY: 0 + rotation: 12 + scaleX: 0.75 + scaleY: 1.1 + originX: 64 + originY: 128 + children: + - id: "bg-panel" + type: "rect" + width: 100 + height: 50 + fill: "#000000" + animations: [] +--- case: resolves colorId in background layout elements in: - elements: diff --git a/spec/system/selectors/selectPresentationChanges.spec.yaml b/spec/system/selectors/selectPresentationChanges.spec.yaml index 04571ed..9f5c709 100644 --- a/spec/system/selectors/selectPresentationChanges.spec.yaml +++ b/spec/system/selectors/selectPresentationChanges.spec.yaml @@ -260,6 +260,49 @@ out: resourceId: "bg-school" transformId: "wide" --- +case: "Detect updated background inline transform as resource change" +in: + - state: + projectData: + story: + scenes: + scene1: + sections: + section1: + lines: + - id: "1" + actions: + background: + resourceId: "bg-school" + colorId: "night" + - id: "2" + actions: + background: + x: 200 + y: 120 + scaleX: 1.25 + rotation: -8 + originX: 64 + originY: 128 + contexts: + - currentPointerMode: "read" + pointers: + read: + sectionId: "section1" + lineId: "2" +out: + background: + resource: + changeType: "update" + data: + resourceId: "bg-school" + x: 200 + y: 120 + scaleX: 1.25 + rotation: -8 + originX: 64 + originY: 128 +--- case: "Detect deleted background resource and color via cleanAll" in: - state: diff --git a/src/schemas/presentationActions.yaml b/src/schemas/presentationActions.yaml index d0ca1c9..7babe63 100644 --- a/src/schemas/presentationActions.yaml +++ b/src/schemas/presentationActions.yaml @@ -101,6 +101,33 @@ properties: transformId: type: string description: Optional transform resource to position the background + x: + type: number + description: Custom x position in renderer pixels to override the background transform x + y: + type: number + description: Custom y position in renderer pixels to override the background transform y + anchorX: + type: number + description: Custom normalized x anchor point to override the background transform anchorX + anchorY: + type: number + description: Custom normalized y anchor point to override the background transform anchorY + scaleX: + type: number + description: Custom unitless x scale multiplier to override the background transform scaleX + scaleY: + type: number + description: Custom unitless y scale multiplier to override the background transform scaleY + rotation: + type: number + description: Custom rotation in degrees to override the background transform rotation + originX: + type: number + description: Custom x transform origin in renderer pixels to override the background transform originX + originY: + type: number + description: Custom y transform origin in renderer pixels to override the background transform originY opacity: type: number minimum: 0 diff --git a/src/stores/constructPresentationState.js b/src/stores/constructPresentationState.js index 892a073..4a53f87 100644 --- a/src/stores/constructPresentationState.js +++ b/src/stores/constructPresentationState.js @@ -55,9 +55,47 @@ const ITEM_TRANSFORM_FIELDS = [ "originY", ]; +const BACKGROUND_TRANSFORM_FIELDS = [ + "x", + "y", + "anchorX", + "anchorY", + "scaleX", + "scaleY", + "rotation", + "originX", + "originY", +]; + const hasItemTransform = (item) => ITEM_TRANSFORM_FIELDS.some((field) => hasDefinedProperty(item, field)); +const hasBackgroundTransform = (background) => + BACKGROUND_TRANSFORM_FIELDS.some((field) => + hasDefinedProperty(background, field), + ); + +const applyPersistentBackgroundTransform = (background, previousBackground) => { + if (!previousBackground) { + return background; + } + + for (const field of BACKGROUND_TRANSFORM_FIELDS) { + if ( + !hasDefinedProperty(background, field) && + hasDefinedProperty(previousBackground, field) + ) { + background[field] = previousBackground[field]; + } + + if (hasOwnProperty(background, field) && background[field] === undefined) { + delete background[field]; + } + } + + return background; +}; + const findPreviousItem = (previousItems, item, index) => { const previousAtIndex = previousItems[index]; if (previousAtIndex?.id === item?.id) { @@ -309,6 +347,11 @@ export const background = (state, presentation) => { const hasColorId = hasDefinedProperty(presentation.background, "colorId"); const hasOpacity = hasDefinedProperty(presentation.background, "opacity"); const hasBlur = hasDefinedProperty(presentation.background, "blur"); + const hasTransformId = hasDefinedProperty( + presentation.background, + "transformId", + ); + const hasTransform = hasBackgroundTransform(presentation.background); const { animationsOnly, state: animState } = getAnimationsOnlyState( presentation.background, @@ -316,7 +359,9 @@ export const background = (state, presentation) => { hasDefinedProperty(p, "resourceId") || hasDefinedProperty(p, "colorId") || hasDefinedProperty(p, "opacity") || - hasDefinedProperty(p, "blur"), + hasDefinedProperty(p, "blur") || + hasDefinedProperty(p, "transformId") || + hasBackgroundTransform(p), ); if (animationsOnly) { @@ -329,7 +374,14 @@ export const background = (state, presentation) => { return; } - if (!hasResourceId && !hasColorId && !hasOpacity && !hasBlur) { + if ( + !hasResourceId && + !hasColorId && + !hasOpacity && + !hasBlur && + !hasTransformId && + !hasTransform + ) { delete state.background; return; } @@ -347,6 +399,8 @@ export const background = (state, presentation) => { } } + applyPersistentBackgroundTransform(nextBackground, previousBackground); + if (!hasColorId && previousBackground?.colorId) { nextBackground.colorId = previousBackground.colorId; } @@ -380,6 +434,13 @@ export const background = (state, presentation) => { delete nextBackground.animations; } + if ( + hasOwnProperty(nextBackground, "transformId") && + nextBackground.transformId === undefined + ) { + delete nextBackground.transformId; + } + if ( hasOwnProperty(nextBackground, "opacity") && nextBackground.opacity === undefined diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 8ed7a1c..0d7d3b3 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -618,6 +618,23 @@ const getBackgroundTransform = (resources, background = {}) => { return transform; }; +const BACKGROUND_TRANSFORM_FIELDS = [ + "x", + "y", + "anchorX", + "anchorY", + "scaleX", + "scaleY", + "rotation", + "originX", + "originY", +]; + +const hasBackgroundTransformOverrides = (background = {}) => + BACKGROUND_TRANSFORM_FIELDS.some((field) => + hasOwnProperty(background, field), + ); + const resolveBackgroundKind = (resources = {}, resourceId) => { if (!resourceId) { return undefined; @@ -2128,6 +2145,9 @@ export const addBackgroundOrCg = ( resources, presentationState.background, ); + const hasAuthoredBackgroundTransform = + authoredBackgroundTransform || + hasBackgroundTransformOverrides(presentationState.background); const previousBackgroundResourceId = previousPresentationState?.background?.resourceId; @@ -2193,7 +2213,10 @@ export const addBackgroundOrCg = ( ...getBackgroundAppearance(presentationState.background, { includeDefaultAlpha: true, }), - ...getElementTransform(backgroundTransform), + ...getElementTransform( + backgroundTransform, + presentationState.background, + ), }; if (isVideo) { @@ -2218,19 +2241,22 @@ export const addBackgroundOrCg = ( children: layout.elements, ...getBackgroundAppearance(presentationState.background), }; - if (authoredBackgroundTransform) { + if (hasAuthoredBackgroundTransform) { Object.assign( bgContainer, - getElementTransform({ - x: 0, - y: 0, - anchorX: 0, - anchorY: 0, - rotation: 0, - scaleX: 1, - scaleY: 1, - ...authoredBackgroundTransform, - }), + getElementTransform( + { + x: 0, + y: 0, + anchorX: 0, + anchorY: 0, + rotation: 0, + scaleX: 1, + scaleY: 1, + ...authoredBackgroundTransform, + }, + presentationState.background, + ), ); } const processedContainer = parseAndRender( diff --git a/src/util.js b/src/util.js index f3d9aec..244c774 100644 --- a/src/util.js +++ b/src/util.js @@ -1330,6 +1330,22 @@ const toBackgroundResourceChangeData = (background) => { data.transformId = background.transformId; } + for (const field of [ + "x", + "y", + "anchorX", + "anchorY", + "scaleX", + "scaleY", + "rotation", + "originX", + "originY", + ]) { + if (background[field] !== undefined) { + data[field] = background[field]; + } + } + if (background.loop !== undefined) { data.loop = background.loop; } diff --git a/vt/reference/background/inline-transform-01.webp b/vt/reference/background/inline-transform-01.webp new file mode 100644 index 0000000..c48d184 --- /dev/null +++ b/vt/reference/background/inline-transform-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c8e57e65340ae6fb288f29002bc9210b730553e657d2b6889b36e4d34fff7cbc +size 4372 diff --git a/vt/specs/background/inline-transform.yaml b/vt/specs/background/inline-transform.yaml new file mode 100644 index 0000000..72863a6 --- /dev/null +++ b/vt/specs/background/inline-transform.yaml @@ -0,0 +1,40 @@ +--- +title: Background Inline Transform +description: | + Image backgrounds should support inline transform fields without requiring a + transformId resource. + + - the image background should render from the authored inline x/y position + - the authored inline anchor, scale, rotation, and origin should override centered defaults +--- +screen: + width: 1920 + height: 1080 +resources: + images: + bg-card: + fileId: dmni32 + width: 640 + height: 360 +story: + initialSceneId: backgroundScene + scenes: + backgroundScene: + name: Background Scene + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + background: + resourceId: bg-card + x: 640 + y: 340 + anchorX: 0 + anchorY: 0 + rotation: -12 + scaleX: 1.25 + scaleY: 1.25 + originX: 320 + originY: 180