From d11194f9f3083f4d17fb29d990aef9a3b2f41337 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Thu, 14 May 2026 17:35:59 +0200 Subject: [PATCH 01/12] Parser: improved LOOK and EXAMINE commands, incl. interaction with "hidden" items --- public/assets/tv_rc.png | Bin 0 -> 79210 bytes public/scenes/test_room.json | 188 +++++++++++++++++----- public/sprites/tv_rc.json | 9 ++ public/text/objects/Static_305.json | 8 + public/text/objects/left_dofa_pillow.json | 8 + public/text/objects/left_sofa_pillow.json | 8 + public/text/objects/rc_tv.json | 8 + public/text/objects/sofa_pillow1.json | 7 + public/text/objects/sofa_pillow1_1.json | 7 + public/text/objects/sofa_pillow2.json | 8 + public/text/objects/sofa_pillow_left.json | 8 + public/text/objects/tc_tv.json | 8 + public/text/objects/tv_rc.json | 6 + public/text/scenes/test_room.json | 5 +- public/text/system/parser.json | 1 + src/core/TextAssetManager.ts | 1 + src/mechanics/Parser.ts | 86 ++++++++-- src/systems/GameSemanticAPI.ts | 16 +- tests/fixtures/parserFactory.ts | 16 +- tests/fixtures/textAssetFactory.ts | 1 + tests/game/navigation-and-spatial.test.ts | 2 +- tests/integration/parser-game.test.ts | 180 ++++++++++++++++++++- 22 files changed, 509 insertions(+), 72 deletions(-) create mode 100644 public/assets/tv_rc.png create mode 100644 public/sprites/tv_rc.json create mode 100644 public/text/objects/Static_305.json create mode 100644 public/text/objects/left_dofa_pillow.json create mode 100644 public/text/objects/left_sofa_pillow.json create mode 100644 public/text/objects/rc_tv.json create mode 100644 public/text/objects/sofa_pillow1.json create mode 100644 public/text/objects/sofa_pillow1_1.json create mode 100644 public/text/objects/sofa_pillow2.json create mode 100644 public/text/objects/sofa_pillow_left.json create mode 100644 public/text/objects/tc_tv.json create mode 100644 public/text/objects/tv_rc.json diff --git a/public/assets/tv_rc.png b/public/assets/tv_rc.png new file mode 100644 index 0000000000000000000000000000000000000000..902fc568bc4fb98aff363a9dc4a122f23f395830 GIT binary patch literal 79210 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 = { examine_relation_prompt: 'Examine what area?', relation_empty: 'You see nothing {relation} the {target}.', relation_contents: '{Relation} the {target} you see: {items}.', + relation_discovered_contents: '{Relation} the {target} you discover: {items}.', relation_location: 'It is {relation} the {target}.', relation_not_supported: "You can't determine what is {relation} the {target} from here.", take_prompt: 'Take what?', diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 7847080..3335948 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -22,6 +22,7 @@ import { buildSceneTextLayerSnapshot, getInactiveSubsceneAncestors, getSceneTextLayerAccessState, + getSceneTextRelationAccessStates, getSceneTextRelationDescendants, } from '../scene/SceneTextLayer'; import type { @@ -2633,8 +2634,14 @@ export class Parser { if (outcome.status !== 'ok' || !outcome.message) return outcome; const extraMessages: string[] = []; - const contentsMessage = this.getEntityInventoryContentsText(entity); - if (contentsMessage) extraMessages.push(contentsMessage); + if ( + outcome.code === 'entity_description' || + outcome.code === 'entity_generic_description' || + outcome.code === 'entity_details' || + outcome.code === 'entity_description_fallback' + ) { + extraMessages.push(...this.getEntitySpatialContentsText(entity, { revealLookable: true })); + } const scene = this.game.sceneManager.currentScene; if (scene && entity?.name && !this.getEntityParserNoteNeedsCheck(scene, entity.name)) { @@ -2649,23 +2656,68 @@ export class Parser { }; } - private getEntityInventoryContentsText(entity: SceneObject): string | null { - if (!entity?.name || !this.hasEntityInventoryRelation(entity, 'in')) return null; - const relationOutcome = this.game.describeSpatialRelation(entity.name, 'in'); - if (relationOutcome.status !== 'ok' || relationOutcome.code !== 'relation_contents') { - return null; - } - return relationOutcome.message?.trim() || null; + private getEntitySpatialContentsText( + entity: SceneObject, + options: { revealLookable?: boolean } = {} + ): string[] { + const scene = this.game.sceneManager.currentScene; + if (!scene || !entity?.name) return []; + + let textLayer = buildSceneTextLayerSnapshot(scene, this.game); + const anchorTitle = + textLayer.entryById.get(entity.name)?.title?.trim() || + this.getPlayerFacingObjectTitle(entity)?.trim() || + null; + if (!anchorTitle) return []; + + return (['in', 'on', 'under', 'behind'] as const) + .map((relation) => { + let discovered = false; + if (options.revealLookable) { + const revealableLookables = getSceneTextRelationAccessStates( + scene, + this.game, + entity.name, + relation, + { includeHidden: true } + ).filter((accessState) => accessState.hiddenReason === 'lookable'); + + if (revealableLookables.length) { + revealableLookables.forEach((accessState) => + scene.revealHiddenEntity(accessState.object) + ); + textLayer = buildSceneTextLayerSnapshot(scene, this.game); + discovered = true; + } + } + + const childTitles = getSceneTextRelationDescendants(textLayer, entity.name, relation) + .map((entry) => entry.title) + .filter((title): title is string => !!title); + + if (!childTitles.length) return null; + + return this.game.text( + discovered ? 'parser.relation_discovered_contents' : 'parser.relation_contents', + { + Relation: this.capitalize(this.getRelationDisplayText(relation)), + relation: this.getRelationDisplayText(relation), + target: anchorTitle, + items: this.formatTitleList(childTitles), + } + ); + }) + .filter((message): message is string => !!message?.trim()); } - private hasEntityInventoryRelation(entity: SceneObject, relation: ParserRelationType): boolean { - return ( - entity.components?.some( - (component: any) => - component?.type === 'Inventory' && - (!component.relation || component.relation === relation) - ) || false - ); + private capitalize(value: string): string { + return value ? value.charAt(0).toUpperCase() + value.slice(1) : value; + } + + private formatTitleList(items: string[]): string { + if (items.length <= 1) return items[0] || ''; + if (items.length === 2) return `${items[0]} and ${items[1]}`; + return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`; } private getEntityParserNoteNeedsCheck(scene: any, entityId: string): boolean { diff --git a/src/systems/GameSemanticAPI.ts b/src/systems/GameSemanticAPI.ts index 48044de..06dfc00 100644 --- a/src/systems/GameSemanticAPI.ts +++ b/src/systems/GameSemanticAPI.ts @@ -924,6 +924,7 @@ export class GameSemanticAPI { includeHidden: true, }).filter((accessState) => accessState.hiddenReason === 'lookable') : []; + const discoveredLookables = revealableLookables.length > 0; if (effectiveRelation && revealableLookables.length) { revealableLookables.forEach((accessState) => scene.revealHiddenEntity(accessState.object)); const revealedTextLayer = buildSceneTextLayerSnapshot(scene, this.game); @@ -951,12 +952,15 @@ export class GameSemanticAPI { return { status: 'ok', code: 'relation_contents', - message: this.game.text('parser.relation_contents', { - Relation: this.capitalize(this.getRelationDisplayText(relation)), - relation: this.getRelationDisplayText(relation), - target: anchorTitle, - items: this.formatTitleList(childTitles), - }), + message: this.game.text( + discoveredLookables ? 'parser.relation_discovered_contents' : 'parser.relation_contents', + { + Relation: this.capitalize(this.getRelationDisplayText(relation)), + relation: this.getRelationDisplayText(relation), + target: anchorTitle, + items: this.formatTitleList(childTitles), + } + ), data: { relation, anchorNodeId, diff --git a/tests/fixtures/parserFactory.ts b/tests/fixtures/parserFactory.ts index 0390ae6..846cf38 100644 --- a/tests/fixtures/parserFactory.ts +++ b/tests/fixtures/parserFactory.ts @@ -961,6 +961,7 @@ export function createParserFixture(): ParserFixture { effectiveRelation, { includeHidden: true } ).filter((accessState) => accessState.hiddenReason === 'lookable'); + const discoveredLookables = revealableLookables.length > 0; if (revealableLookables.length) { revealableLookables.forEach((accessState) => fixture.scene.revealHiddenEntity(accessState.object) @@ -982,12 +983,15 @@ export function createParserFixture(): ParserFixture { } return okOutcome( 'relation_contents', - fixture.game.text('parser.relation_contents', { - Relation: relation.charAt(0).toUpperCase() + relation.slice(1), - relation, - target: anchorTitle, - items: formatTitleList(childTitles), - }) + fixture.game.text( + discoveredLookables ? 'parser.relation_discovered_contents' : 'parser.relation_contents', + { + Relation: relation.charAt(0).toUpperCase() + relation.slice(1), + relation, + target: anchorTitle, + items: formatTitleList(childTitles), + } + ) ); }; diff --git a/tests/fixtures/textAssetFactory.ts b/tests/fixtures/textAssetFactory.ts index 0afb844..97fa977 100644 --- a/tests/fixtures/textAssetFactory.ts +++ b/tests/fixtures/textAssetFactory.ts @@ -84,6 +84,7 @@ const DEFAULT_SERVICE_TEXT: Record = { 'parser.parse_unknown': "I don't understand.", 'parser.relation_empty': 'You see nothing {relation} the {target}.', 'parser.relation_contents': '{Relation} the {target} you see: {items}.', + 'parser.relation_discovered_contents': '{Relation} the {target} you discover: {items}.', }; const DEFAULT_PARSER_LEXICON: ParserLexiconAsset = { diff --git a/tests/game/navigation-and-spatial.test.ts b/tests/game/navigation-and-spatial.test.ts index 820456d..8bc12fa 100644 --- a/tests/game/navigation-and-spatial.test.ts +++ b/tests/game/navigation-and-spatial.test.ts @@ -198,7 +198,7 @@ describe('Game navigation and spatial API', () => { expect(populated.status).toBe('ok'); expect(populated.message).toBe( - fixture.game.text('parser.relation_contents', { + fixture.game.text('parser.relation_discovered_contents', { Relation: 'In', target: 'Cabinet', items: 'Book A and Book B', diff --git a/tests/integration/parser-game.test.ts b/tests/integration/parser-game.test.ts index 152dcbc..6872eba 100644 --- a/tests/integration/parser-game.test.ts +++ b/tests/integration/parser-game.test.ts @@ -49,6 +49,171 @@ describe('Parser + game integration smoke', () => { ); }); + it('includes visible spatial contents for all relations on direct LOOK target', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('Chair', { + title: 'Chair', + description: 'A wooden chair.', + }); + fixture.addEntity('note', { + title: 'Piece of paper', + description: 'A folded note.', + spatial: { parentNodeId: 'Chair', relation: 'under' }, + }); + fixture.addEntity('hat', { + title: 'Hat', + description: 'A hat.', + spatial: { parentNodeId: 'Chair', relation: 'on' }, + }); + fixture.addEntity('remote', { + title: 'Remote control', + description: 'A remote.', + spatial: { parentNodeId: 'Chair', relation: 'behind' }, + }); + fixture.addEntity('coin', { + title: 'Coin', + description: 'A coin.', + spatial: { parentNodeId: 'Chair', relation: 'in' }, + }); + + const result = await fixture.run('look chair'); + + expect(result.messages.at(-1)).toBe( + [ + 'A wooden chair.', + fixture.game.text('parser.relation_contents', { + Relation: 'In', + target: 'Chair', + items: 'Coin', + }), + fixture.game.text('parser.relation_contents', { + Relation: 'On', + target: 'Chair', + items: 'Hat', + }), + fixture.game.text('parser.relation_contents', { + Relation: 'Under', + target: 'Chair', + items: 'Piece of paper', + }), + fixture.game.text('parser.relation_contents', { + Relation: 'Behind', + target: 'Chair', + items: 'Remote control', + }), + ].join('\n') + ); + }); + + it('discovers hidden lookable spatial contents on direct LOOK target only once', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('Chair', { + title: 'Chair', + description: 'A wooden chair.', + }); + const key = fixture.addEntity('key', { + title: 'Key', + description: 'A hidden key.', + spatial: { parentNodeId: 'Chair', relation: 'under' }, + }); + key.hidden = 'lookable'; + + const firstLook = await fixture.run('look chair'); + expect(firstLook.messages.at(-1)).toBe( + [ + 'A wooden chair.', + fixture.game.text('parser.relation_discovered_contents', { + Relation: 'Under', + target: 'Chair', + items: 'Key', + }), + ].join('\n') + ); + expect(fixture.scene.isHiddenEntityRevealed(key)).toBe(true); + + const secondLook = await fixture.run('look chair'); + expect(secondLook.messages.at(-1)).toBe( + [ + 'A wooden chair.', + fixture.game.text('parser.relation_contents', { + Relation: 'Under', + target: 'Chair', + items: 'Key', + }), + ].join('\n') + ); + }); + + it('includes visible spatial contents after EXAMINE description', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('desk', { + title: 'Desk', + description: 'A desk.', + details: 'A walnut writing desk.', + }); + fixture.textAssets.setObject('desk', { + title: 'Desk', + description: 'A desk.', + details: 'A walnut writing desk.', + }); + fixture.addEntity('letter', { + title: 'Letter', + description: 'A folded letter.', + spatial: { parentNodeId: 'desk', relation: 'on' }, + }); + + const result = await fixture.run('examine desk'); + + expect(result.messages.at(-1)).toBe( + [ + 'A walnut writing desk.', + fixture.game.text('parser.relation_contents', { + Relation: 'On', + target: 'Desk', + items: 'Letter', + }), + ].join('\n') + ); + }); + + it('discovers hidden lookable spatial contents through EXAMINE target', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('desk', { + title: 'Desk', + description: 'A desk.', + details: 'A walnut writing desk.', + }); + fixture.textAssets.setObject('desk', { + title: 'Desk', + description: 'A desk.', + details: 'A walnut writing desk.', + }); + const key = fixture.addEntity('key', { + title: 'Key', + description: 'A hidden key.', + spatial: { parentNodeId: 'desk', relation: 'under' }, + }); + key.hidden = 'lookable'; + + const result = await fixture.run('examine desk'); + + expect(result.messages.at(-1)).toBe( + [ + 'A walnut writing desk.', + fixture.game.text('parser.relation_discovered_contents', { + Relation: 'Under', + target: 'Desk', + items: 'Key', + }), + ].join('\n') + ); + expect(fixture.scene.isHiddenEntityRevealed(key)).toBe(true); + }); + it('surfaces the distance error for a far but visible EXAMINE target', async () => { const fixture = createParserFixture(); fixture.addPlayer('Hero', 0, 0); @@ -274,7 +439,7 @@ describe('Parser + game integration smoke', () => { const relationResult = await fixture.run('look under chair'); expect(relationResult.messages.at(-1)).toBe( - fixture.game.text('parser.relation_contents', { + fixture.game.text('parser.relation_discovered_contents', { Relation: 'Under', target: 'Chair', items: 'Key', @@ -306,7 +471,7 @@ describe('Parser + game integration smoke', () => { const relationResult = await fixture.run('look behind boombox'); expect(relationResult.messages.at(-1)).toBe( - fixture.game.text('parser.relation_contents', { + fixture.game.text('parser.relation_discovered_contents', { Relation: 'Behind', target: 'Boombox', items: 'audio cables', @@ -366,7 +531,16 @@ describe('Parser + game integration smoke', () => { expect(fixture.scene.isHiddenEntityRevealed(cables)).toBe(false); const examineAnchorResult = await fixture.run('examine boombox'); - expect(examineAnchorResult.messages.at(-1)).toBe('A dusty cassette recorder.'); + expect(examineAnchorResult.messages.at(-1)).toBe( + [ + 'A dusty cassette recorder.', + fixture.game.text('parser.relation_contents', { + Relation: 'Behind', + target: 'Boombox', + items: 'audio cables', + }), + ].join('\n') + ); expect(fixture.scene.isHiddenEntityRevealed(cables)).toBe(true); const revealedResult = await fixture.run('look cables'); From 96c124a981c5d5bbdc6fb8a8fc150eab228e896c Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Thu, 14 May 2026 17:59:32 +0200 Subject: [PATCH 02/12] SoundSys: added "Attached volume" in settings --- GDD.md | 10 +++-- Parser.md | 13 +++++++ SoundSys.md | 3 ++ TextAssets.md | 7 ++++ .../editor/properties/SceneProperties.tsx | 6 ++- .../editor/properties/SettingsProperties.tsx | 36 +++++++++++++++++ .../editor/properties/propertiesConstants.ts | 2 + src/core/Game.ts | 39 ++++++++++++++----- src/systems/SoundManager.ts | 28 ++++++++++++- tests/systems/sound-manager.test.ts | 26 +++++++++++++ 10 files changed, 154 insertions(+), 16 deletions(-) diff --git a/GDD.md b/GDD.md index 464cda1..b701854 100644 --- a/GDD.md +++ b/GDD.md @@ -387,7 +387,7 @@ Parser видит это так, как будто: Из этого следует правило: `on`/`under`/`behind` между вложенными значимыми объектами не отменяет их `in`-отношение к более внешнему контейнеру. Одно и то же дерево spatial-узлов может давать разные корректные текстовые отношения в зависимости от якоря запроса. -Это правило используется не только для описаний, но и для relation-aware parser-действий. `TAKE Book B FROM Cabinet` может найти `Book B`, потому что относительно `Cabinet` она находится внутри шкафа; `TAKE Book B FROM Book A` тоже может быть допустимой естественной формулировкой, потому что `from` для `TAKE` трактуется как общий источник, а ближайшее конкретное отношение между `Book A` и `Book B` остаётся `on`. После semantic resolution все действия всё равно проходят обычные runtime-проверки расстояния, блокеров, closed/transparent Switch и возможности взять предмет. +Это правило используется не только для описаний, но и для relation-aware parser-действий. `LOOK Cabinet` и `EXAMINE Cabinet` после основного текста перечисляют видимые titled-потомки по всем relations (`in`, `on`, `under`, `behind`) относительно `Cabinet`; `LOOK IN Cabinet` перечисляет только потомков в выбранном relation. `TAKE Book B FROM Cabinet` может найти `Book B`, потому что относительно `Cabinet` она находится внутри шкафа; `TAKE Book B FROM Book A` тоже может быть допустимой естественной формулировкой, потому что `from` для `TAKE` трактуется как общий источник, а ближайшее конкретное отношение между `Book A` и `Book B` остаётся `on`. После semantic resolution все действия всё равно проходят обычные runtime-проверки расстояния, блокеров, closed/transparent Switch и возможности взять предмет. Таким образом, технические spatial-узлы можно использовать для внутренней структуры сцены, не засоряя ими текстовый слой и не ломая parser-команды. @@ -611,10 +611,10 @@ Parser-команды `OPEN` и `CLOSE` используют тот же runtime - `hidden` (`false | lookable | examinable`) : семантическое поле titled-объекта. - `false`: обычное поведение; - - `lookable`: объект отсутствует в semantic world model, пока не будет раскрыт через `LOOK`-контекст, включая relation-look и mouse title reveal; + - `lookable`: объект отсутствует в semantic world model, пока не будет раскрыт через осмотр видимого anchor: `LOOK `, relation-look (`LOOK UNDER `), `EXAMINE ` или mouse title reveal; - `examinable`: объект отсутствует, пока не будет раскрыт успешным `EXAMINE`. - После раскрытия объект становится обычной семантической сущностью до конца текущей runtime-сессии сцены. + При первом раскрытии `lookable`-объекта через spatial-осмотр player-facing текст использует discovery-формулировку (`you discover`) вместо обычной visibility-формулировки (`you see`). После раскрытия объект становится обычной семантической сущностью до конца текущей runtime-сессии сцены, и последующие описания используют обычный `you see`. - _State_ () : Состояние объекта, например открыт/закрыт, включено/выключено, etc. Состояния это по сути переменные или свойства объекта, которые могут быть изменены скриптом. Они имеют поля: @@ -1067,7 +1067,9 @@ F1 Game F2 Save F3 Load F4 New F5 Sprite Edit F9 Settings - _New_: Создаёт новую сцену из файла шаблона (default). Если текущая сцена содержит несохранённые изменения, перед созданием или загрузкой новой сцены открывается системный поп-ап подтверждения. - _Sprite_: Переход в редактор спрайтов. -- _Settings_: Открывает в правой панели глобальные настройки игры. Сейчас они ограничены только настройками шейдера CRT: +- _Settings_: Открывает в правой панели глобальные настройки игры: + - _Attached Volume_: глобальная коррекция громкости только для звуков, присоединённых к объектам сцены. Значение 1.0 оставляет авторскую громкость без изменений. + - _CRT settings_: настройки шейдера CRT: - _CRT MODE on/off_: включает/выключает все эффекты CRT (серый фон, искажения, scanlines, abberations) - _CRT geometry_: задаёт степень "выпуклости" экрана - _CRT scanlines_: размер scanlines diff --git a/Parser.md b/Parser.md index 03f5f29..c1f3357 100644 --- a/Parser.md +++ b/Parser.md @@ -225,6 +225,8 @@ Parser не должен самостоятельно обходить raw `.spa - `EXAMINE` использует инвентарь, объекты активной subscene и объекты в пределах допустимой дистанции; - `GO TO` использует сценовые цели и достижимые сценовые объекты. +Для direct `LOOK ` и `EXAMINE ` parser после основного описания добавляет spatial-summary видимых titled-потомков target по relations `in`, `on`, `under`, `behind`. Сводка строится через runtime text layer, поэтому безымянные технические узлы схлопываются, hidden unrevealed объекты не попадают в обычную видимость, а relation определяется относительно выбранного semantic anchor. + Текущая модель scope: ```ts @@ -873,6 +875,8 @@ Plural matching в v1 намеренно простой: - `LOOK` использует обычное краткое описание (`description`); - `EXAMINE` использует расширенное описание (`details`). +- После основного описания `EXAMINE ` добавляет ту же spatial-summary видимых дочерних объектов, что и `LOOK `. +- `hidden: lookable` spatial-дочерние объекты могут быть раскрыты через `EXAMINE ` так же, как через `LOOK ` или relation LOOK. Если `details` отсутствует: @@ -890,6 +894,15 @@ Plural matching в v1 намеренно простой: Это правило относится к игровому миру, а не к языку, поэтому применяется на стороне `Game.examineEntity()`. +### Spatial discovery text + +Relation-aware visibility messages use two service text keys: + +- `parser.relation_contents`: ordinary visible contents, default `"{Relation} the {target} you see: {items}."`; +- `parser.relation_discovered_contents`: first-time discovery of `hidden: lookable` contents, default `"{Relation} the {target} you discover: {items}."`. + +The discovery text is used only when the command actually reveals at least one previously hidden lookable object. After reveal, the same object is part of normal semantic visibility for the current scene runtime session, so later `LOOK` / `EXAMINE` output uses `parser.relation_contents`. + --- ## Pending Clarification diff --git a/SoundSys.md b/SoundSys.md index 91e8a85..0d7b98f 100644 --- a/SoundSys.md +++ b/SoundSys.md @@ -48,6 +48,9 @@ The engine provides a dedicated **3D SOUND ENV.** section in the Scene Propertie ### Dynamic Hot-Swapping The system supports real-time switching of the **Default Reverb IR**. When changed in the editor, all active sounds using the scene default will immediately update their acoustics by recreating their internal `ConvolverNode`. Clearing the field will smoothly return sounds to a "dry" state. +### Global Attached Volume +The F9 Settings panel exposes **Attached Volume**, a global correction applied only to sounds attached to scene objects. `1.0` preserves authored playback volume, values above `1.0` boost attached 3D sounds, and lower values reduce them. It is applied before the dry/reverb split, so scene acoustics and dry/wet ratios remain unchanged. + ## Proximity Effect (EQ & Reverb Scaling) When `useProximityEQ: true` is enabled, the spatial relationship between the camera and the object drives a dynamic mixer: diff --git a/TextAssets.md b/TextAssets.md index 0158cb0..495dc39 100644 --- a/TextAssets.md +++ b/TextAssets.md @@ -111,6 +111,13 @@ Scripts do not generate text themselves. They only change which named text field - Parser and UI should read only the resolved standard fields, not custom variant names directly. - The LLM parser cascade receives resolved parser/world context plus the system prompt asset; it should not read arbitrary scene files directly. +Parser service text also owns relation-summary phrasing: + +- `parser.relation_contents` is used when `LOOK `, `EXAMINE `, or relation LOOK reports already visible spatial contents. +- `parser.relation_discovered_contents` is used only on the first reveal of `hidden: lookable` spatial contents; the default wording changes `you see` to `you discover`. + +After discovery, the object is no longer hidden for the current runtime scene session, so later summaries return to `parser.relation_contents`. + ## Object semantic fields for LLM context Object Text Assets can describe lightweight semantic knowledge for the Stage 2 LLM cascade. diff --git a/src/components/editor/properties/SceneProperties.tsx b/src/components/editor/properties/SceneProperties.tsx index 44cef3e..c11fbec 100644 --- a/src/components/editor/properties/SceneProperties.tsx +++ b/src/components/editor/properties/SceneProperties.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { usePropertiesContext } from './PropertiesContext'; import { Scene } from '../../../scene/Scene'; -import { SoundManager } from '../../../systems/SoundManager'; +import { + SoundManager, + type DistanceModelType, + type PanningModelType, +} from '../../../systems/SoundManager'; export const SceneProperties: React.FC = () => { const { game, obj, formatPanelNumber, setSectionRef, incrementObjectVersion, handleChange } = diff --git a/src/components/editor/properties/SettingsProperties.tsx b/src/components/editor/properties/SettingsProperties.tsx index 6e4df02..4ca37f6 100644 --- a/src/components/editor/properties/SettingsProperties.tsx +++ b/src/components/editor/properties/SettingsProperties.tsx @@ -3,12 +3,16 @@ import { usePropertiesContext } from './PropertiesContext'; import { Select } from '../../common/Select'; import { isTauriRuntime } from '../../../platform/fileApi'; import { useEditorStore } from '../../../store/editorStore'; +import { SoundManager } from '../../../systems/SoundManager'; interface GameSettings { editor?: { uiScale?: number; viewportZoom?: 'fit' | '1' | '1.5' | '2'; }; + audio?: { + attachedVolume?: number; + }; crt?: { enabled: boolean; curvature: number; @@ -82,6 +86,38 @@ export const SettingsProperties: React.FC = () => { )} +

+ +
+ + { + if (!settings.audio) settings.audio = { attachedVolume: 1.0 }; + const val = parseFloat(e.target.value); + if (Number.isFinite(val)) { + settings.audio.attachedVolume = Math.max(0, Math.min(10, val)); + SoundManager.getInstance().setAttachedVolume(settings.audio.attachedVolume); + incrementObjectVersion(); + } + }} + /> +
+
diff --git a/src/components/editor/properties/PropertiesPanel.tsx b/src/components/editor/properties/PropertiesPanel.tsx index 85574ed..2d899fd 100644 --- a/src/components/editor/properties/PropertiesPanel.tsx +++ b/src/components/editor/properties/PropertiesPanel.tsx @@ -558,6 +558,9 @@ export const PropertiesPanel: React.FC = () => { if (field === 'spriteName') { if (obj.setSprite) obj.setSprite(finalVal); } + if (field === 'refScale') { + obj.applySceneCorrectionalScale?.(game?.sceneManager?.currentScene); + } if (field === 'ignoreScaling') { const isIgnored = finalVal; const scene = game?.sceneManager?.currentScene; diff --git a/src/components/editor/properties/SceneProperties.tsx b/src/components/editor/properties/SceneProperties.tsx index c11fbec..1e46161 100644 --- a/src/components/editor/properties/SceneProperties.tsx +++ b/src/components/editor/properties/SceneProperties.tsx @@ -266,6 +266,9 @@ export const SceneProperties: React.FC = () => { const s = game.sceneManager.currentScene.scaling; return ( <> +
+
Depth Scaling
+
)} +
+
Correction
+
+
+ + { + const val = parseFloat(e.target.value); + scene.applyCorrectionalScaleChange(Number.isFinite(val) && val > 0 ? val : 1); + incrementObjectVersion(); + }} + /> +
); })()} diff --git a/src/components/editor/properties/propertiesConstants.ts b/src/components/editor/properties/propertiesConstants.ts index afa6c06..9228f13 100644 --- a/src/components/editor/properties/propertiesConstants.ts +++ b/src/components/editor/properties/propertiesConstants.ts @@ -24,7 +24,7 @@ export const PROPERTIES_LABEL_TOOLTIPS: Record = { H: 'Visible height of the object rectangle.', W: 'Visible width of the object rectangle.', Scale: - 'Overall size multiplier. For polygon objects it scales the current shape around its center; for sprite objects it changes their model scale.', + 'Reference size multiplier for sprite objects and prefabs. Scene Correctional Scale changes existing scene objects as an editor operation, but objects entering the scene keep this Scale unchanged. For polygon objects this field scales the current shape around its center.', Layer: 'Render and interaction layer. Higher layers are treated as being in front of lower ones.', Parallax: 'Camera parallax factor. Values around 1 move with the scene, while lower or higher values create foreground or background depth drift.', @@ -125,6 +125,8 @@ export const PROPERTIES_LABEL_TOOLTIPS: Record = { 'The formula used to calculate volume drop-off:\n- Linear: Steady, constant decrease.\n- Inverse: Natural-sounding decrease (logarithmic).\n- Exponential: Very sharp drop-off at a distance.', 'Default Reverb IR': 'Impulse response file used as the default reverb for all attached sounds in this scene. If empty, attached sounds will be dry by default.', + 'Correctional Scale': + 'Scene-wide scale correction applied on top of object Scale. Changing it scales all scene objects, including locked ones, and their absolute coordinates around the shared scene center, so neighboring objects remain neighboring.', 'UI Scale': 'Editor interface scale multiplier.', 'Game Zoom': 'Scales the game viewport inside the application window. Fit uses the largest size that still stays fully visible.', diff --git a/src/core/Game.ts b/src/core/Game.ts index d3bf1f7..54a9b66 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -7,6 +7,7 @@ import { SpriteEditor } from '../tools/SpriteEditor'; import { AssetLoader } from './AssetLoader'; import { Entity } from '../entities/Entity'; import { SceneObject } from '../entities/SceneObject'; +import { Actor } from '../entities/Actor'; import { registerDemoScripts } from '../scripts/DemoScripts'; import { registerUserScripts } from '../scripts/main'; import { AudioManager } from './AudioManager'; @@ -817,7 +818,13 @@ export class Game implements IGame { }; } - this.sceneManager.switchTo(sceneId); + const player = + currentScene?.player || + (currentScene?.entities.find((entity) => entity instanceof Actor && entity.isPlayer) as + | Actor + | undefined); + + this.sceneManager.switchTo(sceneId, player || undefined); const switchedScene = this.sceneManager.currentScene; return { status: 'ok', diff --git a/src/core/ScriptAPI.ts b/src/core/ScriptAPI.ts index a383765..1f21ccb 100644 --- a/src/core/ScriptAPI.ts +++ b/src/core/ScriptAPI.ts @@ -1,5 +1,6 @@ import type { IGame } from './IGame'; import { QuadObject } from '../entities/QuadObject'; +import { Actor } from '../entities/Actor'; import { SoundManager, type SoundOptions } from '../systems/SoundManager'; export interface CustomTimer { @@ -156,6 +157,14 @@ export class ScriptAPI { return scene.findEntity(name) as any; } + transferActor(actorName: string, targetSceneId: string, targetEntryId?: string | null): boolean { + const scene = this.game.sceneManager.currentScene; + if (!scene) return false; + const actor = scene.findEntity(actorName); + if (!(actor instanceof Actor)) return false; + return !!this.game.sceneManager.transferActorToScene(actor, targetSceneId, { targetEntryId }); + } + /** * Saves the current scene state to the Undo History. * Useful for creating granular undo points within a script. diff --git a/src/entities/Entity.ts b/src/entities/Entity.ts index 68eec45..4c8013e 100644 --- a/src/entities/Entity.ts +++ b/src/entities/Entity.ts @@ -19,6 +19,7 @@ export interface EntityData { spriteName: string | null; color: string; scale: number; + refScale?: number; modelScale?: number; // User defined scale layer: number; parallax?: number; @@ -95,6 +96,7 @@ export class Entity extends SceneObject { spriteName: string | null; image: HTMLImageElement | null; scale: number; + refScale: number; modelScale: number; // layer: number; // Inherited baseWidth: number; @@ -192,6 +194,7 @@ export class Entity extends SceneObject { 'spriteName', 'color', 'scale', + 'refScale', 'modelScale', 'parallax', 'ignoreScaling', @@ -216,6 +219,7 @@ export class Entity extends SceneObject { // Initialize defaults BEFORE setting width/height (which now rely on scale) this.scale = 1.0; + this.refScale = 1.0; this.baseWidth = width; this.baseHeight = height; @@ -249,6 +253,20 @@ export class Entity extends SceneObject { this.loadingRefCount = 0; } + applySceneCorrectionalScale(_scene: any = this.scene): void { + const ref = + typeof this.refScale === 'number' && Number.isFinite(this.refScale) && this.refScale > 0 + ? this.refScale + : typeof this.modelScale === 'number' && + Number.isFinite(this.modelScale) && + this.modelScale > 0 + ? this.modelScale + : 1; + this.refScale = ref; + this.modelScale = ref; + this.update(0); + } + setSprite(filename: string, keepSize: boolean = false): void { // Auto-detect loading state if not explicitly set if (this.isLoading) keepSize = true; @@ -485,6 +503,8 @@ export class Entity extends SceneObject { load(data: any): void { this.startLoading(); try { + const hasAuthoredRefScale = + typeof data.refScale === 'number' && Number.isFinite(data.refScale) && data.refScale > 0; // Special handling for missing baseWidth/baseHeight in old JSONs if (data.baseWidth === undefined && data.width !== undefined) { const scale = data.scale || 1.0; @@ -497,6 +517,25 @@ export class Entity extends SceneObject { super.load(data); + if (!hasAuthoredRefScale) { + const fallback = + typeof data.modelScale === 'number' && + Number.isFinite(data.modelScale) && + data.modelScale > 0 + ? data.modelScale + : typeof data.scale === 'number' && Number.isFinite(data.scale) && data.scale > 0 + ? data.scale + : 1; + this.refScale = fallback; + } + if ( + typeof this.modelScale !== 'number' || + !Number.isFinite(this.modelScale) || + this.modelScale <= 0 + ) { + this.modelScale = this.refScale; + } + if (data.spriteName) { this.setSprite(data.spriteName, true); } diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index eb388ba..23ada33 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -57,6 +57,7 @@ export interface SceneScaling { max: number; horizon: number; front: number; + correctionalScale?: number; } export interface SceneData { @@ -432,6 +433,7 @@ export class Scene { max: 1.0, horizon: 150, // Y coordinate for min scale front: 300, // Y coordinate for max scale + correctionalScale: 1, }; this.player = null; this.camera = { x: 0, y: 0, zoom: 1.0 }; @@ -643,6 +645,106 @@ export class Scene { return this.scaling.min + t * (this.scaling.max - this.scaling.min); } + getCorrectionalScale(): number { + const value = this.scaling?.correctionalScale; + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 1; + } + + applyCorrectionalScaleChange(nextScale: number): void { + const oldScale = this.getCorrectionalScale(); + const safeNext = Number.isFinite(nextScale) && nextScale > 0 ? nextScale : 1; + const factor = oldScale > 0 ? safeNext / oldScale : safeNext; + this.scaling.correctionalScale = safeNext; + + const objects = this.getAllSceneObjects(); + if (!Number.isFinite(factor) || Math.abs(factor - 1) < 0.000001 || objects.length === 0) { + this.entities.forEach((entity) => entity.applySceneCorrectionalScale(this)); + return; + } + + const centers: Array<{ x: number; y: number }> = []; + for (const object of objects) { + const vertices = (object as any).vertices as Array<{ x: number; y: number }> | undefined; + if (Array.isArray(vertices) && vertices.length > 0) { + centers.push({ + x: vertices.reduce((sum, point) => sum + point.x, 0) / vertices.length, + y: vertices.reduce((sum, point) => sum + point.y, 0) / vertices.length, + }); + continue; + } + + const poly = (object as any).poly as Array<{ x: number; y: number }> | undefined; + if (Array.isArray(poly) && poly.length > 0) { + centers.push({ + x: poly.reduce((sum, point) => sum + point.x, 0) / poly.length, + y: poly.reduce((sum, point) => sum + point.y, 0) / poly.length, + }); + continue; + } + + if ('x' in object && 'y' in object) { + centers.push({ x: (object as any).x, y: (object as any).y }); + } + } + + if (centers.length === 0) { + this.entities.forEach((entity) => entity.applySceneCorrectionalScale(this)); + return; + } + + const originX = centers.reduce((sum, point) => sum + point.x, 0) / centers.length; + const originY = centers.reduce((sum, point) => sum + point.y, 0) / centers.length; + const scalePoint = (point: { x: number; y: number }) => ({ + x: Math.round(originX + (point.x - originX) * factor), + y: Math.round(originY + (point.y - originY) * factor), + }); + + for (const object of objects) { + const vertices = (object as any).vertices as + | Array<{ x: number; y: number; p?: number }> + | undefined; + if (Array.isArray(vertices) && vertices.length > 0) { + (object as any).vertices = vertices.map((vertex) => ({ + ...vertex, + ...scalePoint(vertex), + })); + const scaledVertices = (object as any).vertices as Array<{ x: number; y: number }>; + (object as any).x = + scaledVertices.reduce((sum, vertex) => sum + vertex.x, 0) / scaledVertices.length; + (object as any).y = + scaledVertices.reduce((sum, vertex) => sum + vertex.y, 0) / scaledVertices.length; + continue; + } + + const poly = (object as any).poly as Array<{ x: number; y: number }> | undefined; + if (Array.isArray(poly) && poly.length > 0) { + (object as any).poly = poly.map(scalePoint); + continue; + } + + if ('x' in object && 'y' in object) { + const scaled = scalePoint({ x: (object as any).x, y: (object as any).y }); + (object as any).x = scaled.x; + (object as any).y = scaled.y; + } + } + + this.entities.forEach((entity) => { + const ref = + typeof entity.refScale === 'number' && + Number.isFinite(entity.refScale) && + entity.refScale > 0 + ? entity.refScale + : typeof entity.modelScale === 'number' && + Number.isFinite(entity.modelScale) && + entity.modelScale > 0 + ? entity.modelScale + : 1; + entity.refScale = ref * factor; + entity.applySceneCorrectionalScale(this); + }); + } + isWalkable(x: number, y: number, sourceEntity?: Entity): boolean { // console.log(`[Scene] isWalkable(${x}, ${y}) source=${sourceEntity?.name} Collider=${sourceEntity?.colliderWidth}x${sourceEntity?.colliderHeight}`); diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index ea43949..77fa421 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -2,6 +2,7 @@ import { Scene } from './Scene'; import type { IGame } from '../core/IGame'; import { Entity } from '../entities/Entity'; import { Actor } from '../entities/Actor'; +import type { SceneObject } from '../entities/SceneObject'; import { Walkbox } from '../entities/Walkbox'; import { Triggerbox } from '../entities/Triggerbox'; import type { EntryTrigger } from '../entities/TriggerComponents'; @@ -74,6 +75,14 @@ type CachedSceneEntry = { pinned: boolean; }; +export type ActorSceneTransferOptions = { + targetEntryId?: string | null; + removeExistingPlayer?: boolean; + setAsScenePlayer?: boolean; + preserveSpatialChildren?: boolean; + activateScene?: boolean; +}; + export class SceneManager { game: IGame; currentScene: Scene | null; @@ -110,88 +119,128 @@ export class SceneManager { this.cacheScene(scene, false); } - switchTo(sceneId: string, activator?: Actor): void { - const oldScene = this.currentScene; - const scene = this.ensureSceneLoaded(sceneId); - if (!scene) { - console.error(`Scene ${sceneId} not found!`); - return; - } + private getInventoryRelations(entity: Entity): Array<'in' | 'on' | 'under' | 'behind'> { + const relations = (entity.components || []) + .filter((component: any) => component?.type === 'Inventory') + .map((component: any) => + component?.relation === 'on' || + component?.relation === 'under' || + component?.relation === 'behind' || + component?.relation === 'in' + ? component.relation + : 'in' + ); + return Array.from(new Set(relations)); + } - // --- PLAYER TRANSFER PRIORITY --- - // If the activator is a player, we must ensure they are the ONLY player in the target scene. - if (activator && (activator as any).isPlayer) { - // 1. Remove ANY existing player instance from the target scene's entities (except the activator itself) - const existingPlayer = scene.entities.find((e) => (e as any).isPlayer && e !== activator); - if (existingPlayer) { - scene.removeEntity(existingPlayer); + private collectActorTransferEntities(actor: Actor, sourceScene: Scene | null): Entity[] { + const collected = new Set([actor]); + const queue: Entity[] = [actor]; + + const enqueue = (entity: Entity | null | undefined) => { + if (!entity || collected.has(entity)) return; + collected.add(entity); + queue.push(entity); + }; + + for (const relation of this.getInventoryRelations(actor)) { + for (const entity of this.game.inventoryManager?.getInventoryEntities?.(actor, relation) || + []) { + enqueue(entity); } + } - // 2. Transfer the live player - if (oldScene && oldScene !== scene) { - oldScene.removeEntity(activator as Entity); - scene.addEntity(activator as Entity); + while (queue.length > 0) { + const current = queue.shift(); + if (!current || !sourceScene) continue; + + for (const candidate of sourceScene.entities) { + if (!(candidate instanceof Entity)) continue; + const parentId = + typeof (candidate as any).spatial?.parentNodeId === 'string' + ? (candidate as any).spatial.parentNodeId.trim() + : ''; + if (parentId === current.name) { + enqueue(candidate); + } } + } - // 3. FORCE the scene's player reference to our live activator - scene.player = activator; - } else if (activator && oldScene && oldScene !== scene) { - // Handle NPC transfer - oldScene.removeEntity(activator as Entity); - scene.addEntity(activator as Entity); - } - - // Handle Entry point placement (works for both scene switch and same-scene teleport) - if (this.pendingEntryId) { - const entryObj = scene.getObjectByName(this.pendingEntryId); - if (entryObj) { - const entryComp = entryObj.components?.find((c) => c.type === 'Entry') as - | EntryTrigger - | undefined; - if (entryComp) { - // Calculate center of object - let targetX: number | null = null; - let targetY: number | null = null; - - if (entryObj.type === 'Triggerbox' || (entryObj as any).poly) { - const poly = (entryObj as any).poly as { x: number; y: number }[]; - if (poly && poly.length > 0) { - let cx = 0, - cy = 0; - poly.forEach((p) => { - cx += p.x; - cy += p.y; - }); - targetX = cx / poly.length; - targetY = cy / poly.length; - } - } else if ('x' in entryObj && 'y' in entryObj) { - // It's an Entity or Quad - targetX = (entryObj as any).x; - targetY = (entryObj as any).y; - } + return Array.from(collected); + } - if (activator && targetX !== null && targetY !== null) { - activator.x = targetX; - activator.y = targetY; - if (entryComp.direction && typeof (activator as any).setDirection === 'function') { - (activator as any).setDirection(entryComp.direction); - } - activator.update(0); - - // Ensure player reference is set before snapping - if ((activator as any).isPlayer) { - scene.player = activator; - if (scene.autoCenter) { - scene.snapCameraToPlayer(); - } - } - } - } + private detachEntityForSceneTransfer(scene: Scene, entity: Entity): void { + const index = scene.entities.indexOf(entity); + if (index === -1) return; + scene.entities.splice(index, 1); + scene.revealedHiddenEntities.delete(entity.name); + scene.subsceneEntities.delete(entity); + if (scene.player === entity) { + scene.player = null; + } + } + + private attachEntityForSceneTransfer(scene: Scene, entity: Entity): void { + if (!scene.entities.includes(entity)) { + scene.entities.push(entity); + } + // @ts-ignore + entity.scene = scene; + } + + private findFirstEntryId(scene: Scene): string | null { + const entry = scene + .getAllSceneObjects() + .find((object) => object.components?.some((component) => component.type === 'Entry')); + return entry?.name || null; + } + + private applyEntryPlacement( + scene: Scene, + actor: Actor, + entryId: string | null + ): SceneObject | null { + if (!entryId) return null; + const entryObj = scene.getObjectByName(entryId); + if (!entryObj) return null; + const entryComp = entryObj.components?.find((c) => c.type === 'Entry') as + | EntryTrigger + | undefined; + if (!entryComp) return null; + + let targetX: number | null = null; + let targetY: number | null = null; + + if (entryObj.type === 'Triggerbox' || (entryObj as any).poly) { + const poly = (entryObj as any).poly as { x: number; y: number }[]; + if (poly && poly.length > 0) { + let cx = 0; + let cy = 0; + poly.forEach((p) => { + cx += p.x; + cy += p.y; + }); + targetX = cx / poly.length; + targetY = cy / poly.length; } - this.pendingEntryId = null; + } else if ('x' in entryObj && 'y' in entryObj) { + targetX = (entryObj as any).x; + targetY = (entryObj as any).y; + } + + if (targetX === null || targetY === null) return entryObj; + actor.x = targetX; + actor.y = targetY; + actor.layer = entryObj.layer; + actor.parallax = entryObj.parallax; + if (entryComp.direction && typeof (actor as any).setDirection === 'function') { + (actor as any).setDirection(entryComp.direction); } + actor.update(0); + return entryObj; + } + private finalizeSceneActivation(oldScene: Scene | null, scene: Scene): void { this.currentScene = scene; SoundManager.getInstance().setEnvironment(scene.soundEnv); if (oldScene !== scene) { @@ -209,6 +258,85 @@ export class SceneManager { this.evictScenesIfNeeded(); } + transferActorToScene( + actor: Actor, + targetSceneId: string, + options: ActorSceneTransferOptions = {} + ): Scene | null { + const sourceScene = this.currentScene; + const targetScene = this.ensureSceneLoaded(targetSceneId); + if (!targetScene) { + console.error(`Scene ${targetSceneId} not found!`); + return null; + } + + const removeExistingPlayer = options.removeExistingPlayer ?? !!(actor as any).isPlayer; + const setAsScenePlayer = options.setAsScenePlayer ?? !!(actor as any).isPlayer; + const preserveSpatialChildren = options.preserveSpatialChildren ?? true; + const activateScene = options.activateScene ?? setAsScenePlayer; + const transferEntities = preserveSpatialChildren + ? this.collectActorTransferEntities(actor, sourceScene) + : [actor]; + + if (removeExistingPlayer) { + const existingPlayer = targetScene.entities.find((e) => (e as any).isPlayer && e !== actor); + if (existingPlayer) { + targetScene.removeEntity(existingPlayer); + } + } + + if (sourceScene && sourceScene !== targetScene) { + for (const entity of transferEntities) { + this.detachEntityForSceneTransfer(sourceScene, entity); + } + for (const entity of transferEntities) { + this.attachEntityForSceneTransfer(targetScene, entity); + entity.applySceneCorrectionalScale?.(targetScene); + } + } else if (!targetScene.entities.includes(actor)) { + this.attachEntityForSceneTransfer(targetScene, actor); + } + + if (setAsScenePlayer) { + targetScene.player = actor; + } + const targetEntryId = + options.targetEntryId ?? + (sourceScene !== targetScene ? this.findFirstEntryId(targetScene) : null); + this.applyEntryPlacement(targetScene, actor, targetEntryId); + if (setAsScenePlayer && sourceScene !== targetScene && targetScene.defaultCamera) { + targetScene.camera.zoom = targetScene.defaultCamera.zoom; + } + if (setAsScenePlayer && targetScene.autoCenter) { + targetScene.snapCameraToPlayer(); + } + if (activateScene) { + this.finalizeSceneActivation(sourceScene, targetScene); + } + + return targetScene; + } + + switchTo(sceneId: string, activator?: Actor): void { + const oldScene = this.currentScene; + const scene = this.ensureSceneLoaded(sceneId); + if (!scene) { + console.error(`Scene ${sceneId} not found!`); + return; + } + + if (activator) { + this.transferActorToScene(activator, sceneId, { + targetEntryId: this.pendingEntryId, + activateScene: false, + }); + } else { + this.pendingEntryId = null; + } + this.pendingEntryId = null; + this.finalizeSceneActivation(oldScene, scene); + } + exposeEntitiesToWindow(): void { if (!this.currentScene) return; const shouldExpose = @@ -540,7 +668,7 @@ export class SceneManager { if (data.camMaxX !== undefined) newScene.camMaxX = data.camMaxX; if (data.camMinY !== undefined) newScene.camMinY = data.camMinY; if (data.camMaxY !== undefined) newScene.camMaxY = data.camMaxY; - if (data.scaling) newScene.scaling = data.scaling; + if (data.scaling) newScene.scaling = { ...newScene.scaling, ...data.scaling }; if (data.soundEnv) { newScene.soundEnv = { ...newScene.soundEnv, ...data.soundEnv }; diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index 588300e..a031c0b 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -316,8 +316,15 @@ export class ComponentSystem { const targetSceneId = exit.targetSceneId?.trim() || scene.id; if (!sceneManager || !targetSceneId) return false; - sceneManager.pendingEntryId = exit.targetEntryId?.trim() || null; - sceneManager.switchTo(targetSceneId, activator); + if (activator) { + sceneManager.transferActorToScene(activator, targetSceneId, { + targetEntryId: exit.targetEntryId?.trim() || null, + activateScene: true, + }); + return true; + } + + sceneManager.switchTo(targetSceneId); return true; } diff --git a/tests/entities/entity-ref-scale.test.ts b/tests/entities/entity-ref-scale.test.ts new file mode 100644 index 0000000..97727f8 --- /dev/null +++ b/tests/entities/entity-ref-scale.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { Entity } from '../../src/entities/Entity'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +describe('Entity refScale', () => { + it('serializes refScale and restores legacy objects from modelScale', () => { + const fixture = createSceneFixture(); + const entity = new Entity(fixture.game, 0, 0, 10, 10, 'item'); + entity.refScale = 0.75; + + expect(entity.toJSON().refScale).toBe(0.75); + + const legacy = Entity.fromJSON(fixture.game, { + type: 'Entity', + name: 'legacy', + x: 0, + y: 0, + width: 10, + height: 10, + spriteName: null, + color: '#fff', + scale: 0.5, + modelScale: 0.6, + layer: 0, + }); + + expect(legacy.refScale).toBe(0.6); + }); + + it('applies refScale directly to modelScale and final scale', () => { + const fixture = createSceneFixture(); + fixture.scene.scaling = { ...fixture.scene.scaling, enabled: false, correctionalScale: 1.5 }; + const entity = new Entity(fixture.game, 0, 0, 10, 10, 'item'); + entity.refScale = 0.8; + fixture.scene.addEntity(entity); + + entity.applySceneCorrectionalScale(fixture.scene); + + expect(entity.modelScale).toBeCloseTo(0.8); + expect(entity.scale).toBeCloseTo(0.8); + }); +}); diff --git a/tests/fixtures/gameFactory.ts b/tests/fixtures/gameFactory.ts index 41be59e..989b180 100644 --- a/tests/fixtures/gameFactory.ts +++ b/tests/fixtures/gameFactory.ts @@ -39,38 +39,158 @@ export function createTestGame(): TestGameHarness { scenes: new Map(), sceneRegistry: new Map(), pendingEntryId: null, - switchTo(sceneId: string, activator?: Actor) { + getInventoryRelations(entity: Actor) { + return Array.from( + new Set( + (entity.components || []) + .filter((component: any) => component?.type === 'Inventory') + .map((component: any) => + component?.relation === 'on' || + component?.relation === 'under' || + component?.relation === 'behind' || + component?.relation === 'in' + ? component.relation + : 'in' + ) + ) + ); + }, + collectActorTransferEntities(actor: Actor, sourceScene: Scene | null) { + const collected = new Set([actor]); + const queue: any[] = [actor]; + const enqueue = (entity: any) => { + if (!entity || collected.has(entity)) return; + collected.add(entity); + queue.push(entity); + }; + + for (const relation of this.getInventoryRelations(actor)) { + for (const entity of game.inventoryManager?.getInventoryEntities?.( + actor as any, + relation as any + ) || []) { + enqueue(entity); + } + } + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || !sourceScene) continue; + for (const candidate of sourceScene.entities) { + const parentId = + typeof (candidate as any).spatial?.parentNodeId === 'string' + ? (candidate as any).spatial.parentNodeId.trim() + : ''; + if (parentId === current.name) { + enqueue(candidate); + } + } + } + + return Array.from(collected); + }, + transferActorToScene(actor: Actor, sceneId: string, options: any = {}) { const targetScene = this.scenes.get(sceneId); - if (!targetScene) return; + if (!targetScene) return null; const oldScene = this.currentScene; - if (activator && oldScene && oldScene !== targetScene) { - oldScene.removeEntity(activator); - targetScene.addEntity(activator); - if (activator.isPlayer) { - targetScene.player = activator; + const removeExistingPlayer = options.removeExistingPlayer ?? !!actor.isPlayer; + const setAsScenePlayer = options.setAsScenePlayer ?? !!actor.isPlayer; + const activateScene = options.activateScene ?? setAsScenePlayer; + const entities = + options.preserveSpatialChildren === false + ? [actor] + : this.collectActorTransferEntities(actor, oldScene); + + if (removeExistingPlayer) { + const existingPlayer = targetScene.entities.find( + (entity: any) => entity.isPlayer && entity !== actor + ); + if (existingPlayer) { + targetScene.removeEntity(existingPlayer); } } - this.currentScene = targetScene; - if (oldScene !== targetScene) { - targetScene.clearParserRecentTurns?.(); + if (oldScene && oldScene !== targetScene) { + for (const entity of entities) { + const index = oldScene.entities.indexOf(entity); + if (index >= 0) oldScene.entities.splice(index, 1); + oldScene.subsceneEntities.delete(entity); + if (oldScene.player === entity) oldScene.player = null; + } + for (const entity of entities) { + if (!targetScene.entities.includes(entity)) targetScene.entities.push(entity); + (entity as any).scene = targetScene; + entity.applySceneCorrectionalScale?.(targetScene); + } + } else if (!targetScene.entities.includes(actor)) { + targetScene.entities.push(actor); + (actor as any).scene = targetScene; } - if (this.pendingEntryId) { - const entryObj = targetScene.getObjectByName(this.pendingEntryId); + if (setAsScenePlayer) { + targetScene.player = actor; + } + + const targetEntryId = + options.targetEntryId ?? + (oldScene !== targetScene + ? targetScene + .getAllSceneObjects() + .find((object: any) => + object.components?.some((component: any) => component.type === 'Entry') + )?.name || null + : null); + if (targetEntryId) { + const entryObj = targetScene.getObjectByName(targetEntryId); const entryComp = entryObj?.components?.find( (component: any) => component.type === 'Entry' ); const poly = (entryObj as any)?.poly as { x: number; y: number }[] | undefined; - if (activator && entryComp && Array.isArray(poly) && poly.length > 0) { - activator.x = poly.reduce((sum, point) => sum + point.x, 0) / poly.length; - activator.y = poly.reduce((sum, point) => sum + point.y, 0) / poly.length; - if (entryComp.direction && typeof (activator as any).setDirection === 'function') { - (activator as any).setDirection(entryComp.direction); + if (entryComp && Array.isArray(poly) && poly.length > 0) { + actor.x = poly.reduce((sum, point) => sum + point.x, 0) / poly.length; + actor.y = poly.reduce((sum, point) => sum + point.y, 0) / poly.length; + } else if (entryComp && entryObj && 'x' in entryObj && 'y' in entryObj) { + actor.x = (entryObj as any).x; + actor.y = (entryObj as any).y; + } + if (entryComp) { + actor.layer = entryObj.layer; + actor.parallax = entryObj.parallax; + if (entryComp.direction && typeof (actor as any).setDirection === 'function') { + (actor as any).setDirection(entryComp.direction); } + actor.update?.(0); } - this.pendingEntryId = null; + } + if (setAsScenePlayer && oldScene !== targetScene && targetScene.defaultCamera) { + targetScene.camera.zoom = targetScene.defaultCamera.zoom; + } + if (activateScene) { + this.currentScene = targetScene; + if (oldScene !== targetScene) { + targetScene.clearParserRecentTurns?.(); + } + game.inventoryManager?.handleSceneChange?.(); + } + return targetScene; + }, + switchTo(sceneId: string, activator?: Actor) { + const targetScene = this.scenes.get(sceneId); + if (!targetScene) return; + + const oldScene = this.currentScene; + if (activator) { + this.transferActorToScene(activator, sceneId, { + targetEntryId: this.pendingEntryId, + activateScene: false, + }); + } + this.pendingEntryId = null; + + this.currentScene = targetScene; + if (oldScene !== targetScene) { + targetScene.clearParserRecentTurns?.(); } game.inventoryManager?.handleSceneChange?.(); @@ -224,7 +344,7 @@ export function createTestGame(): TestGameHarness { return notImplementedOutcome('not_implemented_go_to_scene_target'); }, goToScene(sceneId: string) { - this.sceneManager.switchTo(sceneId); + this.sceneManager.switchTo(sceneId, this.sceneManager.currentScene?.player); return { status: 'ok', code: 'scene_changed', diff --git a/tests/fixtures/gameSemanticFactory.ts b/tests/fixtures/gameSemanticFactory.ts index 799e43e..126540f 100644 --- a/tests/fixtures/gameSemanticFactory.ts +++ b/tests/fixtures/gameSemanticFactory.ts @@ -41,14 +41,12 @@ export function createGameSemanticFixture(sceneId: string = 'test_scene'): GameS delete (fixture.game as Record)[methodName]; } - fixture.game.sceneManager.switchTo = (id: string) => { - const scene = fixture.game.sceneManager.scenes.get(id); - if (scene) { - fixture.game.sceneManager.currentScene = scene; - fixture.game.inventoryManager.handleSceneChange(); - if (fixture.game.onSceneChange) { - fixture.game.onSceneChange(scene.name); - } + const switchTo = fixture.game.sceneManager.switchTo.bind(fixture.game.sceneManager); + fixture.game.sceneManager.switchTo = (id: string, activator?: any) => { + switchTo(id, activator); + const scene = fixture.game.sceneManager.currentScene; + if (scene && fixture.game.onSceneChange) { + fixture.game.onSceneChange(scene.name); } }; diff --git a/tests/fixtures/sceneFactory.ts b/tests/fixtures/sceneFactory.ts index 5fd3ea8..8005d71 100644 --- a/tests/fixtures/sceneFactory.ts +++ b/tests/fixtures/sceneFactory.ts @@ -20,6 +20,7 @@ type EntityOptions = { }; type TriggerboxOptions = { + scene?: Scene; title?: string | null; description?: string; details?: string; @@ -125,7 +126,7 @@ export function createSceneFixture(sceneId: string = 'test_scene'): SceneFixture return player; }, addTriggerbox(name, options = {}) { - const targetScene = harness.game.sceneManager.currentScene || scene; + const targetScene = options.scene || harness.game.sceneManager.currentScene || scene; const triggerbox = new Triggerbox(DEFAULT_POLY, name, ''); triggerbox.disabled = options.disabled ?? false; triggerbox.groupID = options.groupID ?? null; diff --git a/tests/game/navigation-and-spatial.test.ts b/tests/game/navigation-and-spatial.test.ts index 01c236e..1d2f9c2 100644 --- a/tests/game/navigation-and-spatial.test.ts +++ b/tests/game/navigation-and-spatial.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createGameSemanticFixture } from '../fixtures/gameSemanticFactory'; import { Actor } from '../../src/entities/Actor'; import { Entity } from '../../src/entities/Entity'; +import { ComponentSystem } from '../../src/systems/ComponentSystem'; describe('Game navigation and spatial API', () => { it('goToSceneTarget resolves scene by id and title', () => { @@ -19,6 +20,54 @@ describe('Game navigation and spatial API', () => { expect(fixture.game.sceneManager.currentScene).toBe(target); }); + it('goToSceneTarget transfers the current player and inventory to the target scene', () => { + const fixture = createGameSemanticFixture('start'); + const player = fixture.addPlayer('Hero', 0, 0); + player.refScale = 0.5; + const target = fixture.addScene('test1', 'New Scene', 'You are in New Scene.'); + target.scaling = { ...target.scaling, enabled: false, correctionalScale: 2 }; + target.defaultCamera = { x: 10, y: 20, zoom: 0.42 }; + target.camera = { x: 99, y: 88, zoom: 2 }; + const entry = fixture.addTriggerbox('DefaultEntry', { + scene: target, + components: [{ type: 'Entry', direction: 'left' }], + }); + entry.layer = 7; + entry.parallax = 0.4; + entry.poly = [ + { x: 150, y: 80 }, + { x: 170, y: 80 }, + { x: 170, y: 100 }, + { x: 150, y: 100 }, + ]; + const cassette = fixture.addEntity('cassette', { + title: 'Cassette', + description: 'A cassette.', + components: [{ type: 'Item' }], + }); + expect(fixture.game.addInventoryEntity(player, cassette, 'in').status).toBe('ok'); + + const outcome = fixture.game.goToSceneTarget('New Scene'); + + expect(outcome.status).toBe('ok'); + expect(fixture.game.sceneManager.currentScene).toBe(target); + expect(fixture.scene.entities).not.toContain(player); + expect(fixture.scene.entities).not.toContain(cassette); + expect(target.entities).toContain(player); + expect(target.entities).toContain(cassette); + expect(target.player).toBe(player); + expect(player.x).toBe(160); + expect(player.y).toBe(90); + expect(player.layer).toBe(7); + expect(player.parallax).toBe(0.4); + expect(player.modelScale).toBe(0.5); + expect(player.scale).toBe(0.5); + expect(target.camera.zoom).toBe(0.42); + expect(fixture.game.inventory).toContain(cassette); + expect(cassette.visible).toBe(false); + expect((cassette as any).spatial).toEqual({ parentNodeId: 'Hero', relation: 'in' }); + }); + it('goToSceneTarget fails for an unknown destination', () => { const fixture = createGameSemanticFixture(); @@ -319,6 +368,128 @@ describe('Game navigation and spatial API', () => { expect((coin as any).spatial).toEqual({ parentNodeId: 'table', relation: 'on' }); }); + it('transfers a player actor with inventory and nested spatial descendants to the target scene', () => { + const fixture = createGameSemanticFixture('start'); + const player = fixture.addPlayer('Hero', 0, 0); + const target = fixture.addScene('hall', 'Hall', 'A hall.'); + const stalePlayer = new Actor(fixture.game as any, 50, 50, 10, 10, 'OldHero'); + stalePlayer.isPlayer = true; + target.addEntity(stalePlayer); + + const cassette = fixture.addEntity('cassette', { + title: 'Cassette', + description: 'A cassette.', + components: [{ type: 'Item' }], + }); + const label = fixture.addEntity('cassette_label', { + title: 'Cassette label', + description: 'A label.', + spatial: { parentNodeId: 'cassette', relation: 'on' }, + }); + + expect(fixture.game.addInventoryEntity(player, cassette, 'in').status).toBe('ok'); + + const moved = fixture.game.sceneManager.transferActorToScene(player, target.id); + + expect(moved).toBe(target); + expect(fixture.scene.entities).not.toContain(player); + expect(fixture.scene.entities).not.toContain(cassette); + expect(fixture.scene.entities).not.toContain(label); + expect(target.entities).toContain(player); + expect(target.entities).toContain(cassette); + expect(target.entities).toContain(label); + expect(target.entities).not.toContain(stalePlayer); + expect(target.player).toBe(player); + fixture.game.sceneManager.currentScene = target; + fixture.game.inventoryManager.handleSceneChange(); + expect(fixture.game.inventory).toContain(cassette); + expect(cassette.visible).toBe(false); + expect((cassette as any).spatial).toEqual({ parentNodeId: 'Hero', relation: 'in' }); + expect((label as any).spatial).toEqual({ parentNodeId: 'cassette', relation: 'on' }); + }); + + it('same-scene actor transfer applies Entry placement without detaching inventory children', () => { + const fixture = createGameSemanticFixture('start'); + const player = fixture.addPlayer('Hero', 0, 0); + const key = fixture.addEntity('key', { + title: 'Key', + description: 'A key.', + components: [{ type: 'Item' }], + }); + const entry = fixture.addEntity('EntryA', { + title: null, + components: [{ type: 'Entry', direction: 'right' }], + }); + entry.x = 120; + entry.y = 80; + + expect(fixture.game.addInventoryEntity(player, key, 'in').status).toBe('ok'); + + fixture.game.sceneManager.transferActorToScene(player, fixture.scene.id, { + targetEntryId: 'EntryA', + }); + + expect(fixture.scene.entities).toContain(player); + expect(fixture.scene.entities).toContain(key); + expect(player.x).toBe(120); + expect(player.y).toBe(80); + expect(key.visible).toBe(false); + expect((key as any).spatial).toEqual({ parentNodeId: 'Hero', relation: 'in' }); + }); + + it('Exit activation transfers the actor through the centralized path and applies Entry placement', () => { + const fixture = createGameSemanticFixture('start'); + const player = fixture.addPlayer('Hero', 0, 0); + const target = fixture.addScene('exit_target', 'Exit Target', 'A room.'); + const entry = new Entity(fixture.game as any, 200, 120, 10, 10, 'EntryA'); + entry.components = [{ type: 'Entry', direction: 'left' }]; + target.addEntity(entry); + const key = fixture.addEntity('key', { + title: 'Key', + description: 'A key.', + components: [{ type: 'Item' }], + }); + expect(fixture.game.addInventoryEntity(player, key, 'in').status).toBe('ok'); + const exit = fixture.addTriggerbox('ExitA', { + components: [{ type: 'Exit', targetSceneId: target.id, targetEntryId: 'EntryA' }], + }); + + const handled = ComponentSystem.handleActivation(exit as any, fixture.scene as any, 0, player); + + expect(handled).toBe(true); + expect(fixture.game.sceneManager.currentScene).toBe(target); + expect(target.entities).toContain(player); + expect(target.entities).toContain(key); + expect(player.x).toBe(200); + expect(player.y).toBe(120); + expect((key as any).spatial).toEqual({ parentNodeId: 'Hero', relation: 'in' }); + }); + + it('transfers an NPC actor with inventory without making it the scene player', () => { + const fixture = createGameSemanticFixture('start'); + const player = fixture.addPlayer('Hero', 0, 0); + const target = fixture.addScene('npc_target', 'NPC Target', 'A room.'); + const npc = new Actor(fixture.game as any, 10, 10, 10, 10, 'NPC'); + npc.components = [ + { type: 'Inventory', relation: 'in', capacity: 2, groups: [], protected: false, items: [] }, + ]; + fixture.scene.addEntity(npc); + const badge = fixture.addEntity('badge', { + title: 'Badge', + description: 'A badge.', + components: [{ type: 'Item' }], + }); + + expect(fixture.game.addInventoryEntity(npc, badge, 'in').status).toBe('ok'); + + fixture.game.sceneManager.transferActorToScene(npc, target.id); + + expect(target.entities).toContain(npc); + expect(target.entities).toContain(badge); + expect(target.player).not.toBe(npc); + expect(fixture.scene.player).toBe(player); + }); + it('switchTo hydrates untitled nested surface extensions and projects them through the titled anchor', () => { const fixture = createGameSemanticFixture('start'); const target = fixture.addScene('library', 'Library', 'You are in Library.'); diff --git a/tests/scene/scene-correctional-scale.test.ts b/tests/scene/scene-correctional-scale.test.ts new file mode 100644 index 0000000..8f2ba11 --- /dev/null +++ b/tests/scene/scene-correctional-scale.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +describe('Scene correctional scale', () => { + it('scales entity positions and scale basis around the shared center', () => { + const fixture = createSceneFixture(); + fixture.scene.scaling = { ...fixture.scene.scaling, enabled: false, correctionalScale: 1 }; + const left = fixture.addEntity('left'); + const right = fixture.addEntity('right'); + left.x = 0; + left.y = 0; + left.refScale = 0.5; + right.x = 10; + right.y = 0; + right.refScale = 1; + + fixture.scene.applyCorrectionalScaleChange(2); + + expect(left.x).toBe(-5); + expect(right.x).toBe(15); + expect(left.modelScale).toBe(1); + expect(left.scale).toBe(1); + expect(right.modelScale).toBe(2); + expect(right.scale).toBe(2); + }); + + it('scales locked objects together with the rest of the scene', () => { + const fixture = createSceneFixture(); + fixture.scene.scaling = { ...fixture.scene.scaling, enabled: false, correctionalScale: 1 }; + const locked = fixture.addEntity('locked'); + const free = fixture.addEntity('free'); + locked.x = 0; + locked.y = 0; + locked.locked = true; + free.x = 10; + free.y = 0; + + const trigger = fixture.addTriggerbox('locked_trigger'); + trigger.locked = true; + trigger.poly = [ + { x: 20, y: 0 }, + { x: 30, y: 0 }, + { x: 30, y: 10 }, + { x: 20, y: 10 }, + ]; + + fixture.scene.applyCorrectionalScaleChange(2); + + expect(locked.x).toBe(-12); + expect(free.x).toBe(8); + expect(trigger.poly).toEqual([ + { x: 28, y: -2 }, + { x: 48, y: -2 }, + { x: 48, y: 18 }, + { x: 28, y: 18 }, + ]); + }); + + it('scales triggerbox polygons with the same correction factor', () => { + const fixture = createSceneFixture(); + fixture.scene.scaling = { ...fixture.scene.scaling, enabled: false, correctionalScale: 1 }; + const entity = fixture.addEntity('anchor'); + entity.x = 0; + entity.y = 0; + const trigger = fixture.addTriggerbox('entry'); + trigger.poly = [ + { x: 10, y: 0 }, + { x: 20, y: 0 }, + { x: 20, y: 10 }, + { x: 10, y: 10 }, + ]; + + fixture.scene.applyCorrectionalScaleChange(2); + + expect(trigger.poly).toEqual([ + { x: 13, y: -2 }, + { x: 33, y: -2 }, + { x: 33, y: 18 }, + { x: 13, y: 18 }, + ]); + }); +}); From 0e546e557671b1f8f6b340bd4d7b886c2ad580de Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sun, 17 May 2026 21:01:20 +0200 Subject: [PATCH 08/12] Fixed and improved cursor in text console. Added Ctrl+ left/right arrows for navigation --- src/components/ConsoleOverlay.tsx | 49 +++++++++++++++++++++++++------ src/components/UIOverlay.tsx | 42 ++++++++++++++++++++++++-- src/core/Game.ts | 34 +++++++++++++++++---- src/core/IGame.ts | 1 + tests/fixtures/gameFactory.ts | 1 + 5 files changed, 110 insertions(+), 17 deletions(-) diff --git a/src/components/ConsoleOverlay.tsx b/src/components/ConsoleOverlay.tsx index 0a002da..8757966 100644 --- a/src/components/ConsoleOverlay.tsx +++ b/src/components/ConsoleOverlay.tsx @@ -131,25 +131,56 @@ export const ConsoleOverlay: React.FC = ({ game }) => { }; const InputMirror: React.FC<{ game: Game }> = ({ game }) => { - const [val, setVal] = useState(''); + const [inputState, setInputState] = useState({ value: '', caret: 0, cursorVisible: false }); useEffect(() => { const input = game.getCommandInput(); if (!input) return; + let frame = 0; const update = () => { - if (input && input.value !== val) { - setVal(input.value); - } - requestAnimationFrame(update); + const value = input.value; + const caret = Math.max(0, Math.min(input.selectionStart ?? value.length, value.length)); + const cursorVisible = + document.activeElement === input && Math.floor(game.cursorBlink / 500) % 2 === 0; + + setInputState((current) => { + if ( + current.value === value && + current.caret === caret && + current.cursorVisible === cursorVisible + ) { + return current; + } + return { value, caret, cursorVisible }; + }); + frame = requestAnimationFrame(update); }; - const rAF = requestAnimationFrame(update); + frame = requestAnimationFrame(update); return () => { - cancelAnimationFrame(rAF); + cancelAnimationFrame(frame); }; - }, [game, val]); + }, [game]); + + const { value, caret, cursorVisible } = inputState; + const beforeCaret = value.slice(0, caret); + const cursorChar = value[caret] || '\u00a0'; + const afterCaret = value.slice(caret + (value[caret] ? 1 : 0)); - return {`> ${val}_`}; + if (!cursorVisible) { + return {`> ${value}`}; + } + + return ( + + {'> '} + {beforeCaret} + + {cursorChar} + + {afterCaret} + + ); }; diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index 29f575b..801f990 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -148,6 +148,17 @@ export const UIOverlay: React.FC = ({ game }) => { return game?.console.continueClosedModal() || false; }, [game]); + const moveCommandInputCaret = React.useCallback( + (input: HTMLInputElement, delta: -1 | 1) => { + const caret = input.selectionStart ?? input.value.length; + const nextCaret = Math.max(0, Math.min(input.value.length, caret + delta)); + input.setSelectionRange(nextCaret, nextCaret); + game?.revealCommandCursor(); + setHistoryIndex(-1); + }, + [game] + ); + const keepCommandInputFocused = React.useCallback( (e: React.MouseEvent) => { e.preventDefault(); @@ -226,12 +237,26 @@ export const UIOverlay: React.FC = ({ game }) => { } } + if (!(e.ctrlKey || e.metaKey) && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + e.preventDefault(); + return; + } + + if (e.key === 'Home' || e.key === 'End') { + game?.revealCommandCursor(); + } + // History Navigation: Ctrl + Up/Down if (game && (e.ctrlKey || e.metaKey)) { - const history = game.console.history; - if (history.length === 0) return; + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault(); + moveCommandInputCaret(e.currentTarget, e.key === 'ArrowLeft' ? -1 : 1); + return; + } if (e.key === 'ArrowUp') { + const history = game.console.history; + if (history.length === 0) return; e.preventDefault(); // Go back/older // If we are at -1 (new/empty), go to last item (length-1) @@ -246,9 +271,16 @@ export const UIOverlay: React.FC = ({ game }) => { } setHistoryIndex(newIndex); e.currentTarget.value = history[newIndex]; + e.currentTarget.setSelectionRange( + history[newIndex].length, + history[newIndex].length + ); + game.revealCommandCursor(); } if (e.key === 'ArrowDown') { + const history = game.console.history; + if (history.length === 0) return; e.preventDefault(); // Go forward/newer // If we are at length-1 (newest), go to -1 (empty) @@ -260,10 +292,16 @@ export const UIOverlay: React.FC = ({ game }) => { if (newIndex === history.length - 1) { newIndex = -1; e.currentTarget.value = ''; + e.currentTarget.setSelectionRange(0, 0); } else { newIndex = Math.min(history.length - 1, newIndex + 1); e.currentTarget.value = history[newIndex]; + e.currentTarget.setSelectionRange( + history[newIndex].length, + history[newIndex].length + ); } + game.revealCommandCursor(); } setHistoryIndex(newIndex); } diff --git a/src/core/Game.ts b/src/core/Game.ts index 54a9b66..e864132 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -464,6 +464,10 @@ export class Game implements IGame { this.consoleInput?.focus(); } + revealCommandCursor(): void { + this.cursorBlink = 0; + } + renderUI(ctx: CanvasRenderingContext2D): void { const w = this.bufferCanvas.width; const h = this.bufferCanvas.height; @@ -508,17 +512,35 @@ export class Game implements IGame { const inputText = this.consoleInput ? this.consoleInput.value : ''; const isFocused = document.activeElement === this.consoleInput; - - let cursor = ''; + const caretIndex = + this.consoleInput && typeof this.consoleInput.selectionStart === 'number' + ? Math.max( + 0, + Math.min(this.consoleInput.selectionStart ?? inputText.length, inputText.length) + ) + : inputText.length; + + let cursorVisible = false; if (isFocused) { this.cursorBlink += 16; - if (Math.floor(this.cursorBlink / 500) % 2 === 0) { - cursor = '_'; - } + cursorVisible = Math.floor(this.cursorBlink / 500) % 2 === 0; } + const inputX = 2; + const inputY = consoleY + 2 + lineHeight * outputLineCount; + const promptText = `> ${inputText}`; ctx.fillStyle = '#fff'; - ctx.fillText(`> ${inputText}${cursor}`, 2, consoleY + 2 + lineHeight * outputLineCount); + ctx.fillText(promptText, inputX, inputY); + + if (cursorVisible) { + const beforeCaretText = `> ${inputText.slice(0, caretIndex)}`; + const cursorChar = inputText[caretIndex] || ' '; + const cursorX = inputX + ctx.measureText(beforeCaretText).width; + const cursorWidth = Math.max(1, ctx.measureText(cursorChar).width); + ctx.fillRect(cursorX, inputY, cursorWidth, lineHeight); + ctx.fillStyle = '#000'; + ctx.fillText(cursorChar, cursorX, inputY); + } } disableCRT(): void { diff --git a/src/core/IGame.ts b/src/core/IGame.ts index 6f9550f..c8f54c5 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -105,6 +105,7 @@ export interface IGame { setCommandInput(input: HTMLInputElement | null): void; getCommandInput(): HTMLInputElement | null; focusCommandInput(): void; + revealCommandCursor(): void; // Core property access needed by entities/systems input: any; diff --git a/tests/fixtures/gameFactory.ts b/tests/fixtures/gameFactory.ts index 989b180..a9e0769 100644 --- a/tests/fixtures/gameFactory.ts +++ b/tests/fixtures/gameFactory.ts @@ -367,6 +367,7 @@ export function createTestGame(): TestGameHarness { return null; }, focusCommandInput() {}, + revealCommandCursor() {}, input: { mouse: { x: 0, y: 0, clicked: false }, isDown: () => false, From 1443b876719add1f6226f6124d81e5aaf3ee5b63 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sun, 17 May 2026 21:07:38 +0200 Subject: [PATCH 09/12] Protection against losing command line focus in Game Mode --- src/components/GameCanvas.tsx | 1 + src/components/UIOverlay.tsx | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/components/GameCanvas.tsx b/src/components/GameCanvas.tsx index febff61..8c54d79 100644 --- a/src/components/GameCanvas.tsx +++ b/src/components/GameCanvas.tsx @@ -156,6 +156,7 @@ export const GameCanvas: React.FC = ({ onGameInit }) => { const active = document.activeElement as HTMLElement; if ( active && + active.id !== 'parser-input' && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.tagName === 'SELECT') diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index 801f990..3e2e2b0 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -98,6 +98,45 @@ export const UIOverlay: React.FC = ({ game }) => { return () => window.clearTimeout(timer); }, [game, editorEnabled, isConsoleOpen, isConsoleModal, previewEntity?.name]); + useEffect(() => { + if (!game) return; + const input = parserInputRef.current; + if (!input) return; + + const shouldLockCommandFocus = () => { + return ( + !input.disabled && + !fileBrowser && + !choiceDialog && + !isConsoleModal && + (!editorEnabled || isConsoleOpen) + ); + }; + + const restoreCommandFocus = () => { + if (!shouldLockCommandFocus()) return; + const caret = input.selectionStart ?? input.value.length; + input.focus({ preventScroll: true }); + input.setSelectionRange(caret, caret); + }; + + const scheduleRestoreCommandFocus = () => { + window.setTimeout(restoreCommandFocus, 0); + }; + + window.addEventListener('pointerdown', scheduleRestoreCommandFocus, true); + window.addEventListener('focusin', scheduleRestoreCommandFocus, true); + input.addEventListener('blur', scheduleRestoreCommandFocus); + + restoreCommandFocus(); + + return () => { + window.removeEventListener('pointerdown', scheduleRestoreCommandFocus, true); + window.removeEventListener('focusin', scheduleRestoreCommandFocus, true); + input.removeEventListener('blur', scheduleRestoreCommandFocus); + }; + }, [game, editorEnabled, isConsoleOpen, isConsoleModal, fileBrowser, choiceDialog]); + useEffect(() => { if (message) { const timer = setTimeout(() => { From 21b11e65610e2e59bbeedc6e9e422951af63c22e Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Mon, 18 May 2026 01:00:09 +0200 Subject: [PATCH 10/12] Fixes in transferActorToScene() --- Sessions.md | 131 ++++++++++++++++++ public/scenes/quad5.json | 122 +++++++++++----- public/scenes/test_room.json | 107 +++++++++++--- .../editor/properties/SceneProperties.tsx | 69 ++++++++- src/scene/SceneManager.ts | 48 ++++++- tests/fixtures/gameFactory.ts | 26 +++- tests/game/navigation-and-spatial.test.ts | 51 +++++++ 7 files changed, 490 insertions(+), 64 deletions(-) diff --git a/Sessions.md b/Sessions.md index 55f54f9..cd78d7d 100644 --- a/Sessions.md +++ b/Sessions.md @@ -2066,3 +2066,134 @@ Refining 3D Spatial Audio for the engine, ensuring that sound triggering, pannin - The `Fixes` commit intentionally includes all current workspace changes, including scene data, prompt/LLM cascade files, and kitchen assets, per user request. - The parser's lower regex cascade correctly does not resolve `rc` while `tv_rc` is hidden and unrevealed; this was confirmed as intended behavior during the session. - Direct semantic content behavior is now narrower by design; any previous tests expecting recursive `LOOK` disclosure were updated to the new contract. + +## Session Entry - 2026-05-17 21:09 +02:00 + +### Session Goals +- Continue from the previous wrap-up without repeating the `Fixes` work. +- Introduce a centralized Actor scene-transfer path that moves a live Actor together with inventory/spatial-owned entities. +- Fix scene travel through `GO`, `Exit`/`Entry`, and script API so player/NPC transfers preserve live objects and inventory state. +- Add controlled Entry placement behavior: default Entry fallback, target camera zoom reset, Entry layer/parallax application. +- Rework scene/object scaling so `Correctional Scale` is an editor scene-normalization tool, while object `Scale` remains portable across scenes. +- Improve text-console cursor/focus behavior in game mode. + +### What Was Implemented +- Added `SceneManager.transferActorToScene(actor, targetSceneId, options?)` as the central transfer API. + - Collects the Actor itself. + - Collects Entity descendants spatially owned by the Actor. + - Collects items stored in the Actor's Inventory components. + - Recursively collects nested descendants of those carried items. + - Moves live object instances between scenes without cloning and without using normal `removeEntity()` cleanup that would clear inventory storage. +- Updated `SceneManager.switchTo(sceneId, activator?)` to delegate Actor movement to the transfer API when an activator is supplied. +- Updated `ComponentSystem.handleExit()` to call `transferActorToScene()` directly with `targetEntryId`. +- Added `ScriptAPI.transferActor(actorName, targetSceneId, targetEntryId?)` for script-side actor movement. +- Fixed semantic `GO ` travel: + - `Game.goToScene()` now passes the current player Actor into `switchTo()`. + - If `currentScene.player` is missing, it falls back to a player Actor in the current scene entities. + - Parser/game integration remains routed through this semantic path. +- Added Entry fallback for scene transfer: + - If cross-scene transfer has no explicit `targetEntryId`, the first `Entry` object in the target scene is used. + - The lookup uses `scene.getAllSceneObjects()`, so it sees `Triggerbox` Entries such as the one in `quad4`. +- Entry placement now applies only to the Actor: + - Actor coordinates/direction are set from Entry. + - Actor `layer` and `parallax` are copied from the Entry. + - Carried inventory items keep inventory ownership and do not receive Entry coordinates/layer/parallax directly. +- Player cross-scene transfer now resets `targetScene.camera.zoom` to `targetScene.defaultCamera.zoom` before camera snap. +- Target-scene pre-authored player placeholders are removed/replaced by the live transferred player Actor. +- NPC Actor transfers move the NPC and its inventory contents without making the NPC `scene.player`. +- Same-scene teleport uses the same transfer API but skips detach/add and only applies Entry placement. + +### Scaling And Editor Changes +- Added `Scene.scaling.correctionalScale` with default `1`. +- Added internal `Entity.refScale` serialization as the stored reference/prefab scale. + - The editor-facing field remains the normal `Scale` field. + - Legacy objects without `refScale` recover it from `modelScale` or `scale`. +- Rejected the intermediate idea of applying target-scene `Correctional Scale` to incoming Actors/items. + - Incoming objects now keep their portable object `Scale`. + - `Correctional Scale` is editor-only scene normalization, not transfer-time object scaling. +- Added `Scene.applyCorrectionalScaleChange(nextScale)`: + - Computes a correction ratio from old to new scale. + - Scales all scene objects around a shared scene center. + - Updates absolute coordinates for entities. + - Updates polygons for Walkboxes/Triggerboxes. + - Updates Quad vertices. + - Updates existing Entity stored scale values so the authored scene itself is normalized. + - Explicitly includes locked objects; locked entities/triggers must not remain behind when the scene is normalized. +- Updated Scene Properties UI: + - Section `2. Scaling` is split into `Depth Scaling` and `Correction`. + - Added `Correctional Scale` field under `Correction`. + - Tooltip explains that it scales all scene objects, including locked ones, around the shared scene center. +- Updated Entity Properties UI: + - Returned to one editor-visible `Scale` field. + - The field edits `refScale` internally while preserving the old UI concept. + +### Text Console / Input Changes +- Improved text console cursor behavior. +- Added Ctrl+Left / Ctrl+Right command-line navigation. +- Added protection against losing command-line focus in game mode. +- The latest related commits are separate from the scene-transfer commit. + +### Important Architecture / Runtime Decisions +- Actor scene movement must use `SceneManager.transferActorToScene()` rather than raw `oldScene.removeEntity(actor)` / `targetScene.addEntity(actor)`. +- Direct scene removal is unsafe for carried objects because normal entity removal clears inventory/storage ownership. +- Inventory contents are live scene entities and should travel with their owning Actor. +- Entry is the authoritative authored portal for Actor coordinates, direction, layer, and parallax. +- Target-scene camera zoom should come from target scene defaults when the player enters a different scene. +- `Correctional Scale` is not a runtime per-object multiplier for incoming objects. +- Object `Scale` remains portable; scene normalization should mutate the authored scene layout, not objects entering that scene. + +### Parser / Mechanics / Scene / Inventory Changes +- Parser `GO` scene changes now preserve the live player Actor and its inventory. +- `Exit`/`Entry`, semantic `GO`, and script transfer all share the same central Actor-transfer path. +- Inventory-owned items remain hidden and spatially owned by the Actor after transfer. +- Nested carried descendants transfer with their carried parent. +- `InventoryManager.handleSceneChange()` runs after final scene state is established. +- Parser static prompt preparation, scene exposure, and scene-change hooks remain part of scene activation. + +### Tests Run And Outcomes +- Focused scene transfer and scale tests: + - `npm test -- tests/entities/entity-ref-scale.test.ts tests/game/navigation-and-spatial.test.ts tests/scene/scene-transition.test.ts -- --runInBand` + - Passed. +- Parser/game integration checks: + - `npm test -- tests/integration/parser-game.test.ts -- --runInBand` + - Passed. +- Semantic API checks: + - `npm test -- tests/game/semantic-api.test.ts -- --runInBand` + - Passed. +- Combined focused suites after scale/correction work: + - `npm test -- tests/scene/scene-correctional-scale.test.ts tests/entities/entity-ref-scale.test.ts tests/game/navigation-and-spatial.test.ts tests/scene/scene-transition.test.ts tests/game/semantic-api.test.ts tests/integration/parser-game.test.ts -- --runInBand` + - Passed, 188 tests. +- Full suite: + - `npm test` + - Passed, 29 files / 354 tests. +- TypeScript: + - `npm run typecheck` + - Passed. +- Whitespace/diff check: + - `git diff --check` + - Passed with only CRLF warnings. + +### Commits Created +- `758e5ce` - `Feature: Centralized Actor Scene Transfer API` + - Central Actor transfer API, GO/Exit/script transfer integration, Entry fallback, camera zoom reset, Entry parallax/layer, Scale/Correctional Scale model, scene correction tests, docs, and scene/text additions including `quad5`. +- `0e546e5` - `Fixed and improved cursor in text console. Added Ctrl+ left/right arrows for navigation` + - Console cursor improvements, Ctrl+arrow movement, related game/UI plumbing. +- `1443b87` - `Protection against losing command line focus in Game Mode` + - Focus protection around game canvas/UI overlay so the command line does not lose focus unexpectedly. + +### Remaining Work / Next Recommended Steps +- Manually verify in the editor: + - `GO quad4` places the transferred player on the target `Triggerbox` Entry. + - The transferred player keeps inventory contents. + - The transferred player inherits Entry `Layer` and `Parallax`. + - Target scene zoom resets to the default camera zoom. + - Changing `Correctional Scale` moves locked and unlocked entities/triggers together. + - Existing neighboring objects remain adjacent after scene correction. +- If scene scaling normalization is used heavily, consider adding an editor command name/history label for correction-scale changes so undo history reads more clearly. +- Consider a small UI hint that `Correctional Scale` is a destructive authored-layout normalization, not a temporary runtime multiplier. + +### Risks / Caveats / Open Questions +- The current working tree is clean at wrap-up time. +- The previous memory decision that described transfer-time object correction was superseded by the later decision: `Correctional Scale` is editor-only scene normalization. +- Scene correction intentionally affects locked objects. This differs from normal transform editing, where locked objects are protected from accidental manual manipulation. +- `Correctional Scale` mutates authored object positions/polygons and stored scale values; use editor undo or source control when experimenting. diff --git a/public/scenes/quad5.json b/public/scenes/quad5.json index 3c2a823..3384a5e 100644 --- a/public/scenes/quad5.json +++ b/public/scenes/quad5.json @@ -155,15 +155,15 @@ "parallax": 0.65, "x": 288, "y": 97, - "width": 514.8508758172302, - "height": 370.4537498401633, + "width": 506.4428571428572, + "height": 364.40387755102046, "baseWidth": 1170, "baseHeight": 841.8571428571429, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "wall-window.json", "color": "#AAAAAA", - "scale": 0.4400434836044702, + "scale": 0.4328571428571429, "refScale": 0.2, "modelScale": 0.54, "ignoreScaling": false, @@ -188,15 +188,15 @@ "parallax": 0.65, "x": -114, "y": 97, - "width": 514.8508758172302, - "height": 370.4537498401633, + "width": 506.4428571428572, + "height": 364.40387755102046, "baseWidth": 1170, "baseHeight": 841.8571428571429, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "wall-window.json", "color": "#AAAAAA", - "scale": 0.4400434836044702, + "scale": 0.4328571428571429, "refScale": 0.2, "modelScale": 0.54, "ignoreScaling": false, @@ -435,15 +435,15 @@ "parallax": 0.7, "x": 232, "y": 84, - "width": 165.04577848727962, - "height": 41.96784805123664, + "width": 162.6062298950786, + "height": 41.34751952439104, "baseWidth": 79.20971532815241, "baseHeight": 20.141450011881506, "colliderWidth": 0, "colliderHeight": 0, "spriteName": null, "color": "#4a1215", - "scale": 2.0836557460599745, + "scale": 2.052857142857143, "refScale": 1, "modelScale": 2.7, "ignoreScaling": false, @@ -1706,15 +1706,15 @@ "parallax": 0.75, "x": 121, "y": 84, - "width": 98.52816503134171, - "height": 19.50178781054212, + "width": 97.31154375082349, + "height": 19.26098062560257, "baseWidth": 47.402978862614084, "baseHeight": 9.382523617203757, "colliderWidth": 0, "colliderHeight": 0, "spriteName": null, "color": "#1d4853", - "scale": 2.0785226455261694, + "scale": 2.052857142857143, "refScale": 1, "modelScale": 2.7, "ignoreScaling": false, @@ -1778,14 +1778,14 @@ "name": "shadow", "type": "Quad", "locked": true, - "disabled": false, + "disabled": true, "groupID": null, "customName": "", "textRedirects": {}, "interactions": {}, "components": [], "layer": 1, - "visible": true, + "visible": false, "hidden": false, "parallax": 1, "x": 121.75, @@ -1841,15 +1841,15 @@ "parallax": 0.648, "x": -252, "y": 109, - "width": 199.0033820554331, - "height": 207.29518964107615, + "width": 195.88114285714286, + "height": 204.04285714285714, "baseWidth": 432, "baseHeight": 450, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "office-plant", "color": "#AAAAAA", - "scale": 0.4606559769801692, + "scale": 0.45342857142857146, "refScale": 0.2, "modelScale": 0.54, "ignoreScaling": false, @@ -2401,7 +2401,7 @@ "blur": 0 }, { - "name": "miles_ds", + "name": "Hero_1", "type": "Actor", "locked": false, "disabled": false, @@ -2412,33 +2412,48 @@ "components": [ { "type": "Actor" + }, + { + "type": "Shadow", + "shadowQuadId": "shadow", + "offsetX": -70, + "offsetY": -25, + "triggerId": "#f" + }, + { + "type": "Inventory", + "capacity": 9007199254740991, + "groups": [], + "protected": false, + "items": ["test"], + "relation": "in" } ], "layer": 1, "visible": true, "hidden": false, - "parallax": 0.8222312322527784, - "x": -59.18365332215403, - "y": 182.25693775080063, - "width": 63.072, - "height": 257.544, + "parallax": 0.7727213747436679, + "x": -928.8248173132807, + "y": 159.65664684322715, + "width": 71.03999999999999, + "height": 290.08, "baseWidth": 96, "baseHeight": 392, - "colliderWidth": 67, - "colliderHeight": 9, + "colliderWidth": 88, + "colliderHeight": 4, "spriteName": "miles_ds-idle-right.json", "color": "#00ffff", - "scale": 0.657, - "refScale": 0.73, - "modelScale": 0.73, - "ignoreScaling": false, + "scale": 0.74, + "refScale": 0.74, + "modelScale": 0.74, + "ignoreScaling": true, "animationSpeed": 30, "opacity": 1, "blendMode": "source-over", "blur": 0, "isPlayer": true, "speed": 0.24, - "direction": "left", + "direction": "right", "animSets": { "idle": { "id": "idle", @@ -2455,15 +2470,56 @@ "right": "miles_ds-walk-right.json" } } + }, + { + "name": "test", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": "#compact_cassete", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Item" + } + ], + "layer": 4, + "visible": false, + "hidden": false, + "spatial": { + "parentNodeId": "Hero_1", + "relation": "in" + }, + "parallax": 1, + "x": -928.8248173132807, + "y": 159.65664684322715, + "width": 14.183901773533426, + "height": 20.156070941336974, + "baseWidth": 19.69986357435198, + "baseHeight": 27.994542974079128, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#AAAAAA", + "scale": 0.7200000000000001, + "refScale": 0.8, + "modelScale": 0.8, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 } ], "folders": [], "camera": { - "x": 0, + "x": -927.0552275306312, "y": 0, - "zoom": 1 + "zoom": 0.33832234175059017 }, - "autoCenter": false, + "autoCenter": true, "cameraSpeed": 5, "camDeadzoneX": 50, "camDeadzoneY": 30, diff --git a/public/scenes/test_room.json b/public/scenes/test_room.json index 5f9ea1a..0fe49a4 100644 --- a/public/scenes/test_room.json +++ b/public/scenes/test_room.json @@ -751,6 +751,41 @@ "parallax": 1, "poly": [], "script": "" + }, + { + "name": "Entry", + "type": "Triggerbox", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Entry", + "direction": "down" + } + ], + "layer": 0, + "visible": true, + "hidden": false, + "parallax": 1, + "poly": [ + { + "x": 1341, + "y": 220 + }, + { + "x": 1303, + "y": 243 + }, + { + "x": 1366, + "y": 253 + } + ], + "script": "" } ], "scaling": { @@ -758,7 +793,8 @@ "min": 0.91, "max": 1, "horizon": 193, - "front": 269 + "front": 269, + "correctionalScale": 1 }, "entities": [ { @@ -799,6 +835,7 @@ "spriteName": "window-view.json", "color": "#00ff00", "scale": 0.65, + "refScale": 0.65, "modelScale": 0.65, "ignoreScaling": true, "animationSpeed": 150, @@ -840,6 +877,7 @@ "spriteName": "room2", "color": "#888888", "scale": 0.7, + "refScale": 0.7, "modelScale": 0.7, "ignoreScaling": true, "animationSpeed": 150, @@ -880,6 +918,7 @@ "spriteName": "chair.json", "color": "#00ff00", "scale": 0.7, + "refScale": 0.7, "modelScale": 0.7, "ignoreScaling": true, "animationSpeed": 150, @@ -920,8 +959,8 @@ "visible": true, "hidden": false, "parallax": 1.040913443385125, - "x": 653.2461823654978, - "y": 248.03136198471273, + "x": 653.2461823654976, + "y": 248.0313619847128, "width": 119.88, "height": 289.34, "baseWidth": 162, @@ -931,6 +970,7 @@ "spriteName": "miles_ds-idle-down.json", "color": "#00ffff", "scale": 0.74, + "refScale": 0.74, "modelScale": 0.74, "ignoreScaling": true, "animationSpeed": 30, @@ -991,6 +1031,7 @@ "spriteName": null, "color": "#000000", "scale": 1.1, + "refScale": 1.1, "modelScale": 1.1, "ignoreScaling": false, "animationSpeed": 150, @@ -1027,6 +1068,7 @@ "spriteName": "sub_drawers_main", "color": "#00ff00", "scale": 0.6, + "refScale": 0.6, "modelScale": 0.6, "ignoreScaling": true, "animationSpeed": 150, @@ -1068,6 +1110,7 @@ "spriteName": "sub_drawers_d2.json", "color": "#00ff00", "scale": 0.6, + "refScale": 0.6, "modelScale": 0.6, "ignoreScaling": true, "animationSpeed": 150, @@ -1104,6 +1147,7 @@ "spriteName": "sub_drawers_d1_body.json", "color": "#00ff00", "scale": 0.6, + "refScale": 0.6, "modelScale": 0.6, "ignoreScaling": true, "animationSpeed": 150, @@ -1140,6 +1184,7 @@ "spriteName": "sub_drawers_d1_items.json", "color": "#00ff00", "scale": 1, + "refScale": 1, "modelScale": 1, "ignoreScaling": true, "animationSpeed": 150, @@ -1185,6 +1230,7 @@ "spriteName": "sub_drawers_top.json", "color": "#00ff00", "scale": 0.6, + "refScale": 0.6, "modelScale": 0.6, "ignoreScaling": true, "animationSpeed": 150, @@ -1226,6 +1272,7 @@ "spriteName": "sub_drawers_d1_facade.json", "color": "#00ff00", "scale": 0.6, + "refScale": 0.6, "modelScale": 0.6, "ignoreScaling": true, "animationSpeed": 150, @@ -1314,8 +1361,8 @@ "visible": true, "hidden": false, "parallax": 1.0791811402100033, - "x": 223.04286339314416, - "y": 306.93895592340505, + "x": 223.04286339314422, + "y": 306.938955923405, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -1325,6 +1372,7 @@ "spriteName": "sofa", "color": "#36d87fff", "scale": 0.8, + "refScale": 0.8, "modelScale": 0.8, "ignoreScaling": false, "animationSpeed": 150, @@ -1370,6 +1418,7 @@ "spriteName": null, "color": "#AAAAAA", "scale": 0.91, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1406,6 +1455,7 @@ "spriteName": null, "color": "#AAAAAA", "scale": 0.91, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1447,6 +1497,7 @@ "spriteName": null, "color": "#AAAAAA", "scale": 0.91, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1468,29 +1519,29 @@ "visible": true, "hidden": false, "parallax": 1, - "x": 593.3381062258995, - "y": 228.9711610286541, + "x": 593.1052025036854, + "y": 228.9545808884336, "ignoreScaling": false, "vertices": [ { - "x": 593.3381062258995, - "y": 228.9711610286541, - "p": 1.0284645332504887 + "x": 593.1052025036854, + "y": 228.9545808884336, + "p": 1.0283679059375372 }, { - "x": 688.715320820934, - "y": 227.8849772543808, - "p": 1.0277805283906434 + "x": 688.2347726051288, + "y": 227.8764937219134, + "p": 1.0277624950709003 }, { - "x": 694.6427272905712, - "y": 260.45726606077204, - "p": 1.0490019709916065 + "x": 703.4430391453096, + "y": 260.3080588874047, + "p": 1.0489484925749581 }, { - "x": 643.0812897084056, - "y": 259.00057703450625, - "p": 1.048148562464707 + "x": 651.4624110015549, + "y": 258.8538504273255, + "p": 1.0480273728177847 } ], "color": "#2b019d", @@ -1539,6 +1590,7 @@ "spriteName": "sub_drawers_d1_id.json", "color": "#AAAAAA", "scale": 0.1183, + "refScale": 0.13, "modelScale": 0.13, "ignoreScaling": false, "animationSpeed": 150, @@ -1579,6 +1631,7 @@ "spriteName": null, "color": "#5e184f", "scale": 0.7478947368421053, + "refScale": 0.8, "modelScale": 0.8, "ignoreScaling": false, "animationSpeed": 150, @@ -1627,6 +1680,7 @@ "spriteName": null, "color": "#d66e29", "scale": 0.91, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1675,6 +1729,7 @@ "spriteName": null, "color": "#d0cb39", "scale": 0.91, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1707,6 +1762,7 @@ "spriteName": null, "color": "#AAAAAA", "scale": 0.91, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1739,6 +1795,7 @@ "spriteName": null, "color": "#AAAAAA", "scale": 0.91, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1771,6 +1828,7 @@ "spriteName": null, "color": "#AAAAAA", "scale": 0.91, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1803,6 +1861,7 @@ "spriteName": null, "color": "#AAAAAA", "scale": 0.91, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1839,6 +1898,7 @@ "spriteName": null, "color": "#4c2e94", "scale": 1, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1875,6 +1935,7 @@ "spriteName": null, "color": "#4c2e94", "scale": 1, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1915,6 +1976,7 @@ "spriteName": "tv_rc", "color": "#AAAAAA", "scale": 0.13, + "refScale": 0.13, "modelScale": 0.13, "ignoreScaling": false, "animationSpeed": 150, @@ -1947,6 +2009,7 @@ "spriteName": "kitchen", "color": "#AAAAAA", "scale": 1, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -1979,6 +2042,7 @@ "spriteName": "kitchen_table", "color": "#AAAAAA", "scale": 1, + "refScale": 1, "modelScale": 1, "ignoreScaling": false, "animationSpeed": 150, @@ -2008,8 +2072,8 @@ "relation": "in" }, "parallax": 1, - "x": 653.2461823654978, - "y": 248.03136198471273, + "x": 653.2461823654976, + "y": 248.0313619847128, "width": 15.368552567463674, "height": 21.83952206955364, "baseWidth": 19.69986357435198, @@ -2019,6 +2083,7 @@ "spriteName": null, "color": "#AAAAAA", "scale": 0.7801349745118332, + "refScale": 0.8, "modelScale": 0.8, "ignoreScaling": false, "animationSpeed": 150, diff --git a/src/components/editor/properties/SceneProperties.tsx b/src/components/editor/properties/SceneProperties.tsx index 1e46161..e981a7c 100644 --- a/src/components/editor/properties/SceneProperties.tsx +++ b/src/components/editor/properties/SceneProperties.tsx @@ -7,6 +7,63 @@ import { type PanningModelType, } from '../../../systems/SoundManager'; +type NumberDraftInputProps = { + value: number; + step?: string; + min?: string; + max?: string; + className?: string; + formatPanelNumber: (value: unknown) => number | string; + onCommit: (value: number) => void; +}; + +const NumberDraftInput: React.FC = ({ + value, + step, + min, + max, + className, + formatPanelNumber, + onCommit, +}) => { + const [draft, setDraft] = React.useState(String(formatPanelNumber(value))); + const [focused, setFocused] = React.useState(false); + + React.useEffect(() => { + if (!focused) { + setDraft(String(formatPanelNumber(value))); + } + }, [focused, formatPanelNumber, value]); + + return ( + { + setFocused(true); + setDraft(String(formatPanelNumber(value))); + }} + onChange={(e) => { + const raw = e.target.value; + setDraft(raw); + if (raw === '' || raw === '-' || raw === '.' || raw === '-.') return; + const next = Number(raw); + if (Number.isFinite(next)) { + onCommit(next); + } + }} + onBlur={() => { + setFocused(false); + setDraft(String(formatPanelNumber(value))); + }} + /> + ); +}; + export const SceneProperties: React.FC = () => { const { game, obj, formatPanelNumber, setSectionRef, incrementObjectVersion, handleChange } = usePropertiesContext(); @@ -221,13 +278,13 @@ export const SceneProperties: React.FC = () => {
- { - scene.defaultCamera.zoom = parseFloat(e.target.value); + value={scene.defaultCamera.zoom} + formatPanelNumber={formatPanelNumber} + onCommit={(value) => { + scene.defaultCamera.zoom = value; incrementObjectVersion(); }} /> diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index 77fa421..396e7b2 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -229,10 +229,11 @@ export class SceneManager { } if (targetX === null || targetY === null) return entryObj; - actor.x = targetX; - actor.y = targetY; actor.layer = entryObj.layer; actor.parallax = entryObj.parallax; + const walkableTarget = this.findNearestWalkableEntryPosition(scene, actor, targetX, targetY); + actor.x = walkableTarget.x; + actor.y = walkableTarget.y; if (entryComp.direction && typeof (actor as any).setDirection === 'function') { (actor as any).setDirection(entryComp.direction); } @@ -240,6 +241,45 @@ export class SceneManager { return entryObj; } + private findNearestWalkableEntryPosition( + scene: Scene, + actor: Actor, + targetX: number, + targetY: number + ): { x: number; y: number } { + if (scene.isWalkable(targetX, targetY, actor)) { + return { x: targetX, y: targetY }; + } + + const step = 4; + const maxRadius = Math.max(128, actor.colliderWidth * 2, actor.colliderHeight * 8); + let best: { x: number; y: number; distanceSq: number } | null = null; + + for (let radius = step; radius <= maxRadius; radius += step) { + for (let dx = -radius; dx <= radius; dx += step) { + for (let dy = -radius; dy <= radius; dy += step) { + if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue; + const x = targetX + dx; + const y = targetY + dy; + if (!scene.isWalkable(x, y, actor)) continue; + const distanceSq = dx * dx + dy * dy; + if (!best || distanceSq < best.distanceSq) { + best = { x, y, distanceSq }; + } + } + } + const nearest = best; + if (nearest !== null) { + return { x: nearest.x, y: nearest.y }; + } + } + + console.warn( + `[SceneManager] Entry placement for ${actor.name} at ${targetX},${targetY} is not walkable.` + ); + return { x: targetX, y: targetY }; + } + private finalizeSceneActivation(oldScene: Scene | null, scene: Scene): void { this.currentScene = scene; SoundManager.getInstance().setEnvironment(scene.soundEnv); @@ -272,6 +312,8 @@ export class SceneManager { const removeExistingPlayer = options.removeExistingPlayer ?? !!(actor as any).isPlayer; const setAsScenePlayer = options.setAsScenePlayer ?? !!(actor as any).isPlayer; + const transfersPlayerActor = + setAsScenePlayer || !!(actor as any).isPlayer || sourceScene?.player === actor; const preserveSpatialChildren = options.preserveSpatialChildren ?? true; const activateScene = options.activateScene ?? setAsScenePlayer; const transferEntities = preserveSpatialChildren @@ -304,7 +346,7 @@ export class SceneManager { options.targetEntryId ?? (sourceScene !== targetScene ? this.findFirstEntryId(targetScene) : null); this.applyEntryPlacement(targetScene, actor, targetEntryId); - if (setAsScenePlayer && sourceScene !== targetScene && targetScene.defaultCamera) { + if (transfersPlayerActor && sourceScene !== targetScene && targetScene.defaultCamera) { targetScene.camera.zoom = targetScene.defaultCamera.zoom; } if (setAsScenePlayer && targetScene.autoCenter) { diff --git a/tests/fixtures/gameFactory.ts b/tests/fixtures/gameFactory.ts index a9e0769..435cb8a 100644 --- a/tests/fixtures/gameFactory.ts +++ b/tests/fixtures/gameFactory.ts @@ -96,6 +96,8 @@ export function createTestGame(): TestGameHarness { const oldScene = this.currentScene; const removeExistingPlayer = options.removeExistingPlayer ?? !!actor.isPlayer; const setAsScenePlayer = options.setAsScenePlayer ?? !!actor.isPlayer; + const transfersPlayerActor = + setAsScenePlayer || !!actor.isPlayer || oldScene?.player === actor; const activateScene = options.activateScene ?? setAsScenePlayer; const entities = options.preserveSpatialChildren === false @@ -157,13 +159,35 @@ export function createTestGame(): TestGameHarness { if (entryComp) { actor.layer = entryObj.layer; actor.parallax = entryObj.parallax; + if (!targetScene.isWalkable(actor.x, actor.y, actor)) { + const startX = actor.x; + const startY = actor.y; + const step = 4; + const maxRadius = Math.max(128, actor.colliderWidth * 2, actor.colliderHeight * 8); + let placed = false; + for (let radius = step; radius <= maxRadius && !placed; radius += step) { + for (let dx = -radius; dx <= radius && !placed; dx += step) { + for (let dy = -radius; dy <= radius; dy += step) { + if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue; + const x = startX + dx; + const y = startY + dy; + if (targetScene.isWalkable(x, y, actor)) { + actor.x = x; + actor.y = y; + placed = true; + break; + } + } + } + } + } if (entryComp.direction && typeof (actor as any).setDirection === 'function') { (actor as any).setDirection(entryComp.direction); } actor.update?.(0); } } - if (setAsScenePlayer && oldScene !== targetScene && targetScene.defaultCamera) { + if (transfersPlayerActor && oldScene !== targetScene && targetScene.defaultCamera) { targetScene.camera.zoom = targetScene.defaultCamera.zoom; } if (activateScene) { diff --git a/tests/game/navigation-and-spatial.test.ts b/tests/game/navigation-and-spatial.test.ts index 1d2f9c2..81f4778 100644 --- a/tests/game/navigation-and-spatial.test.ts +++ b/tests/game/navigation-and-spatial.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { createGameSemanticFixture } from '../fixtures/gameSemanticFactory'; import { Actor } from '../../src/entities/Actor'; import { Entity } from '../../src/entities/Entity'; +import { Triggerbox } from '../../src/entities/Triggerbox'; +import { Walkbox } from '../../src/entities/Walkbox'; import { ComponentSystem } from '../../src/systems/ComponentSystem'; describe('Game navigation and spatial API', () => { @@ -408,6 +410,55 @@ describe('Game navigation and spatial API', () => { expect((label as any).spatial).toEqual({ parentNodeId: 'cassette', relation: 'on' }); }); + it('resets target camera zoom when transferring the current scene player actor', () => { + const fixture = createGameSemanticFixture('start'); + const player = new Actor(fixture.game as any, 0, 0, 10, 10, 'Hero'); + fixture.scene.addEntity(player); + fixture.scene.player = player; + const target = fixture.addScene('zoom_target', 'Zoom Target', 'A room.'); + target.defaultCamera = { x: 10, y: 20, zoom: 0.55 }; + target.camera = { x: 99, y: 88, zoom: 2.5 }; + + fixture.game.sceneManager.transferActorToScene(player, target.id); + + expect(target.camera.zoom).toBe(0.55); + }); + + it('nudges Entry placement to the nearest walkable actor-collider position', () => { + const fixture = createGameSemanticFixture('start'); + const player = fixture.addPlayer('Hero', 0, 0); + player.colliderWidth = 88; + player.colliderHeight = 4; + const target = fixture.addScene('edge_entry_target', 'Edge Entry Target', 'A room.'); + const walkbox = new Walkbox( + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ], + 'Walk_main' + ); + walkbox.mode = 'Add'; + target.addWalkbox(walkbox); + const entry = new Triggerbox( + [ + { x: 90, y: 50 }, + { x: 96, y: 54 }, + { x: 96, y: 46 }, + ], + 'Entry', + '' + ); + entry.components = [{ type: 'Entry', direction: 'left' }]; + target.addTriggerbox(entry); + + fixture.game.sceneManager.transferActorToScene(player, target.id); + + expect(player.x).toBeLessThan(90); + expect(target.isWalkable(player.x, player.y, player)).toBe(true); + }); + it('same-scene actor transfer applies Entry placement without detaching inventory children', () => { const fixture = createGameSemanticFixture('start'); const player = fixture.addPlayer('Hero', 0, 0); From e51cdbea5859ee20773d588d2c919819d93856cf Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Tue, 19 May 2026 12:57:08 +0200 Subject: [PATCH 11/12] Fixed broken mouse text selection in opened console --- src/components/ConsoleOverlay.tsx | 16 +++++++++++++++- src/components/UIOverlay.tsx | 12 +++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/components/ConsoleOverlay.tsx b/src/components/ConsoleOverlay.tsx index 8757966..d507db9 100644 --- a/src/components/ConsoleOverlay.tsx +++ b/src/components/ConsoleOverlay.tsx @@ -100,12 +100,24 @@ export const ConsoleOverlay: React.FC = ({ game }) => { boxSizing: 'border-box', overflow: 'hidden', pointerEvents: 'auto', // Allow scrolling + userSelect: 'text', + WebkitUserSelect: 'text', }} >
{ + event.stopPropagation(); + }} > {lines.map((line, i) => (
= ({ game }) => { color: line.type === 'command' ? '#aaa' : line.type === 'error' ? '#f55' : '#fff', whiteSpace: 'pre-wrap', overflowWrap: 'break-word', + userSelect: 'text', + WebkitUserSelect: 'text', }} > {line.text} diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index 3e2e2b0..6727646 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -30,6 +30,7 @@ export const UIOverlay: React.FC = ({ game }) => { // Console History State const [historyIndex, setHistoryIndex] = useState(-1); const [, forceInventoryRefresh] = useState(0); + const suppressCommandFocusUntilRef = React.useRef(0); // Editor Store State const { enabled: editorEnabled } = useEditorStore(); @@ -113,14 +114,23 @@ export const UIOverlay: React.FC = ({ game }) => { ); }; + const isConsoleLogSelectionTarget = (target: EventTarget | null) => { + return target instanceof Element && !!target.closest('.console-scroll'); + }; + const restoreCommandFocus = () => { + if (Date.now() < suppressCommandFocusUntilRef.current) return; if (!shouldLockCommandFocus()) return; const caret = input.selectionStart ?? input.value.length; input.focus({ preventScroll: true }); input.setSelectionRange(caret, caret); }; - const scheduleRestoreCommandFocus = () => { + const scheduleRestoreCommandFocus = (event?: Event) => { + if (isConsoleLogSelectionTarget(event?.target || null)) { + suppressCommandFocusUntilRef.current = Date.now() + 1000; + return; + } window.setTimeout(restoreCommandFocus, 0); }; From d305f4bbe1392dea64778953412e522b11530fcb Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Tue, 19 May 2026 13:28:59 +0200 Subject: [PATCH 12/12] AI config upd --- AGENTS.md | 1 + .../text/system/commands/teleport_with.json | 2 +- src/core/TextAssetManager.ts | 2 +- tests/fixtures/textAssetFactory.ts | 2 +- tests/parser/commands.test.ts | 26 +++++++++++++++++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1723bcc..37c2667 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,6 +228,7 @@ Gemini rules: - Keep edits scoped to the requested behavior and related contracts. - Update `GDD.md` if gameplay/design behavior changes. - Use structured APIs/parsers where available instead of ad hoc string manipulation. +- For parser command/debugging issues, use the running app when practical: enable `#PEEK-ON`, reproduce the command, and inspect `CONTEXT`, `SCOPE`, `ENVELOPE`, `CORE`, and `RESULT` before guessing. - For runtime/scene/gameplay bugs, prefer diagnostic helpers or temporary probes that explain engine decisions, such as why `isWalkable` returned false, which object blocked a path, or which semantic rule selected a parser target. - Do not revert user changes. Work with dirty files unless the user explicitly asks to revert them. diff --git a/public/text/system/commands/teleport_with.json b/public/text/system/commands/teleport_with.json index 2aa69a5..726ba11 100644 --- a/public/text/system/commands/teleport_with.json +++ b/public/text/system/commands/teleport_with.json @@ -8,7 +8,7 @@ "required": true, "scopes": ["held", "takable"], "validation": { - "allowedTitles": ["your ID card"] + "allowedEntityIds": ["miles_id"] }, "messages": { "missing": "Teleport with what?", diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 0bacb64..5afa61b 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -315,7 +315,7 @@ const DEFAULT_PARSER_COMMANDS: ParserCommandSpec[] = [ noEffect: "That doesn't work.", }, validation: { - allowedTitles: ['your ID card'], + allowedEntityIds: ['miles_id'], }, }, ], diff --git a/tests/fixtures/textAssetFactory.ts b/tests/fixtures/textAssetFactory.ts index 97fa977..5c53b52 100644 --- a/tests/fixtures/textAssetFactory.ts +++ b/tests/fixtures/textAssetFactory.ts @@ -151,7 +151,7 @@ const DEFAULT_PARSER_COMMANDS: ParserCommandSpec[] = [ noEffect: "That doesn't work.", }, validation: { - allowedTitles: ['your ID card'], + allowedEntityIds: ['miles_id'], }, }, ], diff --git a/tests/parser/commands.test.ts b/tests/parser/commands.test.ts index 0b863f7..5cf0662 100644 --- a/tests/parser/commands.test.ts +++ b/tests/parser/commands.test.ts @@ -36,6 +36,32 @@ describe('Parser custom commands', () => { expect(fixture.game.inventory).not.toContain(yourId); }); + it('validates TELEPORT by stable item id instead of display title', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const idCard = fixture.addEntity('miles_id', { + title: 'ID card', + description: 'Your card.', + components: [{ type: 'Item', ignoreDistance: true }], + }); + fixture.scene.removeEntity(idCard); + fixture.game.inventory.push(idCard); + fixture.game.sceneManager.scenes.set('test1', fixture.scene); + fixture.game.sceneManager.sceneRegistry.set('test1', { + id: 'test1', + path: 'test1.json', + name: 'Test Destination', + title: 'Test Destination', + sourceData: null, + lastIndexed: Date.now(), + }); + + const result = await fixture.run('teleport with id card'); + + expect(result.messages.at(-1)).toBe('You vanish in a flash and arrive somewhere else.'); + expect(fixture.game.inventory).not.toContain(idCard); + }); + it('rejects TELEPORT with the wrong matching item', async () => { const fixture = createParserFixture(); fixture.addPlayer();