diff --git a/docs/PresentationSubjectReplacement.md b/docs/PresentationSubjectReplacement.md new file mode 100644 index 00000000..a9c10611 --- /dev/null +++ b/docs/PresentationSubjectReplacement.md @@ -0,0 +1,450 @@ +# Presentation Subject Replacement Semantics + +Status: implemented in Route Engine v1.46.0. + +## Decision Summary + +Presentation actions distinguish continuity from replacement by whether they +explicitly author a render subject: + +- omitting a subject continues the current presentation instance +- authoring only non-subject fields patches the current instance +- explicitly authoring a subject creates or reapplies an instance from only the + fields in that action +- subject equality is irrelevant; explicitly reauthoring the same resource or + content still resets every omitted instance field +- explicitly authored fields on the replacement are applied normally + +This rule applies consistently to backgrounds, character items, and visual +items. + +Replacement resets all omitted state owned by that background, character item, +or visual item. This includes appearance, placement, layer, backing color, +resource playback overrides, and animation selection. The subject and fields +explicitly present in the replacement action are the complete new instance. + +This behavior does not reset other presentation channels or screen-level +state, and it does not redefine the existing collection and removal rules for +other item IDs. + +## Why + +An authored resource is an instruction to present an instance, not merely a +statement about which asset ID happens to be current. Reauthoring the same +resource can intentionally replay an entrance, restore its defaults, or start +a new presentation of the asset. + +Comparing the next subject with the previous subject makes intent depend on +value equality: + +```yaml +# These would behave differently if equality controlled replacement. +background: + resourceId: forest + +background: + resourceId: street +``` + +That is difficult to explain and prevents an author from reapplying `forest` +as a fresh instance. Presence provides a simpler contract: + +> Omission means continuity. Explicit subject authoring means replacement. + +It also prevents state chosen for one instance from leaking accidentally onto +the next instance. Brightness, contrast, saturation, blur, placement, layer, +backing color, playback overrides, and animations can all be specific to one +presentation of a subject. + +If a project needs settings across several subjects, it should author those +settings on each replacement. A future whole-screen filter facility may +provide a separate persistent scope for global scene grading; that facility is +outside this change. + +## Authoring Contract + +Subject-bearing actions are complete declarations of the desired presentation +instance. Authors and authoring tools must include every non-default setting +the instance needs. An omitted optional field intentionally selects its normal +default; it is not a request to recover that field from the previous instance. + +An intentional patch must omit the subject-bearing field. For example, a +background action without `resourceId` may adjust the active background, while +a background action with `resourceId` fully declares a new or reapplied +background instance. + +The engine must not compensate for incomplete subject-bearing actions by +merging previous state. That would make a syntactically complete action depend +on execution history and recreate the ambiguity this contract removes. + +> **Deprecation note:** History-dependent subject-less continuation patches, +> such as `background: { opacity: 0.8 }`, remain supported for compatibility in +> v1.46.0 but are planned for removal in a future version. New content and +> authoring tools should omit the channel when nothing changes, or emit the +> complete subject-bearing action and every desired non-default field. + +## Terminology + +### Presentation instance + +The active background, character item, or visual item stored in presentation +state. + +Character and visual item `id` values identify persistent item slots so later +actions can find an existing item. Reusing an item ID does not by itself imply +that any state from its previous instance must be inherited. The `id` remains +on a replacement because the replacement action explicitly authors it as the +item lookup key. + +### Subject + +The content rendered by a presentation instance: + +| Target | Subject-bearing authoring | +| --------------------------- | ------------------------------------------------------------------- | +| Background | `resourceId` | +| Character item | a non-empty `sprites` array | +| Resource-backed visual item | `resourceId` | +| Inline-layout visual item | a complete `layout` value | +| Text visual item | a complete `text` value containing both `content` and `textStyleId` | + +A partial text patch containing only `content` or only `textStyleId` is not a +complete subject. It continues the existing text visual and therefore +preserves other omitted instance fields. + +Changing a visual between `resourceId`, `layout`, and `text` forms is always a +subject replacement. + +### Instance-owned state + +All fields stored on the active target belong to its presentation instance: + +| Target | Instance-owned state | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Background | `resourceId`, `colorId`, transform fields, `alpha`/`opacity`, `blur`, `filters`, resource playback overrides, and `animations` | +| Character item | `id`, `sprites`, transform fields, `alpha`/`opacity`, `blur`, `filters`, and `animations` | +| Visual item | `id`, its subject form, transform fields, `layer`, `alpha`/`opacity`, `blur`, `filters`, resource playback overrides, and `animations` | + +On replacement, Route Engine retains none of these fields from the previous +instance. Fields explicitly authored beside the new subject form the new +instance. Defaults for omitted fields are resolved through the existing +resource and renderer defaults. + +Route Engine does not model brightness, contrast, or saturation as dedicated +action fields. Those values are renderer shader-filter parameters and reset +with the containing `filters` array. + +## Normative Rules + +For each target, Route Engine determines whether the current action explicitly +authors a subject. It must not compare that subject with the previous subject. + +Conceptually: + +```text +if action explicitly authors a subject: + next instance = normalized explicitly authored action only +else: + next instance = previous instance patched by authored fields +``` + +### Omitted action + +When a line omits a presentation channel entirely, the existing channel state +continues according to its current persistence rules. + +```yaml +- background: + resourceId: forest + blur: { x: 6, y: 6 } +- dialogue: + mode: adv + content: + - text: The forest remains blurred. +``` + +### Continuation patch + +An action without a subject patches the current instance. All omitted instance +fields are inherited according to the existing patch rules. This is legacy +compatibility behavior covered by the deprecation note in the authoring +contract; new content should not rely on it. + +```yaml +- background: + resourceId: forest + filters: + - id: grade + type: shader + parameters: + saturation: 0.5 +- background: + blur: { x: 3, y: 3 } +``` + +The second action keeps `forest` and its `filters`, then adds or replaces +`blur`. + +### Subject replacement + +An explicit subject starts a fresh instance, whether the subject value is new +or identical to the current value. + +```yaml +- background: + resourceId: forest + alpha: 0.6 + blur: { x: 6, y: 6 } +- background: + resourceId: forest +``` + +The second action produces a background state containing only +`resourceId: forest`. Alpha, blur, filters, transform, backing color, playback +overrides, and animations all use their defaults because none were authored on +the replacement. + +The same rule applies to a character composition: + +```yaml +- character: + items: + - id: alice + transformId: center + sprites: + - id: body + resourceId: alice-happy + animationSpeed: 0.5 + x: 900 + blur: { x: 6, y: 6 } +- character: + items: + - id: alice + transformId: center + sprites: + - id: body + resourceId: alice-happy +``` + +The second `alice` item contains only its explicitly authored `id`, +`transformId`, and `sprites`. It does not inherit `x`, the sprite playback +override, blur, filters, opacity, or animations even though its item ID and +sprite resource are identical. Character `sprites` currently require an +explicit `transformId`, so that authored placement remains on the replacement. + +It also applies to a resource-backed visual: + +```yaml +- visual: + items: + - id: fog + resourceId: fog-heavy + transformId: fullscreen + layer: 70 + animationSpeed: 0.5 + filters: + - id: desaturate + type: shader +- visual: + items: + - id: fog + resourceId: fog-heavy +``` + +The second `fog` item contains only `id` and `resourceId`. It does not inherit +the transform, layer, resource playback override, filter, opacity, blur, or +animations. Its layer and placement therefore resolve from their defaults. + +### Replacement with explicit state + +Explicit fields apply to the new instance: + +```yaml +background: + resourceId: street + transformId: fullscreen + alpha: 0.8 + filters: + - id: night-grade + type: shader +``` + +This produces `street` with the authored transform, alpha, and filter. Every +other field uses its default. + +### Explicit clearing remains valid + +Existing clear forms remain useful for continuation patches: + +- `alpha: 1` resets alpha +- `blur: null` clears blur +- `filters: []` clears filters + +A subject replacement does not need to emit clear or default values merely to +avoid inheritance. Absence on a replacement already means the field is absent +from the new instance and its normal default applies. + +## Behavior Matrix + +| Authored action | Subject replacement | Inherit any omitted instance state | +| ----------------------------------------- | ------------------: | ---------------------------------: | +| Channel omitted | No | Yes | +| Background patch without `resourceId` | No | Yes | +| Background with any defined `resourceId` | Yes | No | +| Character item patch without `sprites` | No | Yes | +| Character item with non-empty `sprites` | Yes | No | +| Visual item patch without a full subject | No | Yes | +| Visual item with any defined `resourceId` | Yes | No | +| Visual item with complete `layout` | Yes | No | +| Visual item with complete `text` | Yes | No | +| Visual item with partial `text` patch | No | Yes | + +“Any defined `resourceId`” includes the same string already present in state. +The engine checks authored presence, not equality. + +## Implemented Behavior + +The presentation-state reducer detects explicit subject authoring before it +applies any previous-state merge or persistence helper. A subject replacement +uses only the normalized fields in the current action. A continuation patch +retains the existing field-specific patch and persistence behavior. + +As a result, this sequence does not transfer the first background's appearance +to the second: + +```yaml +- background: + resourceId: forest + blur: { x: 6, y: 6 } +- background: + resourceId: street +``` + +The result is a new instance containing only `resourceId: street`. + +## Implementation Notes + +The implementation uses one shared concept of explicit subject authoring rather +than resource-equality branches in each reducer. + +1. It detects background, character-item, and visual-item subject authoring using + the table above. +2. For a subject replacement, it constructs the next instance solely from the + normalized authored action. +3. It does not run previous-state merge, transform persistence, appearance + persistence, playback persistence, or persistent-animation restoration for + that instance. +4. For a continuation patch, it retains the existing field-specific patch and + persistence behavior. +5. It continues normalizing explicitly authored `alpha` to the internal legacy + `opacity` representation. +6. The public action documentation and schema descriptions expose the same + contract. + +Renderer changes are unnecessary. The render state already consumes the +resolved presentation state; the behavioral change belongs at the point where +presentation actions are merged. + +There are no equality checks or inheritance flags. Authors who intentionally +want state on a replacement state it explicitly. + +## Verification Coverage + +Both state transitions and rendered output are covered. + +### Targeted state coverage + +System and unit cases for background, character, and visual targets prove: + +1. an omitted channel preserves existing instance state +2. a continuation patch preserves the subject and every unmentioned field +3. reauthoring the same subject resets every omitted instance field +4. authoring a different subject resets every omitted instance field +5. replacement with explicit instance fields uses only the explicit values +6. `blur: null` and `filters: []` still clear appearance on patches +7. a partial visual-text patch preserves other instance state +8. a complete visual-text or inline-layout subject resets other instance state +9. screen state and unrelated item IDs are unaffected + +Each target must cover representative appearance, transform, playback, and +animation fields. Visual coverage must also include `layer`, and background +coverage must include `colorId`. At least one case per target must cover a +filter with brightness, contrast, or saturation parameters so the test +protects the entire opaque filter object, not only blur. + +### Render and VT coverage + +The actual visual path is validated with isolated background, character, and +visual VT scenarios. Each scenario shows: + +1. a subject rendered with unmistakable non-default appearance and placement +2. a continuation patch retaining the unmentioned instance state +3. an explicit reassertion rendering with default appearance, placement, and + layer where applicable + +Keep the three targets isolated rather than combining them on one page. This +makes a failure attributable to one state transition and follows the VT +isolation requirements in `docs/vt-guidelines.md`. + +The implementation PR runs the targeted automated tests and compares the +relevant VT screenshots before review. + +## Compatibility and Rollout + +This is an intentional behavior change. Existing projects may repeat a subject +while relying on any inherited instance field, including transform, layer, +backing color, playback, animation, opacity, blur, or filters. Those actions +will render differently under these semantics. + +The affected persistence behavior is not limited to the latest release: + +| Existing behavior | Present since | +| --------------------------------------------------------------------- | ------------- | +| Background `colorId` persists across resource updates | v1.15.0 | +| Background opacity and blur persist across updates | v1.16.0 | +| Character and visual opacity and blur persist by item ID | v1.19.0 | +| Character and visual transform overrides persist by item ID | v1.20.0 | +| Inline background transform overrides persist | v1.22.0 | +| Full text-visual updates preserve placement and appearance | v1.23.0 | +| Persistent character and visual animations survive compatible updates | v1.39.1 | +| Background, character, and visual filters persist | v1.42.0 | + +Presentation subject merging is materially the same in v1.42.0, v1.43.0, and +v1.45.0. A project upgrading from any of those recent versions can therefore +observe this change. + +The break is silent rather than structural: existing YAML remains valid, but a +subject-bearing action that relies on omitted instance fields renders +differently. Actions that omit the subject for continuation, and replacements +that explicitly author every required setting, are unaffected. + +The supported authoring contract expects complete subject-bearing actions, so +the practical risk for conforming projects is low. The migration risk is +concentrated in hand-authored content and older or third-party tools that used +subject-bearing actions as partial patches. + +Route Engine has no project-level presentation-semantics version from which to +select old versus new merging behavior. Under the complete-action authoring +contract, the change ships as a feature release in Route Engine v1.46.0, with +the migration risk for partial subject-bearing actions documented here. + +There is no universal authored clear form for every instance field, so having +authoring tools emit `alpha: 1`, `blur: null`, and `filters: []` cannot fully +reproduce fresh-instance behavior. A compatibility mode would first require a +versioned project or engine option and is not included here. + +For an engine-level rollout, migration tooling should identify subject-bearing +actions that omit instance fields after prior state was established for the +same channel or item ID. Authors who intended inheritance can copy the intended +fields onto the replacement action. Authors who intended a fresh instance need +no additional fields after migration. + +## Out of Scope + +- changing screen-level opacity or blur persistence +- adding screen-level shader filters +- changing renderer or resource default values +- changing continuation-patch inheritance +- adding per-sprite-part character appearance +- comparing previous and next resource values +- changing item removal or array replacement behavior diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index 2d3dbd62..f21e6659 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -411,11 +411,15 @@ visual: # WGSL source with mainVertex and mainFragment ``` -Action filters are persistent appearance. A filters-only action updates the -existing target without repeating its resource, transform, or character sprite -parts. Omission preserves the current filters; `filters: []` clears them. For a -background with a renderable resource and a backing `colorId`, filters apply to -the resource target. A color-only background applies filters to its color rect. +Action filters are instance appearance. A filters-only continuation action +updates the existing target without repeating its resource, transform, or +character sprite parts. Omission on a continuation patch preserves the current +filters; `filters: []` clears them. Explicitly reauthoring a background +`resourceId`, a non-empty character `sprites` array, or a complete visual +subject creates a fresh instance and resets omitted filters along with all other +omitted instance state. For a background with a renderable resource and a +backing `colorId`, filters apply to the resource target. A color-only background +applies filters to its color rect. #### Layout Text Styles @@ -1510,8 +1514,9 @@ transform fields remain supported as overrides for compatibility. #### Updating an inline layout -Later lines can replace the inline layout by visual `id` while retaining its -transform, layer, alpha, and blur. Inline transform patches merge by field: +A complete inline `layout` reauthors the visual subject and creates a fresh +instance. Include every non-default transform, layer, appearance, and animation +field required by that instance: ```yaml visual: @@ -1524,15 +1529,26 @@ visual: content: "Chapter 2" textStyleId: title transform: + x: 960 y: 220 + anchorX: 0.5 + anchorY: 0.5 + scaleX: 1 + scaleY: 1 + rotation: 0 + layer: 70 ``` -Appearance-only and animation-only updates do not need to repeat the layout: +To patch the current inline-layout instance, omit `layout`. Inline transform +patches then merge by field, and appearance-only or animation-only updates do +not need to repeat the subject: ```yaml visual: items: - id: chapterTitle + transform: + y: 240 alpha: 0.5 animations: resourceId: titleFadeOut @@ -1543,7 +1559,9 @@ visual: The existing `text` form remains supported for compatibility. New projects should prefer an inline layout so the visual can grow beyond one text element. For a new legacy text visual, both `text.content` and `text.textStyleId` are -required; later patches can supply either field alone. +required. A later action containing both fields reauthors the subject and +creates a fresh instance; a partial text patch can supply either field alone +and preserves the other instance fields. ```yaml visual: @@ -1716,6 +1734,20 @@ sprite part is faded, blurred, or filtered together. Visual item appearance applies to the single visual item container, sprite, video, animated sprite, particle system, layout, or text element. +Appearance persists when its presentation channel is omitted or when a later +item action omits the subject. Explicitly authoring a background `resourceId`, +a non-empty character `sprites` array, a visual `resourceId`, a complete visual +`layout`, or complete visual `text` creates a fresh instance from that action +alone. Omitted appearance, placement, layer, playback, and animation fields +then use their defaults, even if the subject value is unchanged. See +[Presentation Subject Replacement Semantics](PresentationSubjectReplacement.md) +for the complete contract and migration notes. + +> **Deprecation note:** Subject-less continuation patches such as a background +> action containing only `opacity` remain supported for compatibility in +> v1.46.0, but support is planned for removal. New content should omit an +> unchanged presentation channel or emit its complete subject-bearing action. + ```yaml character: items: diff --git a/package.json b/package.json index 8c6f155f..03b58419 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "1.45.2", + "version": "1.46.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.subjectReplacement.test.js b/spec/constructPresentationState.subjectReplacement.test.js new file mode 100644 index 00000000..3113c6e5 --- /dev/null +++ b/spec/constructPresentationState.subjectReplacement.test.js @@ -0,0 +1,328 @@ +import { describe, expect, it } from "vitest"; +import { constructPresentationState } from "../src/stores/constructPresentationState.js"; + +const blur = { + x: 6, + y: 9, + quality: 3, + kernelSize: 9, + repeatEdgePixels: true, +}; + +const grade = { + id: "grade", + type: "shader", + parameters: { + brightness: 1.2, + contrast: 0.8, + saturation: 0.5, + }, +}; + +const persistentAnimation = { + resourceId: "persistent-motion", + playback: { + continuity: "persistent", + loop: true, + }, +}; + +const createBackground = () => ({ + resourceId: "forest", + animationName: "idle", + animationSpeed: 0.5, + loop: false, + colorId: "backdrop", + transformId: "fullscreen", + x: 120, + y: 80, + anchorX: 0.5, + anchorY: 0.5, + scaleX: 1.2, + scaleY: 1.1, + rotation: 4, + originX: 960, + originY: 540, + opacity: 0.6, + blur, + filters: [grade], + animations: persistentAnimation, +}); + +const createCharacter = () => ({ + id: "lead", + transformId: "center", + x: 120, + y: 980, + anchorX: 0.5, + anchorY: 1, + scaleX: 0.9, + scaleY: 0.9, + rotation: 4, + originX: 20, + originY: 40, + opacity: 0.6, + blur, + filters: [grade], + animations: persistentAnimation, + sprites: [ + { + id: "body", + resourceId: "lead-body", + animationName: "idle", + animationSpeed: 0.5, + loop: false, + }, + ], +}); + +const createVisual = () => ({ + id: "fog", + resourceId: "fog-heavy", + animationName: "idle", + animationSpeed: 0.5, + transformId: "fullscreen", + x: 120, + y: 80, + anchorX: 0.5, + anchorY: 0.5, + scaleX: 1.2, + scaleY: 1.1, + rotation: 4, + originX: 20, + originY: 40, + layer: 70, + opacity: 0.6, + blur, + filters: [grade], + animations: persistentAnimation, +}); + +describe("presentation subject replacement", () => { + describe("background", () => { + it.each([ + ["same", "forest"], + ["different", "street"], + ])( + "resets every omitted field when the %s resource is authored", + (_, resourceId) => { + const state = constructPresentationState([ + { + screen: { opacity: 0.9 }, + background: createBackground(), + }, + { background: { resourceId } }, + ]); + + expect(state.background).toEqual({ resourceId }); + expect(state.screen).toEqual({ opacity: 0.9 }); + }, + ); + + it("preserves omitted fields for a continuation patch", () => { + const initialBackground = createBackground(); + const state = constructPresentationState([ + { background: initialBackground }, + { background: { blur: null } }, + ]); + + expect(state.background).toEqual({ + ...initialBackground, + blur: null, + }); + }); + + it("uses only fields explicitly authored on a replacement", () => { + const state = constructPresentationState([ + { background: createBackground() }, + { + background: { + resourceId: "street", + transformId: "street-fit", + alpha: 0.8, + }, + }, + ]); + + expect(state.background).toEqual({ + resourceId: "street", + transformId: "street-fit", + opacity: 0.8, + }); + }); + }); + + describe("character items", () => { + it.each([ + ["same", "lead-body"], + ["different", "lead-alt"], + ])( + "resets every omitted field when the %s sprite composition is authored", + (_, resourceId) => { + const sprites = [{ id: "body", resourceId }]; + const state = constructPresentationState([ + { character: { items: [createCharacter()] } }, + { + character: { + items: [{ id: "lead", transformId: "right", sprites }], + }, + }, + ]); + + expect(state.character.items[0]).toEqual({ + id: "lead", + transformId: "right", + sprites, + }); + }, + ); + + it("preserves the current composition and omitted fields for a continuation patch", () => { + const initialCharacter = createCharacter(); + const state = constructPresentationState([ + { character: { items: [initialCharacter] } }, + { + character: { + items: [{ id: "lead", transformId: "right", opacity: 0.75 }], + }, + }, + ]); + + expect(state.character.items[0]).toEqual({ + ...initialCharacter, + transformId: "right", + opacity: 0.75, + }); + }); + }); + + describe("visual items", () => { + it.each([ + ["same", "fog-heavy"], + ["different", "fog-light"], + ])( + "resets every omitted field when the %s resource is authored", + (_, resourceId) => { + const state = constructPresentationState([ + { visual: { items: [createVisual()] } }, + { visual: { items: [{ id: "fog", resourceId }] } }, + ]); + + expect(state.visual.items[0]).toEqual({ id: "fog", resourceId }); + }, + ); + + it("preserves omitted fields for a continuation patch", () => { + const initialVisual = createVisual(); + const state = constructPresentationState([ + { visual: { items: [initialVisual] } }, + { visual: { items: [{ id: "fog", opacity: 0.75 }] } }, + ]); + + expect(state.visual.items[0]).toEqual({ + ...initialVisual, + opacity: 0.75, + }); + }); + + it("treats a complete text value as a fresh subject", () => { + const state = constructPresentationState([ + { + visual: { + items: [ + { + id: "fog", + text: { content: "Chapter 1", textStyleId: "title" }, + transformId: "title-top", + layer: 70, + opacity: 0.6, + filters: [grade], + animations: persistentAnimation, + }, + ], + }, + }, + { + visual: { + items: [ + { + id: "fog", + text: { content: "Chapter 2", textStyleId: "accent" }, + }, + ], + }, + }, + ]); + + expect(state.visual.items[0]).toEqual({ + id: "fog", + text: { content: "Chapter 2", textStyleId: "accent" }, + }); + }); + + it("preserves omitted fields for a partial text patch", () => { + const state = constructPresentationState([ + { + visual: { + items: [ + { + id: "title", + text: { content: "Chapter 1", textStyleId: "title" }, + transformId: "title-top", + layer: 70, + opacity: 0.6, + filters: [grade], + }, + ], + }, + }, + { + visual: { + items: [{ id: "title", text: { content: "Chapter 2" } }], + }, + }, + ]); + + expect(state.visual.items[0]).toEqual({ + id: "title", + text: { content: "Chapter 2", textStyleId: "title" }, + transformId: "title-top", + layer: 70, + opacity: 0.6, + filters: [grade], + }); + }); + + it("treats a complete inline layout as a fresh subject", () => { + const nextLayout = { + elements: [{ id: "title", type: "text", content: "Chapter 2" }], + }; + const state = constructPresentationState([ + { + visual: { + items: [ + { + id: "title", + layout: { + elements: [ + { id: "title", type: "text", content: "Chapter 1" }, + ], + }, + transformId: "title-top", + layer: 70, + opacity: 0.6, + filters: [grade], + }, + ], + }, + }, + { visual: { items: [{ id: "title", layout: nextLayout }] } }, + ]); + + expect(state.visual.items[0]).toEqual({ + id: "title", + layout: nextLayout, + }); + }); + }); +}); diff --git a/spec/system/constructPresentationState.spec.yaml b/spec/system/constructPresentationState.spec.yaml index cec42a27..2bfb0c37 100644 --- a/spec/system/constructPresentationState.spec.yaml +++ b/spec/system/constructPresentationState.spec.yaml @@ -94,7 +94,7 @@ out: content: - text: "Next line" --- -case: background colorId persists across resource updates +case: background resource replacement resets colorId in: - - background: resourceId: "mainBackground" @@ -104,7 +104,6 @@ in: out: background: resourceId: "altBackground" - colorId: "backdrop" --- case: background colorId update keeps the current background resource in: @@ -242,7 +241,7 @@ out: content: - text: "Next line" --- -case: persistent background animation survives same-background reassertion without animations +case: same-background reassertion resets persistent animation in: - - background: resourceId: "mainBackground" @@ -255,10 +254,6 @@ in: out: background: resourceId: "mainBackground" - animations: - resourceId: "bgFade" - playback: - continuity: "persistent" --- case: persistent background animation clears when background is modified in: @@ -1477,7 +1472,7 @@ out: textStyleId: "accentTitle" transformId: "titleTop" --- -case: visual text full update keeps previous transform, appearance, and layer +case: visual text full update resets omitted transform, appearance, and layer in: - - visual: items: @@ -1501,9 +1496,6 @@ out: text: content: "Chapter 2" textStyleId: "accentTitle" - transformId: "titleTop" - layer: 70 - opacity: 0.8 --- case: new visual text item requires content and textStyleId in: @@ -1525,7 +1517,7 @@ in: textStyleId: "title" throws: 'Visual item "title" cannot define both resourceId and text' --- -case: item transform overrides persist across later item updates +case: visual resource replacement resets previous transform overrides in: - - visual: items: @@ -1554,15 +1546,6 @@ out: resourceId: "effect-fog" transformId: "fullscreen" opacity: 0.45 - x: 100 - y: 120 - anchorX: 0 - anchorY: 0 - scaleX: 1.2 - scaleY: 1.3 - rotation: -8 - originX: 20 - originY: 40 --- case: preserve background without animations (no empty animations added) in: @@ -2008,7 +1991,7 @@ out: content: - text: "Second ADV line" --- -case: inline visual layout replacement preserves item state and merges inline transform +case: inline visual layout replacement resets omitted item state in: - - visual: items: @@ -2051,15 +2034,7 @@ out: content: "Chapter 2" textStyleId: "title" transform: - x: 100 y: 180 - anchorX: 0.5 - anchorY: 0.5 - rotation: 0 - scaleX: 1 - scaleY: 1 - layer: 70 - opacity: 0.8 --- case: inline visual layout cannot be combined with resourceId in: diff --git a/spec/system/selectors/selectPresentationChanges.spec.yaml b/spec/system/selectors/selectPresentationChanges.spec.yaml index fb6264b1..7bef6446 100644 --- a/spec/system/selectors/selectPresentationChanges.spec.yaml +++ b/spec/system/selectors/selectPresentationChanges.spec.yaml @@ -161,7 +161,7 @@ out: data: colorId: "night" --- -case: "Detect updated background resource without color change" +case: "Detect updated background resource and cleared color" in: - state: projectData: @@ -192,6 +192,10 @@ out: changeType: "update" data: resourceId: "bg-home" + color: + changeType: "delete" + data: + colorId: "night" --- case: "Detect updated background color without resource change" in: @@ -259,6 +263,10 @@ out: data: resourceId: "bg-school" transformId: "wide" + color: + changeType: "delete" + data: + colorId: "night" --- case: "Detect updated background inline transform as resource change" in: diff --git a/spec/system/selectors/selectPresentationState.spec.yaml b/spec/system/selectors/selectPresentationState.spec.yaml index 201db378..d35034fe 100644 --- a/spec/system/selectors/selectPresentationState.spec.yaml +++ b/spec/system/selectors/selectPresentationState.spec.yaml @@ -36,7 +36,7 @@ out: background: resourceId: abcd --- -case: background colorId persists across later lines +case: background resource replacement resets colorId across later lines in: - state: global: @@ -75,7 +75,6 @@ in: out: background: resourceId: xyz - colorId: night dialogue: mode: adv content: diff --git a/spec/system/selectors/selectSectionChanges.spec.yaml b/spec/system/selectors/selectSectionChanges.spec.yaml index ce3147ec..73fdff41 100644 --- a/spec/system/selectors/selectSectionChanges.spec.yaml +++ b/spec/system/selectors/selectSectionChanges.spec.yaml @@ -168,11 +168,15 @@ out: changeType: "update" data: resourceId: "bg2" + color: + changeType: "delete" + data: + colorId: "night" - id: "3" changes: background: color: - changeType: "update" + changeType: "add" data: colorId: "day" - id: "4" diff --git a/spec/visualInlineLayout.test.js b/spec/visualInlineLayout.test.js index 57f7ec35..2f47c175 100644 --- a/spec/visualInlineLayout.test.js +++ b/spec/visualInlineLayout.test.js @@ -170,7 +170,7 @@ describe("inline visual layouts", () => { }); }); - it("replaces inline layout content by id while preserving visual state", () => { + it("replaces inline layout content by id with only explicitly authored state", () => { const presentationState = constructPresentationState([ { visual: { @@ -226,22 +226,21 @@ describe("inline visual layouts", () => { }, ]); - expect(presentationState.visual.items[0]).toMatchObject({ + expect(presentationState.visual.items[0]).toEqual({ id: "title", layout: { - elements: [{ content: "Chapter 2" }], + elements: [ + { + id: "text", + type: "text", + content: "Chapter 2", + textStyleId: "title", + }, + ], }, transform: { - x: 100, y: 180, - anchorX: 0.5, - anchorY: 0.5, - scaleX: 1, - scaleY: 1, - rotation: 0, }, - layer: 70, - opacity: 0.8, }); }); diff --git a/src/schemas/presentationActions.yaml b/src/schemas/presentationActions.yaml index ef476876..07ef165a 100644 --- a/src/schemas/presentationActions.yaml +++ b/src/schemas/presentationActions.yaml @@ -238,7 +238,7 @@ definitions: visualText: type: object - description: Text-backed visual configuration. Placement, layer, alpha, blur, filters, and animations stay on the visual item. + description: Text-backed visual configuration. A value with both content and textStyleId defines a fresh visual subject and resets omitted item state; a partial value patches the active text subject. Placement, layer, alpha, blur, filters, and animations stay on the visual item. properties: content: $ref: "#/definitions/visualTextContent" @@ -283,7 +283,7 @@ definitions: visualInlineLayout: type: object - description: Inline RouteGraphics layout rendered as one visual item. The visual item owns placement, layer, appearance, and animations. + description: Inline RouteGraphics layout rendered as one visual item. A complete layout defines a fresh visual subject and resets omitted item state. The visual item owns placement, layer, appearance, and animations. properties: elements: type: array @@ -361,7 +361,7 @@ properties: properties: resourceId: type: string - description: ID of the background resource to display + description: ID of the background resource to display. Authoring this field creates a fresh background instance and resets omitted color, placement, appearance, playback, and animation state, even when the ID is unchanged. Omit resourceId for a continuation patch. animationName: type: string description: Optional spritesheet animation name. If omitted for a spritesheet resource, the first defined animation is used. @@ -373,7 +373,7 @@ properties: description: Optional video or spritesheet playback loop override colorId: type: string - description: Optional resources.colors ID for the solid color rendered behind the background resource. Persists until changed or cleared with cleanAll + description: Optional resources.colors ID for the solid color rendered behind the background resource. Persists through omitted background actions and subject-less patches, but resets when resourceId is authored without colorId. transformId: type: string description: Optional transform resource to position the background @@ -586,7 +586,7 @@ properties: $ref: "#/definitions/shaderFilters" sprites: type: array - description: Array of sprite parts that make up the character + description: Array of sprite parts that make up the character. Authoring a non-empty array creates a fresh character item and resets every omitted item field, even when the composition is unchanged. Omit sprites for a continuation patch. items: type: object properties: @@ -638,7 +638,7 @@ properties: description: Override the animation speed resourceId: type: string - description: ID of an image, video, spritesheet, particle, or layout resource + description: ID of an image, video, spritesheet, particle, or layout resource. Authoring this field creates a fresh visual item and resets every omitted item field, even when the ID is unchanged. Omit all complete visual subjects for a continuation patch. text: $ref: "#/definitions/visualText" layout: diff --git a/src/stores/constructPresentationState.js b/src/stores/constructPresentationState.js index 4247aa56..8786dd87 100644 --- a/src/stores/constructPresentationState.js +++ b/src/stores/constructPresentationState.js @@ -144,11 +144,22 @@ const hasVisualSubject = (item, previousItem) => { return !previousItem?.text; }; +const hasExplicitVisualSubject = (item) => + hasDefinedProperty(item, "resourceId") || + hasCompleteVisualText(item) || + hasCompleteVisualLayout(item); + const hasCharacterSubject = (item) => (item?.sprites && item.sprites.length > 0) || item?.transformId || item?.resourceId; +const hasExplicitCharacterSubject = (item) => + hasDefinedProperty(item, "resourceId") || + (hasDefinedProperty(item, "sprites") && + Array.isArray(item.sprites) && + item.sprites.length > 0); + const mergeVisualItemPatch = (previousItem, item) => { const mergedItem = { ...clonePresentationValue(previousItem), @@ -340,7 +351,11 @@ const processItemsWithAnimations = ( items, hasResourceFn, previousItems = [], - { hasPatchFn = () => false, mergeItemFn } = {}, + { + hasPatchFn = () => false, + hasSubjectFn = (item, previousItem) => hasResourceFn(item, previousItem), + mergeItemFn, + } = {}, ) => { if (!items || items.length === 0) { return { hasValidItems: false, processedItems: [] }; @@ -349,7 +364,7 @@ const processItemsWithAnimations = ( const processedItems = items .map((item, index) => { const previousItem = findPreviousItem(previousItems, item, index); - const hasResource = hasResourceFn(item, previousItem); + const hasSubject = hasSubjectFn(item, previousItem); const hasAppearance = hasItemAppearance(item); const hasTransform = hasItemTransform(item); const hasPatch = hasPatchFn(item); @@ -357,7 +372,7 @@ const processItemsWithAnimations = ( let processedItem = normalizeAlphaAlias(clonePresentationValue(item)); if ( - !hasResource && + !hasSubject && (hasAppearance || hasTransform || hasPatch) && previousItem ) { @@ -376,6 +391,7 @@ const processItemsWithAnimations = ( const nextHasResource = hasResourceFn(processedItem); if ( + !hasSubject && previousHasResource && nextHasResource && hasPersistentAnimationSelection(previousItem) @@ -388,6 +404,10 @@ const processItemsWithAnimations = ( } } + if (hasSubject) { + return processedItem; + } + return applyPersistentItemAppearance( applyPersistentItemTransform(processedItem, previousItem), previousItem, @@ -677,23 +697,37 @@ export const background = (state, presentation) => { } } - applyPersistentBackgroundTransform(nextBackground, previousBackground, { - hasAuthoredTransformId: hasTransformId, - }); + if (!hasResourceId) { + applyPersistentBackgroundTransform(nextBackground, previousBackground, { + hasAuthoredTransformId: hasTransformId, + }); + } - if (!hasColorId && previousBackground?.colorId) { + if (!hasResourceId && !hasColorId && previousBackground?.colorId) { nextBackground.colorId = previousBackground.colorId; } - if (!hasOpacity && hasDefinedProperty(previousBackground, "opacity")) { + if ( + !hasResourceId && + !hasOpacity && + hasDefinedProperty(previousBackground, "opacity") + ) { nextBackground.opacity = previousBackground.opacity; } - if (!hasBlur && hasDefinedProperty(previousBackground, "blur")) { + if ( + !hasResourceId && + !hasBlur && + hasDefinedProperty(previousBackground, "blur") + ) { nextBackground.blur = clonePresentationValue(previousBackground.blur); } - if (!hasFilters && hasDefinedProperty(previousBackground, "filters")) { + if ( + !hasResourceId && + !hasFilters && + hasDefinedProperty(previousBackground, "filters") + ) { nextBackground.filters = clonePresentationValue( previousBackground.filters, ); @@ -763,6 +797,7 @@ export const background = (state, presentation) => { } if ( + !hasResourceId && previousBackground?.resourceId === nextBackground.resourceId && previousBackground?.transformId === nextBackground.transformId && !hasOwnProperty(nextBackground, "animations") && @@ -1104,6 +1139,7 @@ export const visual = (state, presentation) => { previousItems, { hasPatchFn: hasVisualSubjectPatch, + hasSubjectFn: hasExplicitVisualSubject, mergeItemFn: mergeVisualItemPatch, }, ); @@ -1157,6 +1193,7 @@ export const character = (state, presentation) => { presentation.character.items, hasCharacterSubject, state.character?.items || [], + { hasSubjectFn: hasExplicitCharacterSubject }, ); if (hasValidItems) { diff --git a/vt/reference/background/subject-replacement-01.webp b/vt/reference/background/subject-replacement-01.webp new file mode 100644 index 00000000..8c3d94c4 --- /dev/null +++ b/vt/reference/background/subject-replacement-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ca7de9aa32ec33c31cc4f6c4d624670032a3fa96ec14e7b916303ae24dc8477 +size 1956 diff --git a/vt/reference/background/subject-replacement-02.webp b/vt/reference/background/subject-replacement-02.webp new file mode 100644 index 00000000..b55a5573 --- /dev/null +++ b/vt/reference/background/subject-replacement-02.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:587c6726d6441fe9d4eedd0ee0b256b700b15bc18e85c3da9248f3dae5c03bf1 +size 2378 diff --git a/vt/reference/background/subject-replacement-03.webp b/vt/reference/background/subject-replacement-03.webp new file mode 100644 index 00000000..5207f05d --- /dev/null +++ b/vt/reference/background/subject-replacement-03.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:492076e4ced59a7faf8324016f6d2fdbb0228e8ab083ca9daddc01886df56d18 +size 10860 diff --git a/vt/reference/character/subject-replacement-01.webp b/vt/reference/character/subject-replacement-01.webp new file mode 100644 index 00000000..366fbf5f --- /dev/null +++ b/vt/reference/character/subject-replacement-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:863bd2550a16415e61620eb30e80fbcd857e20ab11dcd4a87adda9e47e85472e +size 2372 diff --git a/vt/reference/character/subject-replacement-02.webp b/vt/reference/character/subject-replacement-02.webp new file mode 100644 index 00000000..d3357098 --- /dev/null +++ b/vt/reference/character/subject-replacement-02.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a2ac8623a320ed1a2ce43dd38a774f3d74b932fa26d1a43f9bccce683ed9847 +size 2706 diff --git a/vt/reference/character/subject-replacement-03.webp b/vt/reference/character/subject-replacement-03.webp new file mode 100644 index 00000000..92d2e04a --- /dev/null +++ b/vt/reference/character/subject-replacement-03.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2876a1651b2151433b646bccd8cbb26d1426c8e6cb0a7958e1204b002f0c4488 +size 5490 diff --git a/vt/reference/visual/subject-replacement-01.webp b/vt/reference/visual/subject-replacement-01.webp new file mode 100644 index 00000000..bf4fc9ce --- /dev/null +++ b/vt/reference/visual/subject-replacement-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f4bce9e32ae5269ae6a2ab9189cbe49bf3553f480b83d4573025fbbf69acddf +size 2376 diff --git a/vt/reference/visual/subject-replacement-02.webp b/vt/reference/visual/subject-replacement-02.webp new file mode 100644 index 00000000..b3c95534 --- /dev/null +++ b/vt/reference/visual/subject-replacement-02.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:043884c17441d6763d95bba660988a22de62b04ff3b3b82a60e3ec5716b65bea +size 2708 diff --git a/vt/reference/visual/subject-replacement-03.webp b/vt/reference/visual/subject-replacement-03.webp new file mode 100644 index 00000000..9da1c85d --- /dev/null +++ b/vt/reference/visual/subject-replacement-03.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38453297a855f36a4727ce98449f253d1eaebfc901318523fca026591b6f9193 +size 5496 diff --git a/vt/specs/background/subject-replacement.yaml b/vt/specs/background/subject-replacement.yaml new file mode 100644 index 00000000..38f171f7 --- /dev/null +++ b/vt/specs/background/subject-replacement.yaml @@ -0,0 +1,63 @@ +--- +title: Background Subject Replacement +description: | + A subject-less background action should patch the active instance, while + explicitly reauthoring the same resourceId should create a fresh instance. + + - the first screenshot should show the forest inset, faded, and blurred + - the second screenshot should increase opacity while preserving placement and blur + - the third screenshot should show the same forest at default placement and appearance +skipInitialScreenshot: true +steps: + - action: screenshot + - action: customEvent + name: vt:nextLine + - action: screenshot + - action: customEvent + name: vt:nextLine + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#FFFFFF" +resources: + images: + forest: + fileId: dmni32 + width: 1920 + height: 1080 +story: + initialSceneId: backgroundSubjectScene + scenes: + backgroundSubjectScene: + name: Background Subject Scene + initialSectionId: main + sections: + main: + lines: + - id: styledBackground + actions: + background: + resourceId: forest + x: 280 + y: 180 + anchorX: 0 + anchorY: 0 + scaleX: 0.7 + scaleY: 0.7 + opacity: 0.5 + blur: + x: 6 + y: 9 + quality: 3 + kernelSize: 9 + repeatEdgePixels: true + - id: patchedBackground + actions: + background: + opacity: 0.8 + - id: replacedBackground + actions: + background: + resourceId: forest diff --git a/vt/specs/character/subject-replacement.yaml b/vt/specs/character/subject-replacement.yaml new file mode 100644 index 00000000..e4b6536e --- /dev/null +++ b/vt/specs/character/subject-replacement.yaml @@ -0,0 +1,85 @@ +--- +title: Character Subject Replacement +description: | + A subject-less character item should patch the active instance, while + explicitly reauthoring the same sprite composition should create a fresh + instance. + + - the first screenshot should show the character left of center, faded, and blurred + - the second screenshot should increase opacity while preserving placement and blur + - the third screenshot should show the same sprite centered with default appearance +skipInitialScreenshot: true +steps: + - action: screenshot + - action: customEvent + name: vt:nextLine + - action: screenshot + - action: customEvent + name: vt:nextLine + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#FFFFFF" +resources: + colors: + stageWhite: + hex: "#FFFFFF" + images: + character-a: + fileId: char_sprite_1 + width: 553 + height: 865 + transforms: + character-center: + x: 960 + y: 1080 + anchorX: 0.5 + anchorY: 1 + rotation: 0 + scaleX: 1 + scaleY: 1 +story: + initialSceneId: characterSubjectScene + scenes: + characterSubjectScene: + name: Character Subject Scene + initialSectionId: main + sections: + main: + lines: + - id: styledCharacter + actions: + background: + colorId: stageWhite + character: + items: + - id: lead + transformId: character-center + x: 520 + opacity: 0.5 + blur: + x: 6 + y: 9 + quality: 3 + kernelSize: 9 + repeatEdgePixels: true + sprites: + - id: body + resourceId: character-a + - id: patchedCharacter + actions: + character: + items: + - id: lead + opacity: 0.8 + - id: replacedCharacter + actions: + character: + items: + - id: lead + transformId: character-center + sprites: + - id: body + resourceId: character-a diff --git a/vt/specs/visual/subject-replacement.yaml b/vt/specs/visual/subject-replacement.yaml new file mode 100644 index 00000000..c279da19 --- /dev/null +++ b/vt/specs/visual/subject-replacement.yaml @@ -0,0 +1,81 @@ +--- +title: Visual Subject Replacement +description: | + A subject-less visual item should patch the active instance, while explicitly + reauthoring the same resourceId should create a fresh instance. + + - the first screenshot should show the visual left of center, faded, and blurred + - the second screenshot should increase opacity while preserving placement and blur + - the third screenshot should show the same resource centered with default appearance +skipInitialScreenshot: true +steps: + - action: screenshot + - action: customEvent + name: vt:nextLine + - action: screenshot + - action: customEvent + name: vt:nextLine + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#FFFFFF" +resources: + colors: + stageWhite: + hex: "#FFFFFF" + images: + visual-sprite: + fileId: char_sprite_2 + width: 553 + height: 865 + transforms: + visual-center: + x: 960 + y: 1080 + anchorX: 0.5 + anchorY: 1 + rotation: 0 + scaleX: 1 + scaleY: 1 +story: + initialSceneId: visualSubjectScene + scenes: + visualSubjectScene: + name: Visual Subject Scene + initialSectionId: main + sections: + main: + lines: + - id: styledVisual + actions: + background: + colorId: stageWhite + visual: + items: + - id: glow + resourceId: visual-sprite + transformId: visual-center + x: 520 + layer: 90 + opacity: 0.5 + blur: + x: 6 + y: 9 + quality: 3 + kernelSize: 9 + repeatEdgePixels: true + - id: patchedVisual + actions: + visual: + items: + - id: glow + opacity: 0.8 + - id: replacedVisual + actions: + visual: + items: + - id: glow + resourceId: visual-sprite + transformId: visual-center