diff --git a/package.json b/package.json index 55675a03..f02e43e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "0.7.5", + "version": "0.7.6", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git", diff --git a/spec/system/renderState/addCharacters.spec.yaml b/spec/system/renderState/addCharacters.spec.yaml index 2b91de22..6d56dc78 100644 --- a/spec/system/renderState/addCharacters.spec.yaml +++ b/spec/system/renderState/addCharacters.spec.yaml @@ -51,7 +51,7 @@ out: y: 0 children: - type: "container" - id: "character-container-char1-0-body-face" + id: "character-container-char1" x: 100 y: 200 anchorX: 0.5 @@ -61,14 +61,14 @@ out: scaleY: 1 children: - type: "sprite" - id: "character-container-char1-0-body-face-body" + id: "character-container-char1-body" src: "body.png" width: 200 height: 200 x: 0 y: 0 - type: "sprite" - id: "character-container-char1-0-body-face-face" + id: "character-container-char1-face" src: "face.png" width: 200 height: 200 @@ -128,7 +128,7 @@ out: y: 0 children: - type: "container" - id: "character-container-char1-0-body" + id: "character-container-char1" x: 50 y: 100 anchorX: 0 @@ -138,7 +138,7 @@ out: scaleY: 1 children: - type: "sprite" - id: "character-container-char1-0-body-body" + id: "character-container-char1-body" src: "body.png" width: 200 height: 200 @@ -147,7 +147,7 @@ out: animations: - id: "character-animation-in" type: "transition" - targetId: "character-container-char1-0-body" + targetId: "character-container-char1" next: tween: alpha: @@ -199,7 +199,7 @@ out: animations: - id: "character-animation-out" type: "transition" - targetId: "character-container-char1-0-body" + targetId: "character-container-char1" prev: tween: alpha: @@ -260,7 +260,7 @@ out: y: 0 children: - type: "container" - id: "character-container-char1-0-body" + id: "character-container-char1" x: 0 y: 0 anchorX: 0.5 @@ -270,7 +270,7 @@ out: scaleY: 1 children: - type: "sprite" - id: "character-container-char1-0-body-body" + id: "character-container-char1-body" src: "body.png" width: 200 height: 200 @@ -279,7 +279,7 @@ out: animations: - id: "character-animation-in" type: "transition" - targetId: "character-container-char1-0-body" + targetId: "character-container-char1" next: tween: alpha: @@ -351,7 +351,7 @@ out: y: 0 children: - type: "container" - id: "character-container-char1-0-body" + id: "character-container-char1" x: 0 y: 0 anchorX: 0.5 @@ -361,7 +361,7 @@ out: scaleY: 1 children: - type: "sprite" - id: "character-container-char1-0-body-body" + id: "character-container-char1-body" src: "body.png" width: 200 height: 200 @@ -370,7 +370,7 @@ out: animations: - id: "character-animation-update" type: "update" - targetId: "character-container-char1-0-body" + targetId: "character-container-char1" tween: scaleX: keyframes: @@ -383,6 +383,120 @@ out: value: 2.2 easing: easeOutQuad --- +case: keep stable character target when swapping a sprite part on update +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + - presentationState: + character: + items: + - id: "char1" + transformId: "transform1" + sprites: + - id: "body" + resourceId: "body" + - id: "face" + resourceId: "face-smile" + animations: + resourceId: "scaleUp" + previousPresentationState: + character: + items: + - id: "char1" + sprites: + - id: "body" + resourceId: "body" + - id: "face" + resourceId: "face-neutral" + resources: + transforms: + transform1: + x: 240 + y: 320 + anchorX: 0.5 + anchorY: 1 + rotation: 0 + scaleX: 1 + scaleY: 1 + images: + body: + fileId: "body.png" + width: 200 + height: 300 + face-neutral: + fileId: "face-neutral.png" + width: 200 + height: 300 + face-smile: + fileId: "face-smile.png" + width: 200 + height: 300 + animations: + scaleUp: + type: update + tween: + scaleX: + keyframes: + - duration: 600 + value: 1.1 + easing: easeOutQuad + scaleY: + keyframes: + - duration: 600 + value: 1.1 + easing: easeOutQuad +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - type: "container" + id: "character-container-char1" + x: 240 + y: 320 + anchorX: 0.5 + anchorY: 1 + rotation: 0 + scaleX: 1 + scaleY: 1 + children: + - type: "sprite" + id: "character-container-char1-body" + src: "body.png" + width: 200 + height: 300 + x: 0 + y: 0 + - type: "sprite" + id: "character-container-char1-face" + src: "face-smile.png" + width: 200 + height: 300 + x: 0 + y: 0 + animations: + - id: "character-animation-update" + type: "update" + targetId: "character-container-char1" + tween: + scaleX: + keyframes: + - duration: 600 + value: 1.1 + easing: easeOutQuad + scaleY: + keyframes: + - duration: 600 + value: 1.1 + easing: easeOutQuad +--- case: multiple characters with different sprites in: - elements: @@ -446,7 +560,7 @@ out: y: 0 children: - type: "container" - id: "character-container-char1-0-body-face" + id: "character-container-char1" x: 100 y: 100 anchorX: 0.5 @@ -456,21 +570,21 @@ out: scaleY: 1 children: - type: "sprite" - id: "character-container-char1-0-body-face-body" + id: "character-container-char1-body" src: "char1_body.png" width: 200 height: 200 x: 0 y: 0 - type: "sprite" - id: "character-container-char1-0-body-face-face" + id: "character-container-char1-face" src: "char1_face.png" width: 200 height: 200 x: 0 y: 0 - type: "container" - id: "character-container-char2-1-body2" + id: "character-container-char2" x: 200 y: 100 anchorX: 0.5 @@ -480,7 +594,7 @@ out: scaleY: 1 children: - type: "sprite" - id: "character-container-char2-1-body2-body2" + id: "character-container-char2-body2" src: "char2_body.png" width: 200 height: 200 @@ -488,6 +602,94 @@ out: y: 0 animations: [] --- +case: keep legacy distinct ids when duplicate character ids are authored +in: + - elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: [] + animations: [] + - presentationState: + character: + items: + - id: "char1" + transformId: "transform1" + sprites: + - id: "body" + resourceId: "body" + - id: "char1" + transformId: "transform2" + sprites: + - id: "body" + resourceId: "body" + resources: + transforms: + transform1: + x: 100 + y: 100 + anchorX: 0.5 + anchorY: 1 + rotation: 0 + scaleX: 1 + scaleY: 1 + transform2: + x: 300 + y: 100 + anchorX: 0.5 + anchorY: 1 + rotation: 0 + scaleX: 1 + scaleY: 1 + images: + body: + fileId: "body.png" + width: 200 + height: 300 +out: + elements: + - id: "story" + type: "container" + x: 0 + y: 0 + children: + - type: "container" + id: "character-container-char1-0-body" + x: 100 + y: 100 + anchorX: 0.5 + anchorY: 1 + rotation: 0 + scaleX: 1 + scaleY: 1 + children: + - type: "sprite" + id: "character-container-char1-0-body-body" + src: "body.png" + width: 200 + height: 300 + x: 0 + y: 0 + - type: "container" + id: "character-container-char1-1-body" + x: 300 + y: 100 + anchorX: 0.5 + anchorY: 1 + rotation: 0 + scaleX: 1 + scaleY: 1 + children: + - type: "sprite" + id: "character-container-char1-1-body-body" + src: "body.png" + width: 200 + height: 300 + x: 0 + y: 0 + animations: [] +--- case: no presentationState.character - return unchanged state in: - elements: @@ -715,7 +917,7 @@ out: y: 0 children: - type: "container" - id: "character-container-char1-0-body" + id: "character-container-char1" x: 500 y: 200 anchorX: 0.5 @@ -725,7 +927,7 @@ out: scaleY: 1 children: - type: "sprite" - id: "character-container-char1-0-body-body" + id: "character-container-char1-body" src: "body.png" width: 200 height: 200 @@ -774,7 +976,7 @@ out: y: 0 children: - type: "container" - id: "character-container-char1-0-body" + id: "character-container-char1" x: 100 y: 600 anchorX: 0.5 @@ -784,7 +986,7 @@ out: scaleY: 1 children: - type: "sprite" - id: "character-container-char1-0-body-body" + id: "character-container-char1-body" src: "body.png" width: 200 height: 200 @@ -834,7 +1036,7 @@ out: y: 0 children: - type: "container" - id: "character-container-char1-0-body" + id: "character-container-char1" x: 300 y: 400 anchorX: 0.5 @@ -844,7 +1046,7 @@ out: scaleY: 1 children: - type: "sprite" - id: "character-container-char1-0-body-body" + id: "character-container-char1-body" src: "body.png" width: 200 height: 200 @@ -893,7 +1095,7 @@ out: y: 0 children: - type: "container" - id: "character-container-char1-0-body" + id: "character-container-char1" x: 0 y: 200 anchorX: 0.5 @@ -903,7 +1105,7 @@ out: scaleY: 1 children: - type: "sprite" - id: "character-container-char1-0-body-body" + id: "character-container-char1-body" src: "body.png" width: 200 height: 200 diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index edd43ba2..2d59bc01 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -341,7 +341,33 @@ const ensureDialogueContentItems = (content, path) => { return content; }; -const getCharacterContainerId = (item, index = 0) => { +const getDuplicateItemIds = (items = []) => { + const counts = new Map(); + + for (const item of items) { + if (!item?.id) { + continue; + } + + counts.set(item.id, (counts.get(item.id) ?? 0) + 1); + } + + return new Set( + Array.from(counts.entries()) + .filter(([, count]) => count > 1) + .map(([id]) => id), + ); +}; + +const getCharacterContainerId = ( + item, + index = 0, + duplicateCharacterIds = new Set(), +) => { + if (!duplicateCharacterIds.has(item?.id)) { + return `character-container-${item.id}`; + } + const spritePartIds = item?.sprites?.map(({ resourceId }) => resourceId) || []; @@ -1419,6 +1445,8 @@ export const addCharacters = ( const items = presentationState.character.items || []; const previousItems = previousPresentationState?.character?.items || []; + const duplicateCharacterIds = getDuplicateItemIds(items); + const previousDuplicateCharacterIds = getDuplicateItemIds(previousItems); for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -1434,7 +1462,11 @@ export const addCharacters = ( const currentHasSprites = sprites && sprites.length > 0; const previousContainerId = previousItemIndex >= 0 - ? getCharacterContainerId(previousItem, previousItemIndex) + ? getCharacterContainerId( + previousItem, + previousItemIndex, + previousDuplicateCharacterIds, + ) : undefined; if ( @@ -1471,7 +1503,11 @@ export const addCharacters = ( continue; } - const containerId = getCharacterContainerId(item, i); + const containerId = getCharacterContainerId( + item, + i, + duplicateCharacterIds, + ); const transform = resources.transforms[transformId]; if (!transform) { console.warn("Transform not found:", transformId); diff --git a/vt/reference/background/transition-tween-out-02.webp b/vt/reference/background/transition-tween-out-02.webp index 5207f05d..168d87b0 100644 --- a/vt/reference/background/transition-tween-out-02.webp +++ b/vt/reference/background/transition-tween-out-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:492076e4ced59a7faf8324016f6d2fdbb0228e8ab083ca9daddc01886df56d18 -size 10860 +oid sha256:0ac3868abc283729fbdba4a6d760dfb680459f97a2d3ef15c173b13ba329775e +size 6946 diff --git a/vt/reference/background/update-enter-fallback-02.webp b/vt/reference/background/update-enter-fallback-02.webp index 5207f05d..5c3a410c 100644 --- a/vt/reference/background/update-enter-fallback-02.webp +++ b/vt/reference/background/update-enter-fallback-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:492076e4ced59a7faf8324016f6d2fdbb0228e8ab083ca9daddc01886df56d18 -size 10860 +oid sha256:057c496fd951296b1b477e9172f12a6a7f4d32b32ba088a33b2648358a121c6e +size 3948 diff --git a/vt/reference/character/sprite-parts-update-01.webp b/vt/reference/character/sprite-parts-update-01.webp new file mode 100644 index 00000000..d3f3d395 --- /dev/null +++ b/vt/reference/character/sprite-parts-update-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7d05e449c914e657f1d4f8082f961a9a456c152d541bd9a4d304442b9a277dc +size 2576 diff --git a/vt/reference/character/sprite-parts-update-02.webp b/vt/reference/character/sprite-parts-update-02.webp new file mode 100644 index 00000000..d3f3d395 --- /dev/null +++ b/vt/reference/character/sprite-parts-update-02.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7d05e449c914e657f1d4f8082f961a9a456c152d541bd9a4d304442b9a277dc +size 2576 diff --git a/vt/reference/character/sprite-parts-update-03.webp b/vt/reference/character/sprite-parts-update-03.webp new file mode 100644 index 00000000..e15b735d --- /dev/null +++ b/vt/reference/character/sprite-parts-update-03.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee2544f8f11bff90f3871ab467dd7489bd2b2304947d557b10ea8a12056fe8e0 +size 2678 diff --git a/vt/reference/character/sprite-parts-update-04.webp b/vt/reference/character/sprite-parts-update-04.webp new file mode 100644 index 00000000..7208a784 --- /dev/null +++ b/vt/reference/character/sprite-parts-update-04.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2f0e1499b874f6a0d99fca35f03663095d5e83eaf1a07548e12efb3deddd384 +size 2758 diff --git a/vt/specs/character/sprite-parts-update.yaml b/vt/specs/character/sprite-parts-update.yaml new file mode 100644 index 00000000..0b502b6f --- /dev/null +++ b/vt/specs/character/sprite-parts-update.yaml @@ -0,0 +1,116 @@ +--- +title: Character Sprite Parts Update +description: | + Same-character sprite-part swaps should preserve the composed character target + so update animations still run on the container instead of degrading into a + replace path. +specs: + - the initial screenshot should show one composed grayscale character with a neutral face + - the post-advance screenshot should swap only the face part while keeping the same character target + - the halfway screenshot should show the smiling face and an in-progress scale update + - the final screenshot should settle on the smiling face at the final scale +steps: + - action: customEvent + name: vt:nextLine + - action: screenshot + - action: customEvent + name: snapShotKeyFrame + detail: + deltaMS: 300 + - action: screenshot + - action: customEvent + name: snapShotKeyFrame + detail: + deltaMS: 300 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 + backgroundColor: "#000000" +resources: + controls: + base1: + elements: + - id: click-area + type: rect + width: 1920 + height: 1080 + alpha: 0.001 + click: + payload: + actions: + nextLine: {} + colorId: overlayColor + images: + parts-body: + fileId: character_parts_body + width: 620 + height: 960 + parts-face-neutral: + fileId: character_parts_face_neutral + width: 620 + height: 960 + parts-face-smile: + fileId: character_parts_face_smile + width: 620 + height: 960 + transforms: + center-stage: + x: 960 + y: 1080 + anchorX: 0.5 + anchorY: 1 + animations: + part-swap-scale: + name: Character Part Swap Scale + type: update + tween: + scaleX: + keyframes: + - duration: 600 + value: 1.2 + easing: linear + scaleY: + keyframes: + - duration: 600 + value: 1.2 + easing: linear + colors: + overlayColor: + hex: "#000000" +story: + initialSceneId: characterPartsScene + scenes: + characterPartsScene: + name: Character Parts Scene + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + control: + resourceId: base1 + character: + items: + - id: grayscale-hero + transformId: center-stage + sprites: + - id: body + resourceId: parts-body + - id: face + resourceId: parts-face-neutral + - id: line2 + actions: + character: + items: + - id: grayscale-hero + transformId: center-stage + sprites: + - id: body + resourceId: parts-body + - id: face + resourceId: parts-face-smile + animations: + resourceId: part-swap-scale diff --git a/vt/static/main.js b/vt/static/main.js index aaeeab74..85b2ee03 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -97,6 +97,18 @@ const init = async () => { url: "/public/characters/sprite-2-2.png", type: "image/png", }, + character_parts_body: { + url: "/public/characters/parts-body-base.png", + type: "image/png", + }, + character_parts_face_neutral: { + url: "/public/characters/parts-face-neutral.png", + type: "image/png", + }, + character_parts_face_smile: { + url: "/public/characters/parts-face-smile.png", + type: "image/png", + }, "94lkj289": { url: "/public/logo1.png", type: "image/png", diff --git a/vt/static/public/characters/parts-body-base.png b/vt/static/public/characters/parts-body-base.png new file mode 100644 index 00000000..cda29249 --- /dev/null +++ b/vt/static/public/characters/parts-body-base.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4770e6017445bae603c784d31fd1688812cf9899d07b594827bdb5427f71e3ad +size 20901 diff --git a/vt/static/public/characters/parts-face-neutral.png b/vt/static/public/characters/parts-face-neutral.png new file mode 100644 index 00000000..034e9048 --- /dev/null +++ b/vt/static/public/characters/parts-face-neutral.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39d6508ea4271dcbb9f3adc36b66bf748cd4898007731006f3ef887ca0e6362f +size 6263 diff --git a/vt/static/public/characters/parts-face-smile.png b/vt/static/public/characters/parts-face-smile.png new file mode 100644 index 00000000..e64ef9f8 --- /dev/null +++ b/vt/static/public/characters/parts-face-smile.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8ee4ca5852607205ee3e3c4da027a6290b9c5bb74e0f0d5453a4fad57551cc4 +size 9035