From 3dc5ebae46e0bef16dbb58600596474a522c561b Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Sun, 8 Mar 2026 19:17:15 +0200 Subject: [PATCH 01/75] Feature: add text asset workflow and runtime integration Implemented a first-pass text asset system for scenes and objects with runtime resolution and editor tooling. Added support for text asset files under public/text for scenes and objects, plus a new TextAssetManager responsible for loading assets, resolving title/description fields, applying runtime text redirects, and duplicating/deleting/opening TA files. Updated the scene editor properties panel so Title is read-only and sourced from the linked text asset. Added Create/Open/Sync/Delete TA actions, including backend file operations for creating, opening, reading, and deleting text asset files. Integrated text assets into parser and runtime behavior: - LOOK now resolves scene descriptions from scene TA - LOOK object title resolves objects by TA title and prints TA description - LOOK AROUND / LOOK HERE / LOOK SCENE resolve to the current scene description - clicking visible scene objects with a TA title prints "You see " instead of walking - Subtrigger clicks resolve title from the forwarded target object Adjusted click hit-testing so layer ordering is respected between entities and triggerboxes, and restored correct close-on-click-outside behavior for subscenes while preserving title detection inside subscenes. Also unified command handling between closed and popup console modes so gameplay commands in the expanded console follow the parser path instead of being treated as console-only commands. Included current scene file updates and added TextAssets.md documentation describing the chosen asset model and redirect rules. --- TextAssets.md | 71 ++++++ public/scenes/home/room.json | 94 ++++++-- public/scenes/home/room_backup.json | 45 +++- public/text/objects/boombox.json | 5 + public/text/objects/logo_1.json | 4 + public/text/objects/miles_id.json | 4 + public/text/objects/miles_id_1.json | 4 + public/text/scenes/home/room.json | 4 + public/text/scenes/home/room_backup.json | 4 + public/text/scenes/new_scene.json | 4 + src/components/ConsoleOverlay.tsx | 24 -- src/components/editor/PropertiesPanel.tsx | 177 +++++++++++++-- src/core/Game.ts | 3 + src/core/IGame.ts | 2 + src/core/TextAssetManager.ts | 227 +++++++++++++++++++ src/entities/SceneObject.ts | 3 + src/mechanics/Parser.ts | 19 +- src/scene/Scene.ts | 21 +- src/scene/SceneInteraction.ts | 188 ++++++++++++++- src/scene/SceneManager.ts | 7 +- src/tools/SceneEditor.ts | 3 + src/tools/editor/EditorPersistenceManager.ts | 1 + src/tools/editor/EditorSelectionManager.ts | 13 +- vite.config.ts | 109 +++++++++ 24 files changed, 943 insertions(+), 93 deletions(-) create mode 100644 TextAssets.md create mode 100644 public/text/objects/boombox.json create mode 100644 public/text/objects/logo_1.json create mode 100644 public/text/objects/miles_id.json create mode 100644 public/text/objects/miles_id_1.json create mode 100644 public/text/scenes/home/room.json create mode 100644 public/text/scenes/home/room_backup.json create mode 100644 public/text/scenes/new_scene.json create mode 100644 src/core/TextAssetManager.ts diff --git a/TextAssets.md b/TextAssets.md new file mode 100644 index 0000000..31c7817 --- /dev/null +++ b/TextAssets.md @@ -0,0 +1,71 @@ +# Text Assets + +## V1 decision + +We start with a minimal text asset system for scene and object descriptions. + +Text assets are stored separately from scene and prefab JSON files: + +- `public/text/scenes/<scene-id>.json` +- `public/text/objects/<object-id>.json` + +Since scene/object IDs according to GDD can contain paths like "building\room", which means that the 'room.json scene' is located in the 'building' folder, there may be subfolders inside these folders. + +## Main rules + +- Scene text asset is created automatically when a scene is created or first saved, if it does not exist yet. +- Object text asset is stored independently from scenes and prefabs, because objects may exist outside a scene or move between scenes. +- Missing text asset files are not errors; runtime falls back to existing built-in fields. +- Text assets contain only data, not code. +- Dynamic text changes are controlled by scripts through runtime properties of scenes and objects. + +## Minimal fields + +Scene asset: + +- `title` +- `description` + +Object asset: + +- `title` +- `description` + +## Custom text variants + +Text assets may also contain custom named fields in the same JSON file, for example: + +- `description_morning` +- `description_evening` +- `title_locked` + +These are alternative text values that can be activated at runtime. + +## Runtime redirection + +The redirection table does not live inside text asset JSON files. + +Instead, each scene and object may have a runtime property such as `textRedirects` that remaps standard text fields to custom fields from the same text asset. + +Example: + +```json +{ + "description": "description_evening" +} +``` + +Meaning: + +- when runtime asks for `description`, it should use `description_evening` from the text asset; +- if no redirect is set, the default `description` field is used; +- if redirect points to a missing field, runtime should fall back to the standard field. + +Scripts do not generate text themselves. They only change which named text field is currently active. + +## Runtime integration + +- `title` maps to the user-facing object or scene name. +- `description` maps to the basic text used by parser/runtime for `look` or `look around`. +- Existing runtime fields remain as fallback and for backward compatibility. +- Parser and UI should read only the resolved standard fields, not custom variant names directly. diff --git a/public/scenes/home/room.json b/public/scenes/home/room.json index 7585f5c..086ae22 100644 --- a/public/scenes/home/room.json +++ b/public/scenes/home/room.json @@ -1,6 +1,8 @@ { "id": "home\\room", "name": "Test Room", + "description": "You are in Test Room.", + "textRedirects": {}, "filename": "home/room", "walkbox": [ { @@ -10,6 +12,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -41,6 +44,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -106,6 +110,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -159,6 +164,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -171,7 +177,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -200,6 +206,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -214,6 +221,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -226,7 +234,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -264,6 +272,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -2, @@ -294,6 +303,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -1, @@ -324,6 +334,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -354,23 +365,24 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 249.76314957424822, - "y": 295.45481146309464, - "width": 119.88, - "height": 289.34, - "baseWidth": 162, - "baseHeight": 391, + "x": 241.58710837405346, + "y": 236.58147331007746, + "width": 119.33276583023533, + "height": 278.4431202705491, + "baseWidth": 168, + "baseHeight": 392, "colliderWidth": 88, "colliderHeight": 4, - "spriteName": "miles_ds-idle-down.json", + "spriteName": "miles_ds-idle-up.json", "color": "#00ffff", - "scale": 0.74, + "scale": 0.7103140823228293, "modelScale": 0.74, - "parallax": 1.0715390924595392, + "parallax": 1.033097301991213, "ignoreScaling": false, "animationSpeed": 30, "opacity": 1, @@ -378,7 +390,7 @@ "blur": 0, "isPlayer": true, "speed": 0.24, - "direction": "down", + "direction": "up", "animSets": { "idle": { "id": "idle", @@ -403,6 +415,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -433,9 +446,10 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 3, + "layer": 0, "visible": true, "x": 135, "y": 310, @@ -463,8 +477,14 @@ "disabled": true, "groupID": "#D2", "customName": "", + "textRedirects": {}, "interactions": {}, - "components": [], + "components": [ + { + "type": "Subtrigger", + "target": "sub_sw_d2" + } + ], "layer": 4, "visible": true, "x": 134, @@ -493,9 +513,10 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 5, + "layer": 4, "visible": true, "x": 136, "y": -4, @@ -523,6 +544,7 @@ "disabled": true, "groupID": "#D1", "customName": "your ID card", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -558,6 +580,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 5, @@ -588,6 +611,7 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 6, @@ -618,6 +642,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -653,6 +678,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -706,12 +732,13 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 222.90433731748038, - "y": 306.92845155295413, + "x": 222.90433731748033, + "y": 306.928451552954, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -722,7 +749,7 @@ "color": "#36d87fff", "scale": 0.8, "modelScale": 0.8, - "parallax": 1.079038203629382, + "parallax": 1.0790382036293817, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -732,6 +759,37 @@ "speed": 0.1, "direction": "down", "animSets": {} + }, + { + "name": "boombox", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": -152, + "y": -11, + "width": 112, + "height": 47, + "baseWidth": 123.07692307692307, + "baseHeight": 51.64835164835165, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#AAAAAA", + "scale": 0.91, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 0, + "blendMode": "source-over", + "blur": 0 } ], "camera": { diff --git a/public/scenes/home/room_backup.json b/public/scenes/home/room_backup.json index 7b52df5..a37a3dc 100644 --- a/public/scenes/home/room_backup.json +++ b/public/scenes/home/room_backup.json @@ -1,6 +1,8 @@ { "id": "home\\room_backup", "name": "Test Room", + "description": "You are in Test Room.", + "textRedirects": {}, "filename": "home/room_backup", "walkbox": [ { @@ -10,6 +12,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -41,6 +44,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -106,6 +110,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -159,6 +164,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -171,7 +177,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -200,6 +206,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -214,6 +221,7 @@ "disabled": true, "groupID": "#D ", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -226,7 +234,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 0, + "layer": 1, "visible": true, "poly": [ { @@ -264,6 +272,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -2, @@ -294,6 +303,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -1, @@ -324,6 +334,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -354,12 +365,13 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, "x": 249.76314957424822, - "y": 295.45481146309464, + "y": 295.45481146309453, "width": 119.88, "height": 289.34, "baseWidth": 162, @@ -403,6 +415,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -433,9 +446,10 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 3, + "layer": 0, "visible": true, "x": 135, "y": 310, @@ -463,8 +477,14 @@ "disabled": true, "groupID": "#D2", "customName": "", + "textRedirects": {}, "interactions": {}, - "components": [], + "components": [ + { + "type": "Subtrigger", + "target": "sub_sw_d2" + } + ], "layer": 4, "visible": true, "x": 134, @@ -493,9 +513,10 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], - "layer": 5, + "layer": 4, "visible": true, "x": 136, "y": -4, @@ -523,6 +544,7 @@ "disabled": true, "groupID": "#D1", "customName": "your ID card", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -558,6 +580,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 5, @@ -588,6 +611,7 @@ "disabled": true, "groupID": "#D", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 6, @@ -618,6 +642,7 @@ "disabled": true, "groupID": "#D1", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -653,6 +678,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -706,12 +732,13 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 222.90433731748038, - "y": 306.92845155295413, + "x": 222.90433731748033, + "y": 306.92845155295396, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -722,7 +749,7 @@ "color": "#36d87fff", "scale": 0.8, "modelScale": 0.8, - "parallax": 1.079038203629382, + "parallax": 1.0790382036293817, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, diff --git a/public/text/objects/boombox.json b/public/text/objects/boombox.json new file mode 100644 index 0000000..a405403 --- /dev/null +++ b/public/text/objects/boombox.json @@ -0,0 +1,5 @@ +{ + "title": "Boombox", + "description": "An old cheap tape recorder with a radio.", + "details": "A very basic tape recorder is connected to the computer. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape." +} diff --git a/public/text/objects/logo_1.json b/public/text/objects/logo_1.json new file mode 100644 index 0000000..8697379 --- /dev/null +++ b/public/text/objects/logo_1.json @@ -0,0 +1,4 @@ +{ + "title": "logo", + "description": "You see nothing special." +} diff --git a/public/text/objects/miles_id.json b/public/text/objects/miles_id.json new file mode 100644 index 0000000..442b305 --- /dev/null +++ b/public/text/objects/miles_id.json @@ -0,0 +1,4 @@ +{ + "title": "your ID card", + "description": "You see nothing special." +} diff --git a/public/text/objects/miles_id_1.json b/public/text/objects/miles_id_1.json new file mode 100644 index 0000000..442b305 --- /dev/null +++ b/public/text/objects/miles_id_1.json @@ -0,0 +1,4 @@ +{ + "title": "your ID card", + "description": "You see nothing special." +} diff --git a/public/text/scenes/home/room.json b/public/text/scenes/home/room.json new file mode 100644 index 0000000..0343556 --- /dev/null +++ b/public/text/scenes/home/room.json @@ -0,0 +1,4 @@ +{ + "title": "Test Room", + "description": "You are in Test Room." +} diff --git a/public/text/scenes/home/room_backup.json b/public/text/scenes/home/room_backup.json new file mode 100644 index 0000000..0343556 --- /dev/null +++ b/public/text/scenes/home/room_backup.json @@ -0,0 +1,4 @@ +{ + "title": "Test Room", + "description": "You are in Test Room." +} diff --git a/public/text/scenes/new_scene.json b/public/text/scenes/new_scene.json new file mode 100644 index 0000000..faa8b8b --- /dev/null +++ b/public/text/scenes/new_scene.json @@ -0,0 +1,4 @@ +{ + "title": "New Scene", + "description": "You are in New Scene." +} diff --git a/src/components/ConsoleOverlay.tsx b/src/components/ConsoleOverlay.tsx index 89ceb69..988bfab 100644 --- a/src/components/ConsoleOverlay.tsx +++ b/src/components/ConsoleOverlay.tsx @@ -135,28 +135,6 @@ const InputMirror: React.FC<{ game: Game }> = ({ game }) => { const input = game.getCommandInput(); if (!input) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - const command = input.value; - if (command.trim()) { - // Send to Game Console Processing - if (game.console) { - game.console.processCommand(command); - } else { - // Fallback purely for parser if console not active? - // Actually, if we are in ConsoleOverlay, we want Console logic. - // The original game parser logic might still listen to 'Enter' globally? - // Let's ensure we don't double submit. - // Game.ts -> onKeyDown usually handles parser. - // We might need to coordinate who consumes the input. - // For now, let's assume this is the Console input. - } - input.value = ''; - setVal(''); - } - } - }; - const update = () => { if (input && input.value !== val) { setVal(input.value); @@ -164,11 +142,9 @@ const InputMirror: React.FC<{ game: Game }> = ({ game }) => { requestAnimationFrame(update); }; - input.addEventListener('keydown', handleKeyDown); const rAF = requestAnimationFrame(update); return () => { - input.removeEventListener('keydown', handleKeyDown); cancelAnimationFrame(rAF); }; }, [game, val]); diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 7d0b6d2..de79f86 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -16,6 +16,10 @@ export const PropertiesPanel: React.FC = () => { selectedVertexIndex, } = useEditorStore(); const [groupIdDraft, setGroupIdDraft] = React.useState(''); + const [resolvedTitle, setResolvedTitle] = React.useState(''); + const [textAssetPath, setTextAssetPath] = React.useState(''); + const [isReadingTA, setIsReadingTA] = React.useState(false); + const [hasTextAsset, setHasTextAsset] = React.useState(false); // Derived Object Binding (Source of Truth) // We re-render whenever objectVersion changes (subscribed via store hook) @@ -59,6 +63,125 @@ export const PropertiesPanel: React.FC = () => { incrementHierarchyVersion(); }; + const loadResolvedTitle = React.useCallback( + async (forceReload: boolean = false) => { + if (!game || !obj || selectedObjectType === 'MULTI' || selectedObjectType === 'SETTINGS') { + setResolvedTitle(''); + setTextAssetPath(''); + return; + } + + if (selectedObjectType === 'SCENE') { + const scene = game.sceneManager.currentScene; + if (!scene) return; + const asset = forceReload + ? await game.textAssets.readSceneAsset(scene, true) + : await game.textAssets.readSceneAsset(scene, false); + setHasTextAsset(!!asset); + setResolvedTitle(game.textAssets.getResolvedSceneField(scene, 'title') || ''); + setTextAssetPath(game.textAssets.getSceneAssetProjectPath(scene.id)); + return; + } + + if (game.editor?.selectedObject) { + const selected = game.editor.selectedObject; + const asset = forceReload + ? await game.textAssets.readObjectAsset(selected, true) + : await game.textAssets.readObjectAsset(selected, false); + setHasTextAsset(!!asset); + setResolvedTitle(game.textAssets.getResolvedObjectField(selected, 'title') || ''); + setTextAssetPath(game.textAssets.getObjectAssetProjectPath(selected.name)); + } + }, + [game, obj, selectedObjectType] + ); + + React.useEffect(() => { + loadResolvedTitle(false).catch((err) => { + console.error('Failed to load text asset title:', err); + }); + }, [loadResolvedTitle, selectedObjectId, selectedObjectType]); + + const handleOpenTA = async () => { + if (!game || !obj) return; + try { + if (selectedObjectType === 'SCENE') { + const scene = game.sceneManager.currentScene; + if (!scene) return; + await game.textAssets.openSceneAsset(scene); + } else if (game.editor?.selectedObject) { + await game.textAssets.openObjectAsset(game.editor.selectedObject); + } + await loadResolvedTitle(true); + } catch (err) { + console.error('Failed to open text asset:', err); + game.showNotification?.(`Failed to open TA: ${err}`); + } + }; + + const handleReadTA = async () => { + if (!game || !obj) return; + setIsReadingTA(true); + try { + const path = + selectedObjectType === 'SCENE' + ? game.textAssets.getSceneAssetProjectPath(game.sceneManager.currentScene?.id || '') + : game.editor?.selectedObject + ? game.textAssets.getObjectAssetProjectPath(game.editor.selectedObject.name) + : ''; + const defaultContent = + selectedObjectType === 'SCENE' + ? JSON.stringify( + game.textAssets.buildDefaultSceneAsset(game.sceneManager.currentScene as any), + null, + 2 + ) + : game.editor?.selectedObject + ? JSON.stringify( + game.textAssets.buildDefaultObjectAsset(game.editor.selectedObject), + null, + 2 + ) + : '{}'; + + await fetch('/api/read-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, content: defaultContent }), + }); + await loadResolvedTitle(true); + incrementObjectVersion(); + game.showNotification?.('Text asset reloaded'); + } catch (err) { + console.error('Failed to read text asset:', err); + game.showNotification?.(`Failed to read TA: ${err}`); + } finally { + setIsReadingTA(false); + } + }; + + const handleDeleteTA = async () => { + if (!game || !obj || !hasTextAsset) return; + const confirmed = window.confirm(`Delete text asset?\n${textAssetPath}`); + if (!confirmed) return; + + try { + if (selectedObjectType === 'SCENE') { + const scene = game.sceneManager.currentScene; + if (!scene) return; + await game.textAssets.deleteSceneAsset(scene); + } else if (game.editor?.selectedObject) { + await game.textAssets.deleteObjectAsset(game.editor.selectedObject); + } + await loadResolvedTitle(true); + incrementObjectVersion(); + game.showNotification?.('Text asset deleted'); + } catch (err) { + console.error('Failed to delete text asset:', err); + game.showNotification?.(`Failed to delete TA: ${err}`); + } + }; + React.useEffect(() => { if (selectedObjectType !== 'MULTI') { setGroupIdDraft(''); @@ -751,6 +874,38 @@ export const PropertiesPanel: React.FC = () => { }} /> </div> + <div className="e-row"> + <label className="e-label">Title</label> + <input + type="text" + className="e-input" + value={resolvedTitle} + readOnly + tabIndex={-1} + onFocus={(e) => e.currentTarget.blur()} + style={{ pointerEvents: 'none', color: '#888' }} + /> + {textAssetPath && ( + <> + <div style={{ display: 'flex', gap: '6px', marginTop: '4px' }}> + <button className="e-btn" onClick={handleOpenTA}> + {hasTextAsset ? 'Open TA' : 'Create TA'} + </button> + <button className="e-btn" onClick={handleReadTA} disabled={isReadingTA}> + {isReadingTA ? 'Syncing...' : 'Sync TA'} + </button> + {hasTextAsset && ( + <button className="e-btn" onClick={handleDeleteTA}> + Delete TA + </button> + )} + </div> + <div className="e-label" style={{ color: '#888', fontSize: '0.8em' }}> + {textAssetPath} + </div> + </> + )} + </div> </> )} @@ -792,18 +947,6 @@ export const PropertiesPanel: React.FC = () => { selectedObjectType === 'Actor' || selectedObjectType === 'Static') && ( <> - {/* Display Name */} - <div className="e-row"> - <label className="e-label">Display Name</label> - <input - type="text" - className="e-input" - placeholder="e.g. Pillar (for Parser)" - value={obj.customName || ''} - onChange={(e) => handleChange('customName', e.target.value)} - /> - </div> - {/* Transform: X, Y, W, H */} <div className="e-row" @@ -2611,16 +2754,6 @@ export const PropertiesPanel: React.FC = () => { {/* SCENE Properties */} {selectedObjectType === 'SCENE' && ( <> - <div className="e-row"> - <label className="e-label">Title</label> - <input - type="text" - className="e-input" - value={obj.name || ''} - onChange={(e) => handleChange('name', e.target.value)} - /> - </div> - {/* Camera properties */} {(obj.camera || obj.defaultCamera) && ( <div className="e-row" style={{ borderTop: '1px solid #444', paddingTop: '5px' }}> diff --git a/src/core/Game.ts b/src/core/Game.ts index 2b8b847..4636f53 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -9,6 +9,7 @@ import { Entity } from '../entities/Entity'; import { registerDemoScripts } from '../scripts/DemoScripts'; import { registerUserScripts } from '../scripts/main'; import { AudioManager } from './AudioManager'; +import { TextAssetManager } from './TextAssetManager'; import { Console } from './Console'; @@ -41,6 +42,7 @@ export class Game implements IGame { sceneManager: SceneManager; assets: AssetLoader; audio: AudioManager; + textAssets: TextAssetManager; editor: SceneEditor; spriteEditor: SpriteEditor; console: Console; // Virtual Console @@ -149,6 +151,7 @@ export class Game implements IGame { this.parser = new Parser(this); this.assets = new AssetLoader(); this.audio = new AudioManager(); + this.textAssets = new TextAssetManager(); this.sceneManager = new SceneManager(this); this.editor = new SceneEditor(this); this.spriteEditor = new SpriteEditor(this); diff --git a/src/core/IGame.ts b/src/core/IGame.ts index c0f30ec..7e61d3b 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -3,10 +3,12 @@ import { AudioManager } from './AudioManager'; import { SceneManager } from '../scene/SceneManager'; import { SceneEditor } from '../tools/SceneEditor'; import { Entity } from '../entities/Entity'; +import { TextAssetManager } from './TextAssetManager'; export interface IGame { assets: AssetLoader; audio: AudioManager; + textAssets: TextAssetManager; sceneManager: SceneManager; editor: SceneEditor; inventory: Entity[]; diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts new file mode 100644 index 0000000..3b04b75 --- /dev/null +++ b/src/core/TextAssetManager.ts @@ -0,0 +1,227 @@ +import type { Scene } from '../scene/Scene'; +import type { SceneObject } from '../entities/SceneObject'; + +type TextAssetData = Record<string, string>; + +export class TextAssetManager { + private sceneCache = new Map<string, TextAssetData | null>(); + private objectCache = new Map<string, TextAssetData | null>(); + + private normalizeId(id: string): string { + return String(id || '') + .replace(/\//g, '\\') + .trim(); + } + + private idToRelativePath(id: string): string { + return this.normalizeId(id).replace(/\\/g, '/'); + } + + getSceneAssetProjectPath(sceneId: string): string { + return `public/text/scenes/${this.idToRelativePath(sceneId)}.json`; + } + + getObjectAssetProjectPath(objectId: string): string { + return `public/text/objects/${this.idToRelativePath(objectId)}.json`; + } + + private getSceneAssetUrl(sceneId: string): string { + return `/text/scenes/${this.idToRelativePath(sceneId)}.json`; + } + + private getObjectAssetUrl(objectId: string): string { + return `/text/objects/${this.idToRelativePath(objectId)}.json`; + } + + buildDefaultSceneAsset(scene: Scene): TextAssetData { + return { + title: scene.name || scene.id || 'Untitled Scene', + description: + scene.description || `You are in ${scene.name || scene.id || 'an unnamed scene'}.`, + }; + } + + buildDefaultObjectAsset(obj: SceneObject): TextAssetData { + const fallbackTitle = (obj as any).customName || obj.name || obj.type || 'Object'; + const fallbackDescription = (obj as any).description || 'You see nothing special.'; + return { + title: fallbackTitle, + description: fallbackDescription, + }; + } + + async ensureSceneAssetFile(scene: Scene): Promise<void> { + if (!scene?.id) return; + const assetPath = this.getSceneAssetProjectPath(scene.id); + const content = JSON.stringify(this.buildDefaultSceneAsset(scene), null, 2); + await this.ensureFile(assetPath, content); + } + + async ensureObjectAssetFile(obj: SceneObject): Promise<void> { + if (!obj?.name) return; + const assetPath = this.getObjectAssetProjectPath(obj.name); + const content = JSON.stringify(this.buildDefaultObjectAsset(obj), null, 2); + await this.ensureFile(assetPath, content); + } + + async openSceneAsset(scene: Scene): Promise<void> { + const assetPath = this.getSceneAssetProjectPath(scene.id); + const content = JSON.stringify(this.buildDefaultSceneAsset(scene), null, 2); + await this.openFile(assetPath, content); + } + + async openObjectAsset(obj: SceneObject): Promise<void> { + const assetPath = this.getObjectAssetProjectPath(obj.name); + const content = JSON.stringify(this.buildDefaultObjectAsset(obj), null, 2); + await this.openFile(assetPath, content); + } + + async deleteSceneAsset(scene: Scene): Promise<void> { + await this.deleteFile(this.getSceneAssetProjectPath(scene.id)); + this.sceneCache.delete(this.normalizeId(scene.id)); + } + + async deleteObjectAsset(obj: SceneObject): Promise<void> { + await this.deleteFile(this.getObjectAssetProjectPath(obj.name)); + this.objectCache.delete(this.normalizeId(obj.name)); + } + + async readSceneAsset(scene: Scene, forceReload: boolean = false): Promise<TextAssetData | null> { + const sceneId = this.normalizeId(scene?.id || ''); + if (!sceneId) return null; + if (!forceReload && this.sceneCache.has(sceneId)) { + return this.sceneCache.get(sceneId) || null; + } + const data = await this.fetchJson(this.getSceneAssetUrl(sceneId)); + this.sceneCache.set(sceneId, data); + return data; + } + + async readObjectAsset( + obj: SceneObject, + forceReload: boolean = false + ): Promise<TextAssetData | null> { + const objectId = this.normalizeId(obj?.name || ''); + if (!objectId) return null; + if (!forceReload && this.objectCache.has(objectId)) { + return this.objectCache.get(objectId) || null; + } + const data = await this.fetchJson(this.getObjectAssetUrl(objectId)); + this.objectCache.set(objectId, data); + return data; + } + + async preloadScene(scene: Scene): Promise<void> { + await this.readSceneAsset(scene, true); + await Promise.all( + (scene.entities || []).map((entity: SceneObject) => this.readObjectAsset(entity, true)) + ); + } + + clearCaches(): void { + this.sceneCache.clear(); + this.objectCache.clear(); + } + + getResolvedSceneField(scene: Scene, field: string): string | null { + const sceneId = this.normalizeId(scene?.id || ''); + const asset = sceneId ? this.sceneCache.get(sceneId) : null; + const fallback = field === 'description' ? scene?.description || null : null; + return this.resolveField(asset, scene?.textRedirects || null, field, fallback); + } + + getResolvedObjectField(obj: SceneObject, field: string): string | null { + const objectId = this.normalizeId(obj?.name || ''); + const asset = objectId ? this.objectCache.get(objectId) : null; + const fallback = field === 'description' ? (obj as any).description || null : null; + return this.resolveField(asset, obj?.textRedirects || null, field, fallback); + } + + private resolveField( + asset: TextAssetData | null | undefined, + redirects: Record<string, string> | null | undefined, + field: string, + fallback: string | null + ): string | null { + if (!asset) return fallback; + const redirectTarget = redirects && redirects[field]; + if (redirectTarget) { + const redirected = asset[redirectTarget]; + if (typeof redirected === 'string') return redirected; + console.warn( + `[TextAssetManager] Missing redirected field '${redirectTarget}' for '${field}'.` + ); + } + const direct = asset[field]; + if (typeof direct === 'string') return direct; + return fallback; + } + + private async fetchJson(url: string): Promise<TextAssetData | null> { + try { + const response = await fetch(`${url}?t=${Date.now()}`); + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(await response.text()); + } + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + return null; + } + return (await response.json()) as TextAssetData; + } catch (error) { + console.error('[TextAssetManager] Failed to fetch text asset:', error); + return null; + } + } + + private async ensureFile(filePath: string, content: string): Promise<void> { + await fetch('/api/ensure-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath, content }), + }); + } + + private async openFile(filePath: string, content: string): Promise<void> { + const response = await fetch('/api/open-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath, content }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + } + + async duplicateObjectAssetIfExists( + sourceObjectId: string, + targetObjectId: string + ): Promise<void> { + const sourceUrl = this.getObjectAssetUrl(sourceObjectId); + const sourceData = await this.fetchJson(sourceUrl); + if (!sourceData) return; + + const targetPath = this.getObjectAssetProjectPath(targetObjectId); + const response = await fetch('/api/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: targetPath, content: JSON.stringify(sourceData, null, 2) }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + this.objectCache.set(this.normalizeId(targetObjectId), sourceData); + } + + private async deleteFile(filePath: string): Promise<void> { + const response = await fetch('/api/delete-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + } +} diff --git a/src/entities/SceneObject.ts b/src/entities/SceneObject.ts index 85ee6b1..d79aafd 100644 --- a/src/entities/SceneObject.ts +++ b/src/entities/SceneObject.ts @@ -9,6 +9,7 @@ export class SceneObject { // User-facing name for parser (e.g. "Pillar" instead of "Pillar_01") customName: string = ''; + textRedirects: Record<string, string> = {}; // Script bindings for verbs: { "LOOK": "script.id", "USE": "script.id" } interactions: Record<string, string> = {}; @@ -30,6 +31,7 @@ export class SceneObject { 'disabled', 'groupID', 'customName', + 'textRedirects', 'interactions', 'components', 'layer', @@ -44,6 +46,7 @@ export class SceneObject { this.layer = 0; this.visible = true; this.customName = ''; + this.textRedirects = {}; this.interactions = {}; this.components = []; } diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index b0b3205..7b8f6da 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -40,14 +40,24 @@ export class Parser { execute(verb: string, noun: string): void { const scene = this.game.sceneManager.currentScene; if (!scene) return; + const normalizedNoun = noun.trim().toUpperCase(); + const isSceneLook = + !normalizedNoun || + normalizedNoun === 'AROUND' || + normalizedNoun === 'HERE' || + normalizedNoun === 'SCENE'; // Basic command handling switch (verb) { case 'LOOK': case 'EXAMINE': case 'X': // Common shortcut - if (!noun) { - this.game.log(`You are in ${scene.name}.`); + if (isSceneLook) { + const sceneDescription = + this.game.textAssets.getResolvedSceneField(scene, 'description') || + scene.description || + `You are in ${scene.name}.`; + this.game.log(sceneDescription); } else { const entity = scene.findEntity(noun); if (entity) { @@ -58,7 +68,10 @@ export class Parser { ScriptRegistry.execute(interactionId, { game: this.game, entity: entity }); } else { // Fallback to description - this.game.log(entity.description || `You see nothing special about the ${noun}.`); + const description = + this.game.textAssets.getResolvedObjectField(entity, 'description') || + entity.description; + this.game.log(description || `You see nothing special about the ${noun}.`); } } else { this.game.log(`You don't see any ${noun} here.`); diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 50a8dad..73724cd 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -24,6 +24,8 @@ export interface SceneScaling { export interface SceneData { id: string; name: string; + description?: string; + textRedirects?: Record<string, string>; filename?: string; walkbox: { poly: { x: number; y: number }[]; @@ -50,6 +52,7 @@ export class Scene { id: string; name: string; + description: string; filename: string; background: HTMLImageElement | null; entities: Entity[]; @@ -77,6 +80,7 @@ export class Scene { // Default Camera (saved to scene file, restored on load/reset) defaultCamera: { x: number; y: number; zoom: number }; + textRedirects: Record<string, string> = {}; // Subscene State private _activeSubscene: string | null = null; @@ -104,6 +108,7 @@ export class Scene { this.game = game; this.id = id; this.name = name; + this.description = `You are in ${name}.`; this.filename = ''; // Default empty this.background = null; // Image object this.entities = []; @@ -149,11 +154,15 @@ export class Scene { } findEntity(name: string): Entity | undefined { - return this.entities.find( - (e) => - e.name.toUpperCase() === name.toUpperCase() || - (e.customName && e.customName.toUpperCase() === name.toUpperCase()) - ); + const normalized = name.toUpperCase(); + return this.entities.find((e) => { + const resolvedTitle = this.game.textAssets.getResolvedObjectField(e, 'title'); + return ( + e.name.toUpperCase() === normalized || + (e.customName && e.customName.toUpperCase() === normalized) || + (resolvedTitle && resolvedTitle.toUpperCase() === normalized) + ); + }); } getScaling(y: number): number { @@ -486,6 +495,8 @@ export class Scene { return { id: this.id, name: this.name, + description: this.description, + textRedirects: this.textRedirects, filename: this.filename, walkbox: this.walkbox.map((wb) => wb.toJSON()), triggerboxes: this.triggerboxes.map((tb) => tb.toJSON()), diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index aad36b3..63b0614 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -2,6 +2,7 @@ import type { Scene } from './Scene'; import { SceneObject } from '../entities/SceneObject'; import { Triggerbox } from '../entities/Triggerbox'; import { ComponentSystem } from '../systems/ComponentSystem'; +import { Geometry } from '../utils/Geometry'; function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { const screenW = 420; @@ -14,6 +15,150 @@ function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { }; } +function findVisibleHitObject(scene: Scene, screenX: number, screenY: number): SceneObject | null { + const screenW = 420; + const screenH = 300; + const halfW = screenW / 2; + const halfH = screenH / 2; + const camX = scene.camera.x; + const camY = scene.camera.y; + const zoom = scene.camera.zoom; + + const entities = scene.entities || []; + for (let i = entities.length - 1; i >= 0; i--) { + const entity = entities[i]; + if (entity.disabled || !entity.visible) continue; + + const p = entity.parallax !== undefined ? entity.parallax : 1.0; + const vOx = (entity as any).visualOffset ? (entity as any).visualOffset.x : 0; + const vOy = (entity as any).visualOffset ? (entity as any).visualOffset.y : 0; + const worldX = (screenX - halfW) / zoom + camX * p - vOx; + const worldY = (screenY - halfH) / zoom + camY * p - vOy; + + if (entity.hitTest(worldX, worldY)) return entity; + } + + const worldPos = { + x: (screenX - halfW) / zoom + camX, + y: (screenY - halfH) / zoom + camY, + }; + + if (scene.triggerboxes) { + for (const tb of scene.triggerboxes) { + if (tb.disabled || !tb.visible) continue; + if (Geometry.isPointInPolygon(worldPos, tb.poly)) return tb; + } + } + + if (scene.walkbox) { + for (const wb of scene.walkbox) { + if (wb.disabled || !wb.visible) continue; + if (Geometry.isPointInPolygon(worldPos, wb.poly)) return wb; + } + } + + return null; +} + +function isHitAtScreenPoint( + scene: Scene, + obj: SceneObject, + screenX: number, + screenY: number +): boolean { + const screenW = 420; + const screenH = 300; + const halfW = screenW / 2; + const halfH = screenH / 2; + const camX = scene.camera.x; + const camY = scene.camera.y; + const zoom = scene.camera.zoom; + + if ('x' in obj && 'y' in obj) { + const entity = obj as any; + const p = entity.parallax !== undefined ? entity.parallax : 1.0; + const vOx = entity.visualOffset ? entity.visualOffset.x : 0; + const vOy = entity.visualOffset ? entity.visualOffset.y : 0; + const worldX = (screenX - halfW) / zoom + camX * p - vOx; + const worldY = (screenY - halfH) / zoom + camY * p - vOy; + return obj.hitTest(worldX, worldY); + } + + const worldPos = { + x: (screenX - halfW) / zoom + camX, + y: (screenY - halfH) / zoom + camY, + }; + return obj.hitTest(worldPos.x, worldPos.y); +} + +function sortClickableCandidates(candidates: SceneObject[]): SceneObject[] { + const sorted = [...candidates]; + sorted.sort((a, b) => { + const layerA = a.layer || 0; + const layerB = b.layer || 0; + if (layerA !== layerB) return layerB - layerA; + + const hasXYA = 'x' in (a as any) && 'y' in (a as any); + const hasXYB = 'x' in (b as any) && 'y' in (b as any); + if (hasXYA && !hasXYB) return -1; + if (!hasXYA && hasXYB) return 1; + return 0; + }); + + return sorted; +} + +function getSortedClickableCandidates(scene: Scene): SceneObject[] { + return sortClickableCandidates([ + ...scene.entities.filter((e) => !e.disabled && e.visible), + ...(scene.triggerboxes?.filter((t) => !t.disabled && t.visible) || []), + ...(scene.walkbox?.filter((w) => !w.disabled && w.visible) || []), + ]); +} + +function findTopHitInCandidates( + scene: Scene, + candidates: SceneObject[], + screenX: number, + screenY: number +): SceneObject | null { + for (const candidate of sortClickableCandidates(candidates)) { + if (isHitAtScreenPoint(scene, candidate, screenX, screenY)) { + return candidate; + } + } + return null; +} + +function findTopHitInWorldCandidates( + candidates: SceneObject[], + worldX: number, + worldY: number +): SceneObject | null { + for (const candidate of sortClickableCandidates(candidates)) { + if (candidate.hitTest(worldX, worldY)) { + return candidate; + } + } + return null; +} + +function findTopHitObject(scene: Scene, screenX: number, screenY: number): SceneObject | null { + return findTopHitInCandidates(scene, getSortedClickableCandidates(scene), screenX, screenY); +} + +function resolveSubtriggerTarget(scene: Scene, obj: SceneObject): SceneObject { + const subtrigger = obj.components?.find((c: any) => c?.type === 'Subtrigger') as + | { target?: string } + | undefined; + if (!subtrigger?.target) return obj; + + const target = + scene.triggerboxes.find((t) => t.name === subtrigger.target) || + scene.entities.find((e) => e.name === subtrigger.target); + return target || obj; +} + export function activateSceneObject(scene: Scene, obj: SceneObject, depth: number = 0): void { if (depth > 5) { console.warn('[Scene] Recursion limit reached.'); @@ -31,9 +176,36 @@ export function activateSceneObject(scene: Scene, obj: SceneObject, depth: numbe export function handleSceneClick(scene: Scene, x: number, y: number): void { const world = toWorld(scene, x, y); - const hitObj = scene.getHitObject(world.x, world.y); + + if (scene.activeSubscene) { + const subsceneHit = findTopHitInWorldCandidates( + Array.from(scene.subsceneEntities).filter((obj) => !obj.disabled && obj.visible), + world.x, + world.y + ); + + if (subsceneHit) { + const titleOwner = resolveSubtriggerTarget(scene, subsceneHit); + const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + if (title && title.trim()) { + scene.game.log(`You see ${title}`); + } + activateSceneObject(scene, subsceneHit); + return; + } + + scene.activeSubscene = null; + return; + } + + const hitObj = findTopHitObject(scene, x, y); if (hitObj) { + const title = scene.game.textAssets.getResolvedObjectField(hitObj, 'title'); + if (title) { + scene.game.log(`You see ${title}`); + } + const isWalkBox = hitObj.components && hitObj.components.some((c) => c.type === 'WalkBox'); const isMechanism = hitObj.components && @@ -46,14 +218,14 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { } } - if (scene.activeSubscene) { - for (const obj of scene.subsceneEntities) { - if (obj.hitTest(world.x, world.y)) { - return; - } + const visibleHitObj = findTopHitObject(scene, x, y) || findVisibleHitObject(scene, x, y); + if (visibleHitObj) { + const titleOwner = resolveSubtriggerTarget(scene, visibleHitObj); + const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + if (title && title.trim()) { + scene.game.log(`You see ${title}`); + return; } - scene.activeSubscene = null; - return; } if (scene.player) { diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index ca619eb..02078ce 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -73,14 +73,14 @@ export class SceneManager { const data = await response.json(); // Pass the derived ID to loadSceneData - this.loadSceneData(data, idFromPath); + await this.loadSceneData(data, idFromPath); } catch (e) { console.error(e); this.game.showNotification?.('Failed to load scene'); } } - loadSceneData(data: any, filename?: string): void { + async loadSceneData(data: any, filename?: string): Promise<void> { try { // Priority: // 1. filename argument (derived from path: "sub\scene") @@ -96,6 +96,8 @@ export class SceneManager { // If ID was missing in File but provided by filename, ensure consistency newScene.id = sceneId; + if (data.description !== undefined) newScene.description = data.description; + if (data.textRedirects) newScene.textRedirects = { ...data.textRedirects }; // Restore Camera if (data.camera) { @@ -162,6 +164,7 @@ export class SceneManager { this.addScene(newScene); this.switchTo(newScene.id); + await this.game.textAssets.preloadScene(newScene); // If Editor is active, it needs to know if (this.game.editor) { diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index 7130467..d772e82 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -584,6 +584,9 @@ export class SceneEditor { newScene.scaling.enabled = true; this.game.sceneManager.addScene(newScene); this.game.sceneManager.switchTo(newScene.id); + this.game.textAssets.ensureSceneAssetFile(newScene).catch((err: unknown) => { + console.error('Failed to create default scene text asset:', err); + }); this.syncUI(); this.refreshHierarchy(); this.selectObject('SCENE'); diff --git a/src/tools/editor/EditorPersistenceManager.ts b/src/tools/editor/EditorPersistenceManager.ts index c5f9b1d..4b414bc 100644 --- a/src/tools/editor/EditorPersistenceManager.ts +++ b/src/tools/editor/EditorPersistenceManager.ts @@ -63,6 +63,7 @@ export class EditorPersistenceManager { }); if (response.ok) { + await this.editor.game.textAssets.ensureSceneAssetFile(scene); // Use Toast Message this.editor.game.showNotification(`Scene saved as ${normalizedPath}.json`); } else { diff --git a/src/tools/editor/EditorSelectionManager.ts b/src/tools/editor/EditorSelectionManager.ts index bd891f7..4a5d967 100644 --- a/src/tools/editor/EditorSelectionManager.ts +++ b/src/tools/editor/EditorSelectionManager.ts @@ -558,7 +558,9 @@ export class EditorSelectionManager { const created: SceneObject[] = []; const preserveQuadBindings = payload.items.length > 1; - orderedItems.forEach((item) => { + orderedItems.forEach((item, index) => { + const originalName = + typeof payload.items[index]?.name === 'string' ? payload.items[index].name : item.name; const sourcePoint = this.getReferencePointFromSerializedData(item); const overrideX = insertionPoint.x + (sourcePoint.x - anchorSourcePoint.x); const overrideY = insertionPoint.y + (sourcePoint.y - anchorSourcePoint.y); @@ -571,7 +573,14 @@ export class EditorSelectionManager { const newObj = this.editor.createObjectFromData(item, overrideX, overrideY, { preserveBindings: preserveQuadBindings && item?.type === 'Quad', }); - if (newObj) created.push(newObj); + if (newObj) { + created.push(newObj); + if (originalName && originalName !== newObj.name) { + this.editor.game.textAssets + .duplicateObjectAssetIfExists(originalName, newObj.name) + .catch((err: unknown) => console.error('Failed to duplicate text asset:', err)); + } + } }); return created; diff --git a/vite.config.ts b/vite.config.ts index 41bf373..1cdf2ac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,16 @@ import fs from 'fs'; import path from 'path'; import { exec } from 'child_process'; +function ensureFile(targetPath: string, content: string) { + const dir = path.dirname(targetPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + if (!fs.existsSync(targetPath)) { + fs.writeFileSync(targetPath, content); + } +} + // https://vite.dev/config/ export default defineConfig({ plugins: [ @@ -45,6 +55,30 @@ export default defineConfig({ } }); + server.middlewares.use('/api/ensure-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath, content } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + ensureFile(targetPath, content || '{}'); + res.statusCode = 200; + res.end(JSON.stringify({ success: true })); + } catch (err) { + console.error('[Vite] Ensure file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); + // LIST FILES ENDPOINT server.middlewares.use('/api/list', (req, res, next) => { if (req.method === 'POST') { @@ -90,6 +124,30 @@ export default defineConfig({ next(); } }); + server.middlewares.use('/api/read-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath, content } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + ensureFile(targetPath, content || '{}'); + const fileContent = fs.readFileSync(targetPath, 'utf-8'); + res.statusCode = 200; + res.end(JSON.stringify({ success: true, content: fileContent })); + } catch (err) { + console.error('[Vite] Read file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); // OPEN FOLDER ENDPOINT server.middlewares.use('/api/open-folder', (req, res, next) => { if (req.method === 'POST') { @@ -122,6 +180,57 @@ export default defineConfig({ next(); } }); + server.middlewares.use('/api/open-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath, content } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + ensureFile(targetPath, content || '{}'); + + exec(`start "" "${targetPath}"`); + + res.statusCode = 200; + res.end(JSON.stringify({ success: true })); + } catch (err) { + console.error('[Vite] Open file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); + server.middlewares.use('/api/delete-file', (req, res, next) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { path: relativePath } = JSON.parse(body); + const targetPath = path.resolve(__dirname, relativePath); + if (fs.existsSync(targetPath)) { + fs.unlinkSync(targetPath); + } + res.statusCode = 200; + res.end(JSON.stringify({ success: true })); + } catch (err) { + console.error('[Vite] Delete file error:', err); + res.statusCode = 500; + res.end(JSON.stringify({ error: String(err) })); + } + }); + } else { + next(); + } + }); }, }, ], From 126f90d0a103faa39a07778ad68acb10e742eeae Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 00:38:58 +0200 Subject: [PATCH 02/75] Feature: extend TA API and script documentation Add a separate service text-asset layer under public/text/system for parser, engine and script strings, with dotted key lookup and placeholder interpolation via TextAssetManager. Expose game.text(key, params?) as the base runtime access point for service TA and mirror it in ScriptAPI as api.text(key, params?) for script-side convenience. Keep api.log() as output-only, and document the distinction between lookup and output. Implement runtime text redirect mutators on Scene and SceneObject so scripts can switch standard TA fields like description to alternate fields at runtime via scene.setTextRedirect()/clearTextRedirect() and entity.setTextRedirect()/clearTextRedirect(). Migrate the first set of hardcoded strings in Parser, ComponentSystem, SceneInteraction and DemoScripts to service TA lookups, including parser feedback, click titles, distance/lock messages and demo script narration. Expand GDD scripting/API documentation into a coherent guide covering ScriptContext, game/api responsibilities, currentScene access, object lookup helpers, text redirects, entity and actor operations, and the distinction between script context and browser-console globals. --- GDD.md | 308 +++++++++++++++++++++++----- public/text/objects/boombox.json | 4 +- public/text/objects/miles_id_1.json | 4 - public/text/system/engine.json | 6 + public/text/system/parser.json | 16 ++ public/text/system/scripts.json | 6 + src/core/Game.ts | 5 + src/core/IGame.ts | 1 + src/core/ScriptAPI.ts | 4 + src/core/TextAssetManager.ts | 105 ++++++++++ src/entities/SceneObject.ts | 23 +++ src/mechanics/Parser.ts | 45 ++-- src/scene/Scene.ts | 21 ++ src/scene/SceneInteraction.ts | 6 +- src/scripts/DemoScripts.ts | 8 +- src/systems/ComponentSystem.ts | 12 +- 16 files changed, 486 insertions(+), 88 deletions(-) delete mode 100644 public/text/objects/miles_id_1.json create mode 100644 public/text/system/engine.json create mode 100644 public/text/system/parser.json create mode 100644 public/text/system/scripts.json diff --git a/GDD.md b/GDD.md index 44c6807..f42e810 100644 --- a/GDD.md +++ b/GDD.md @@ -74,6 +74,8 @@ Parser обрабатывает пользовательский ввод кас - _Static_: прямоугольник с координатами X/Y, размерами X/Y, цветом заполнения и опционально спрайтом/анимацией, отображающимся вместо прямоугольника. Спрайт можно переключать на лету. В основном Static это фоны, декоративные элементы и предметы, которые не перемещаются. - _Actor_: объект, который помимо свойств Static имеет направление, в котором он повёрнут и, опционально, спрайты/анимации состояний (idle, walk, talk, etc), причём для каждого направления свой набор. Обычно Actor это NPC и анимированные объекты. Персонаж игрока также является разновидностью Actor. +### ID + Каждая сцена и каждый объект имеют свой уникальный _ID_, который используется для ссылок на них. При этом: 1. id (содержимое поля id/file) для сцен, спрайтов, и также префабов (т.е. сохранённых объектов) может включать один или несколько обратных слешей "\". При сохранении такого объекта слеши работают как маркеры подпапок (относительно дефолтной папки для данного типа объектов), например "home\room1" сохранится как файл room.json в папке home. @@ -81,6 +83,8 @@ Parser обрабатывает пользовательский ввод кас 3. При завершении загрузки объекта сформированный id дополнительно проверяется на предмет совпадения с уже имеющимися в сцене. Если это не уникальный id, то он дополняется до уникального. 4. API при создании объекта или загрузки сцены получает id, трактует его как имя файла с возможным учётом подпапок и загружает его оттуда. +### Свойства сцены + Сцена может поддерживать _Depth-scaling_ -- масштабирование объектов, имитирующее 3d перспективу, когда объекты, находящиеся "дальше от камеры" (то есть, выше по оси Y), становятся меньше. Настройки масштабирования для каждой сцены свои. Кроме того, объекты типа Static и Actor имеют свойство, запрещающее их Depth-scaling. Если Depth-scaling объекта запрещен, то он не изменяет свой размер при изменении Y, даже если Depth-scaling включен для сцены. Это полезно, например, для сцен, где персонаж лезет вертикально вверх по пожарной лестнице, и не должен уменьшаться по мере подъёма, поскольку не удаляется от камеры. @@ -89,7 +93,9 @@ Parser обрабатывает пользовательский ввод кас Важно отметить, что все свойства сцены и всех объектов должны быть доступны для изменения не только в редакторе, но и динамически прямо во время игры, со стороны игровой логики (скриптов). Примерно как свойства в Unity или Unreal Engine. -## Структура классов +## Объекты сцены + +### Структура классов С точки зрения кода класс _SceneObject_ является прародительским для всех объектов сцены в игре, включая Static, Actor, а также полигональные объекты TriggerBox и WalkBox. @@ -101,7 +107,7 @@ SceneObject └── Entity (≈ Static) └── Actor -## Свойства объектов SceneObject +### Свойства объектов SceneObject Эти свойства наследуются всеми объектами в игре: @@ -119,9 +125,9 @@ _Disabled_ : (boolean) _Locked_ : (boolean) Объект может быть заблокирован (Locked) для редактирования в редакторе сцены. Заблокированные объекты нельзя выбрать или переместить кликом мыши на экране (они становятся "прозрачными" для кликов), но их всё ещё можно выбрать в списке объектов. В режиме игры это свойство игнорируется. -## Коллайдеры (Collision Box) +#### Коллайдеры (Collision Box) -Объекты типа Static и Actor имеют свойства `Collider Width` и `Collider Height`, задающие размер прямоугольной области столкновения, которая по X центрирована по объекту, а по Y нижняя граница прямоугольника коллайдера приходится на нижнюю границу спрайта/прямоугольника объекта. То есть, при увеличении высоты коллайдера он растёт вверх, а при увеличении ширины он растёт в обе стороны от центра объекта. +Объекты Entity (Static и Actor имеют свойства `Collider Width` и `Collider Height`, задающие размер прямоугольной области столкновения, которая по X центрирована по объекту, а по Y нижняя граница прямоугольника коллайдера приходится на нижнюю границу спрайта/прямоугольника объекта. То есть, при увеличении высоты коллайдера он растёт вверх, а при увеличении ширины он растёт в обе стороны от центра объекта. - Если размеры коллайдера больше 0, этот объект является препятствием для других объектов (Actor), имеющих коллайдер. - Коллайдер взаимодействует с WalkBox: @@ -129,7 +135,7 @@ _Locked_ : (boolean) - В режиме _Invert_: коллайдер объекта должен полностью находиться внутри разрешенной зоны. - Если размеры коллайдера равны 0, объект считается проходимым, не сталкивается с другими и игнорирует WalkBox. -## Свойства Static +### Свойства Static _Parallax_ Управляет их перемещением при движении камеры. При значении 1 они движутся так же, как другие объекты, при 0.5 движутся вдвое медленней, при 0 остаются вообще неподвижными, а при значениях >1, соответственно, движутся быстрее чем обычные объекты. Это позволяет делать параллаксные фоны с эффектом глубины. Например, спрайт с небом не движется при скроллинге сцены, спрайт с отдалёнными домами движется вдвое медленней, чем остальная сцена, а деревья на переднем плане -- чуть быстрее. @@ -144,7 +150,7 @@ _Visual Effects_ - **Blend Mode**: Режим наложения цвета (Normal, Multiply, Screen, Overlay, etc). - **Blur**: Эффект размытия (в пикселях). -## Свойства Actor +### Свойства Actor Actor это расширение Static. Помимо текущего спрайта, как у Static, имеет направление и (опционально) визуальное состояние. @@ -157,7 +163,7 @@ Actor может иметь сколько угодно групп анимац Для скриптов есть возможность через API переключать состояние. Например, если переключить на группу "talk", то персонаж будет воспроизводить анимации разговора в зависимости от того, куда он повёрнут. Он будет сохранять эту анимацию до тех пор, пока ему не придёт команда перемещаться, тогда он переключится на walk а после остановки автоматически на idle. -## Свойства Quad +### Свойства Quad _Quad_ это примитив, определяемый четырьмя вершинами. В отличие от Static, это не прямоугольник, а произвольный четырёхугольник. Основное назначение -- создание поверхностей и стен с учётом 2.5D перспективы, а также эффектов тени и освещения. @@ -172,8 +178,7 @@ _Sort Mode_ (v0, v1, v2, v3, ignore) ## Компонентная система -Кроме простых свойств, которые есть всегда, объекты сцены могут содержать компоненты (структуры данных), которые могут быть добавлены и удалены в редакторе. Каждый объект может иметь один или несколько компонентов разных типов. Но не любой объект может содержать любой компонент. -Каждый компонент имеет уникальный id компонента. +Кроме простых свойств, которые есть всегда, объекты сцены способны содержать _компоненты_ (структуры данных), которые могут быть добавлены и удалены в редакторе. Каждый объект может иметь один или несколько компонентов разных типов. Но не любой объект может содержать любой компонент. #### Компоненты групп анимаций @@ -266,68 +271,266 @@ Static и Actor могут содержать скриптовые событи > Примечание: События _Always_ и _OnCollide_ зарезервированы в дизайне, но на текущий момент технически не реализованы в движке. +## Текстовые ассеты (TA) + +Наша игра в значительной степени текстовая. Каждый объект или сцена имеет название, а также описания, выдаваемые при различных действиях с объектами, например по методу look ("You see _a desk_"). Есть также тексты, предназначенные не для пользователя, а для SLM/LLM: описывающие возможные действия c предметами, промпты для задания нужной атмосферы и тп. Всё это удобно хранить в виде json файлов. Когда игра загружает сцену и объекты, она читает и текстовые ассеты с ними связанные. + +Текстовые ассеты хранятся в Public\text\ в виде json файлов с именами, совпадающими с id сцен и объектов. + +- `public\text\scenes\<scene-id>.json` +- `public\text\objects\<object-id>.json` + +Поскольку ID могут быть составными, ссылаясь на файлы в подпапках, соответствующие TA тоже могут находиться в подпапках. Например: 'public\text\scenes\home\room.json' для сцены 'home\room' + +Текстовый asset содержит стандартные (используемые движком) поля, а также может содержать дополнительные (кастомные). + +> сейчас стандартными текстовыми полями считаются `title` и `description`. + +Кроме текстовых ассетов сцен и объектов, в проекте есть и **служебные TA** для строк самого движка, парсера, UI и скриптов. Они хранятся отдельно, в `public\text\system\`, разбиваются по доменам (`parser.json`, `engine.json`, `scripts.json`, etc) и адресуются по строковым ключам вида `parser.take_prompt` или `engine.click_you_see`. +Служебные TA не имеют таблицы переадресации. Это просто словари строк, доступных по ключу. +В строках служебных TA допускаются именованные плейсхолдеры, например `{item}` или `{title}`, которые заполняются вызывающим кодом. + # Игровая логика (API & scripting system) Поскольку у нас игра, основанная на сюжетной логике, то нам нужно реагировать на события, такие как: -- столкновения Actor между собой, попадание их в TriggerBox, -- условия, такие как наличие у игрока предмета в инветаре, текущей сцены, присутствия в ней NPC, состояния какой-то внутренней переменной -- команды игрока -- реплики игрока в диалоге NPC и ответы NPC на них +- столкновения Actor между собой, попадание их в TriggerBox; +- условия, такие как наличие у игрока предмета в инветаре, текущей сцены, присутствия в ней NPC, состояния какой-то внутренней переменной; +- команды игрока; +- реплики игрока в диалоге NPC и ответы NPC на них; +- и тд. При этом в качестве реакции на это может потребоваться: -- изменение свойств сцены/объекта (например, скрыть/показать объект, приблизить или отдалить камеру) -- изменение состояния игры (например, изменить внутреннюю переменную timeOfDay на 'night') -- работа с инвентарём игрока (например, взять/забрать предмет) -- перенос игрока или NPC в другую сцену -- работа с анимациями объектов (например, персонаж садится на стул) +- изменение свойств сцены/объекта (например, скрыть/показать объект, приблизить или отдалить камеру); +- изменение состояния игры (например, изменить внутреннюю переменную timeOfDay на 'night'); +- работа с инвентарём игрока (например, взять/забрать предмет); +- перенос игрока или NPC в другую сцену; +- работа с анимациями объектов (например, персонаж садится на стул); - и тд. Комплексный пример: игрок подходит к стене, на которой есть кнопка. Если игрок находится рядом с кнопкой и отдаёт команду нажать на неё, сверху спускается лестница, после чего становится доступна новая команда: "лезь по лестнице". Когда игрок переходит в режим лазания по лестнице, то обычный WalkBox отключается, а включается WalkBox для лестницы, который позволяет персонажу перемещаться лишь вверх и вниз. Кроме того, у персонажа игрока заменяются анимации walk для ходьбы вверх и вниз на анимации лазания вверх и вниз по лестнице, а ещё устанавливается запрет на Depth-scaling, чтобы поднимаясь по лестнице персонаж не уменьшался в размере. Когда игрок долазит до TriggerBox вверху лестницы, он оказывается в другой сцене, при этом свойства его персонажа сбрасываются на дефолтные, то есть он вновь масштабируется и ходит, а не лазит. Очевидно, что это требует какой-то системы скриптов. Для этого мы используем тот же язык, на котором написан движок, то есть Typescript с паттерном Script Registry и API для взаимодействия с игрой. -> Парсер и UI используют этот же API. Например, если пользователь ввёл команду Look <объект> или кликнул наэтот объект, вызовется game.look(object_id) +> Парсер и UI используют этот же API. Например, если пользователь ввёл команду Look <объект> или кликнул на этот объект, вызовется game.look(target_id) ## API -Все скрипты регистрируются в `ScriptRegistry` и получают объект контекста `ScriptContext` со следующими аргументами: +Все скрипты регистрируются в `ScriptRegistry`. При выполнении скрипт получает объект `ScriptContext` со следующими полями: + +- `game`: основной экземпляр игры (`Game.instance`); +- `entity`: объект, на котором сработал скрипт, если он есть; +- `api`: экземпляр `ScriptAPI`, то есть компактная script-oriented обёртка над частью runtime API; +- `args`: опциональные дополнительные аргументы. + +Базовый шаблон скрипта: + +```typescript +ScriptRegistry.register('demo.test', ({ game, entity, api, args }) => { + game.showMessage('Script started'); +}); +``` + +### Видимость и модель доступа + +Важно различать **контекст скрипта** и **контекст браузерной консоли**. + +Штатный игровой скрипт работает только с тем, что передано в `ScriptContext` или доступно через эти ссылки. Нормальный способ доступа к сцене и объектам из скрипта: + +- `game` +- `entity` +- `api` +- `game.sceneManager.currentScene` +- `api.getEntity(name)` +- `api.getActor(name)` +- `api.getQuad(name)` +- `game.sceneManager.currentScene?.findEntity(name)` -- `game`: Ссылка на основной экземпляр игры (`Game.instance`). -- `entity`: Ссылка на объект, на котором сработал скрипт (Entity/Actor/Triggerbox). -- `args`: Опциональные дополнительные аргументы. +Вызовы вида: -### Основные методы +```typescript +Hero.walkTo(100, 100); +``` + +не являются нормальным способом использования API. Такой синтаксис относится к debug-видимости объектов в `window` для браузерной консоли. Он может быть полезен для отладки, но не должен использоваться как опора для игровых скриптов. + +### Доступ через `game` + +`game` — это базовый runtime API. Им пользуются не только скрипты, но и parser, компонентные системы и сам движок. + +Основные методы и свойства, полезные в скриптах: + +- `game.showMessage(text: string)`: выводит сообщение в игровую консоль; +- `game.log(text: string)`: выводит сообщение напрямую в буфер консоли; +- `game.text(key: string, params?: Record<string, string | number>)`: получает строку из служебного TA по ключу; +- `game.playSound(filename: string)`: проигрывает звук из `public/sounds`; +- `game.sceneManager.currentScene`: ссылка на текущую сцену; +- `game.sceneManager.switchTo(sceneId: string)`: переключает игру на другую сцену; +- `game.inventory`: массив предметов в инвентаре игрока. + +Пример: + +```typescript +ScriptRegistry.register('door.locked', ({ game }) => { + game.showMessage(game.text('engine.locked_needs', { item: 'keycard' })); +}); +``` + +### Доступ через `api` + +`api` — это удобная script-side обёртка. Она не заменяет `game`, а сокращает наиболее частые операции. + +Методы `ScriptAPI`: + +- `api.log(text: string)`: выводит текст в игровую консоль; +- `api.text(key: string, params?: Record<string, string | number>)`: получает строку из служебного TA; +- `api.getEntity(name: string)`: возвращает объект сцены по имени; +- `api.getActor(name: string)`: возвращает `Actor` по имени; +- `api.getQuad(name: string)`: возвращает `QuadObject` по имени; +- `api.setTimeout(...)`, `api.clearTimeout(...)`: таймеры; +- `api.setInterval(...)`, `api.clearInterval(...)`: интервалы; +- `api.saveCheckpoint()`: сохраняет текущее состояние сцены в undo history редактора. + +`api.text(...)` и `game.text(...)` по сути делают одно и то же. Разница только в форме доступа: -#### Game +- `game.text(...)` — базовый runtime метод; +- `api.text(...)` — его сокращённая обёртка для скриптов. -- `game.showMessage(text: string)`: Выводит сообщение в игровую консоль/UI. -- `game.playSound(filename: string)`: Проигрывает звуковой файл из папки `public/sounds`. -- `game.sceneManager.switchTo(sceneId: string)`: Загружает и переключает на указанную сцену. +Ни `game.text(...)`, ни `api.text(...)` не выводят текст сами по себе. Они только возвращают строку. + +Примеры: + +```typescript +api.log(api.text('scripts.puzzle_solved')); + +const lamp = api.getEntity('lamp'); +const hero = api.getActor('Hero'); +const floor = api.getQuad('floor_main'); +``` -- 'game.look()' +api.getQuad(name) по сути делает: -#### Entity / Actor +1. берёт game.sceneManager.currentScene +2. ищет объект через scene.findEntity(name) +3. проверяет obj.type === 'Quad' +4. возвращает объект или null -- `entity.setSprite(filename: string)`: Меняет спрайт объекта. -- `entity.description = "..."`: Меняет описание объекта (для команды look). -- `actor.setDirection(dir: 'up'|'down'|'left'|'right')`: Поворачивает персонажа. -- `actor.playAnimSet(id: string)`: Переключает набор анимаций (например, на 'talk'). -- `actor.resetAnimSet()`: Возвращает набор анимаций к дефолтному ('idle'/'walk'). -- `actor.walkTo(x, y)`: Заставляет персонажа идти в указанную точку (с учетом Walkbox). -- `actor.stop()`: Останавливает движение. +Упрощённый эквивалент: + +```typescript +function getQuad(game, name) { + const scene = game.sceneManager.currentScene; + if (!scene) return null; + const obj = scene.findEntity(name); + if (obj && obj.type === 'Quad') { + return obj; + } +} +``` + +### Работа с текущей сценой + +Текущая сцена доступна как: + +```typescript +const scene = game.sceneManager.currentScene; +``` + +Основные полезные методы и свойства сцены: + +- `scene.findEntity(name)`: ищет объект по `id`, `customName` или `title` из TA; +- `scene.resolveTarget(targetStr)`: разрешает цель по `id`, `#group` или смешанному списку целей; +- `scene.setTextRedirect(field, targetField)`: устанавливает runtime-переадресацию стандартного текстового поля сцены на кастомное поле из её TA; +- `scene.clearTextRedirect(field)`: сбрасывает переадресацию; +- `scene.activeSubscene`: текущее состояние Subscene; +- `scene.player`: ссылка на персонажа игрока, если он есть. + +#### Text Redirects + +Каждая сцена и объект _в рантайме_ имеют _таблицу переадресации полей_ TA, позволяющую стандартным полям динамически ссылаться на кастомные поля из того же TA. Если переадресации нет, используется стандартное поле. Если целевое поле отсутствует, движок делает fallback на стандартное поле. + +Например, если мы хотим, чтобы описание сцены зависело от времени суток, можно хранить в TA поля `description`, `description_morning`, `description_evening` и переключать `description` скриптом: + +```typescript +const scene = game.sceneManager.currentScene; +scene?.setTextRedirect('description', 'description_evening'); +``` + +Сброс: + +```typescript +scene?.clearTextRedirect('description'); +``` + +Таблица переадресации, как и другие runtime-изменения сцены, сохраняется вместе с сохранённой игрой. + +### Работа с `entity` + +Если скрипт вызван событием конкретного объекта, он получает его в `entity`. + +Основные операции, доступные на уровне `SceneObject`: + +- `entity.setTextRedirect(field, targetField)` +- `entity.clearTextRedirect(field)` +- `entity.description = '...'` +- `entity.customName = '...'` +- `entity.disabled = true/false` +- `entity.visible = true/false` +- `entity.groupID = '#tag'` +- `entity.layer = number` +- `entity.locked = true/false` + +Если `entity` является `Entity` или `Actor`, также доступны типичные визуальные и пространственные свойства: + +- `entity.x`, `entity.y` +- `entity.scale` +- `entity.parallax` +- `entity.opacity` +- `entity.blur` +- `entity.blendMode` +- `entity.setSprite(filename: string, keepSize?: boolean)` + +Пример: + +```typescript +ScriptRegistry.register('interaction.lamp.use', ({ entity }) => { + entity.visible = false; + entity.setTextRedirect('description', 'description_broken'); +}); +``` + +### Работа с `Actor` + +Если объект является `Actor`, для него доступны методы управления движением и анимацией: + +- `actor.setDirection(dir: 'up' | 'down' | 'left' | 'right')` +- `actor.walkTo(x, y)` +- `actor.moveTo(x, y)` +- `actor.stop()` +- `actor.setState(state)` +- `actor.playAnimSet(id: string)` +- `actor.resetAnimSet()` + +Пример: + +```typescript +ScriptRegistry.register('npc.go_to_door', ({ api }) => { + const hero = api.getActor('Hero'); + hero?.walkTo(180, 140); +}); +``` ### Пример скрипта ```typescript ScriptRegistry.register('interaction.pillar.key', ({ game, entity }) => { - game.showMessage('You insert the key into a hidden slot in the pillar.'); + game.showMessage(game.text('scripts.pillar_key_inserted')); game.playSound('secret_reveal.wav'); // Change pillar appearance entity.setSprite('pillar_open'); - entity.description = 'The pillar is open.'; + entity.description = game.text('scripts.pillar_open_description'); }); ``` @@ -353,10 +556,6 @@ export function registerUserScripts() { } ``` -## Текстовые ресурсы - -Наша игра в значительной степени текстовая. Каждый объект или сцена имеет название, а также описания, выдаваемые при различных дейсвиях с объектами, например по методу look ("You see _a desk_"). Есть также тексты, предназначенные не для пользователя, а для SLM/LLM: описывающие возможные действия c предметами, промпты для задания нужной атмосферы и тп. Всё это удобно хранить в виде текстового файла, или файлов. Когда игра загружает сцену и объекты, она читает и текстовые ассеты с ними связанные. - # Редактор cцены Используется для создания/редактирования cцен и объектов. Включается по нажатию клавиши F1. @@ -462,17 +661,18 @@ Prefab можно загрузить в текущую сцену из файл ### 1. Общие (General) -| Сочетание | Действие | Описание | -+-----------+-------------------+---------- | -| **F1** | Toggle Editor | Открыть/Закрыть редактор сцены | -| **F5** | Sprite Editor | Открыть/Закрыть редактор спрайтов | -| **F9** | Settings | Открыть/Закрыть настройки игры | -| **F2** | Smart Save | Быстрое сохранение сцены (по текущему пути) | -| **Shift+F2** | Save As... | Сохранить сцену как (открывает диалог) | -| **F3** | Load Scene | Загрузить сцену | -| **F4** | New Scene | Создать новую сцену | -| **Alt + L** | Lock Object | Заблокировать/разблокировать объект | -| **Ctrl+Z** | Undo/Redo | Отменить последнее действие | +| Сочетание | Действие | Описание | +| ------------ | ------------- | ----------------------------------------- | +| **F1** | Toggle Editor | Открыть/Закрыть редактор сцены | +| **F5** | Sprite Editor | Открыть/Закрыть редактор спрайтов | +| **F9** | Settings | Открыть/Закрыть настройки игры | +| **F2** | Smart Save | Быстрое сохранение сцены (ID = имя файла) | +| **Shift+F2** | Save As... | Сохранить сцену как... (открывает диалог) | +| **F3** | Load Scene | Загрузить сцену | +| **F4** | New Scene | Создать новую сцену | +| **Alt+L** | Lock Object | Заблокировать/разблокировать объект | +| **Ctrl+Z** | Undo | Отменить последнее действие | +| **Ctrl+R** | Redo | Вернуть отменённое действие | ### 2. Работа с объектами (Object Manipulation) diff --git a/public/text/objects/boombox.json b/public/text/objects/boombox.json index a405403..072159a 100644 --- a/public/text/objects/boombox.json +++ b/public/text/objects/boombox.json @@ -1,5 +1,5 @@ { "title": "Boombox", - "description": "An old cheap tape recorder with a radio.", - "details": "A very basic tape recorder is connected to the computer. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape." + "description": "A compact tape recorder with a radio.", + "details": "The Sharp GF-7 boombox is connected to the computer. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape." } diff --git a/public/text/objects/miles_id_1.json b/public/text/objects/miles_id_1.json deleted file mode 100644 index 442b305..0000000 --- a/public/text/objects/miles_id_1.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "your ID card", - "description": "You see nothing special." -} diff --git a/public/text/system/engine.json b/public/text/system/engine.json new file mode 100644 index 0000000..b7ff50c --- /dev/null +++ b/public/text/system/engine.json @@ -0,0 +1,6 @@ +{ + "click_you_see": "You see {title}", + "too_far_generic": "You are too far away.", + "too_far_from_entity": "You are too far away from the {target}.", + "locked_needs": "Locked. Needs {item}" +} diff --git a/public/text/system/parser.json b/public/text/system/parser.json new file mode 100644 index 0000000..8f8e12c --- /dev/null +++ b/public/text/system/parser.json @@ -0,0 +1,16 @@ +{ + "look_default_scene": "You are in {scene}.", + "look_default_object": "You see nothing special about the {target}.", + "look_not_found": "You don't see any {target} here.", + "take_prompt": "Take what?", + "take_pickup_success": "You picked up the {item}.", + "take_cannot": "You cannot take that.", + "inventory_empty": "You are not carrying anything.", + "inventory_items": "You are carrying: {items}", + "use_prompt": "Use what?", + "use_format_prompt": "Use what on what? (Format: USE ITEM ON TARGET)", + "use_missing_item": "You don't have the {item}.", + "use_no_effect_pair": "Using the {item} on the {target} does nothing.", + "use_no_effect_single": "You try to use the {target}, but nothing happens.", + "parse_unknown": "I don't understand." +} diff --git a/public/text/system/scripts.json b/public/text/system/scripts.json new file mode 100644 index 0000000..4fd9d57 --- /dev/null +++ b/public/text/system/scripts.json @@ -0,0 +1,6 @@ +{ + "pillar_key_inserted": "You insert the key into a hidden slot in the pillar.", + "pillar_compartment_opened": "Click! A secret compartment opens!", + "pillar_open_description": "The pillar is open, revealing a secret compartment.", + "test_audio_playing": "Playing test sound..." +} diff --git a/src/core/Game.ts b/src/core/Game.ts index 4636f53..932e7ff 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -152,6 +152,7 @@ export class Game implements IGame { this.assets = new AssetLoader(); this.audio = new AudioManager(); this.textAssets = new TextAssetManager(); + void this.textAssets.preloadServiceAssets(); this.sceneManager = new SceneManager(this); this.editor = new SceneEditor(this); this.spriteEditor = new SpriteEditor(this); @@ -415,6 +416,10 @@ export class Game implements IGame { this.console.log(text); } + text(key: string, params?: Record<string, string | number>): string { + return this.textAssets.getServiceText(key, params); + } + showNotification(text: string): void { if (this.onMessage) { this.onMessage(text); diff --git a/src/core/IGame.ts b/src/core/IGame.ts index 7e61d3b..752bda8 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -15,6 +15,7 @@ export interface IGame { showMessage(text: string): void; log(text: string): void; + text(key: string, params?: Record<string, string | number>): string; showNotification?(text: string): void; // Optional onSceneChange?(sceneName: string): void; playSound(name: string): void; diff --git a/src/core/ScriptAPI.ts b/src/core/ScriptAPI.ts index 814d76c..23eb3cf 100644 --- a/src/core/ScriptAPI.ts +++ b/src/core/ScriptAPI.ts @@ -14,6 +14,10 @@ export class ScriptAPI { this.game.log(message); } + text(key: string, params?: Record<string, string | number>): string { + return this.game.text(key, params); + } + setInterval(handler: TimerHandler, timeout?: number, ...args: any[]): number { const id = setInterval(handler, timeout, ...args); this.intervals.push(id); diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 3b04b75..9f1df12 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -3,9 +3,41 @@ import type { SceneObject } from '../entities/SceneObject'; type TextAssetData = Record<string, string>; +const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { + parser: { + look_default_scene: 'You are in {scene}.', + look_default_object: 'You see nothing special about the {target}.', + look_not_found: "You don't see any {target} here.", + take_prompt: 'Take what?', + take_pickup_success: 'You picked up the {item}.', + take_cannot: 'You cannot take that.', + inventory_empty: 'You are not carrying anything.', + inventory_items: 'You are carrying: {items}', + use_prompt: 'Use what?', + use_format_prompt: 'Use what on what? (Format: USE ITEM ON TARGET)', + use_missing_item: "You don't have the {item}.", + use_no_effect_pair: 'Using the {item} on the {target} does nothing.', + use_no_effect_single: 'You try to use the {target}, but nothing happens.', + parse_unknown: "I don't understand.", + }, + engine: { + click_you_see: 'You see {title}', + too_far_generic: 'You are too far away.', + too_far_from_entity: 'You are too far away from the {target}.', + locked_needs: 'Locked. Needs {item}', + }, + scripts: { + pillar_key_inserted: 'You insert the key into a hidden slot in the pillar.', + pillar_compartment_opened: 'Click! A secret compartment opens!', + pillar_open_description: 'The pillar is open, revealing a secret compartment.', + test_audio_playing: 'Playing test sound...', + }, +}; + export class TextAssetManager { private sceneCache = new Map<string, TextAssetData | null>(); private objectCache = new Map<string, TextAssetData | null>(); + private serviceCache = new Map<string, TextAssetData>(); private normalizeId(id: string): string { return String(id || '') @@ -33,6 +65,14 @@ export class TextAssetManager { return `/text/objects/${this.idToRelativePath(objectId)}.json`; } + private getServiceAssetUrl(domain: string): string { + return `/text/system/${domain}.json`; + } + + private getDefaultServiceDomain(domain: string): TextAssetData { + return { ...(DEFAULT_SERVICE_ASSETS[domain] || {}) }; + } + buildDefaultSceneAsset(scene: Scene): TextAssetData { return { title: scene.name || scene.id || 'Untitled Scene', @@ -118,9 +158,31 @@ export class TextAssetManager { ); } + async preloadServiceAssets(domains?: string[]): Promise<void> { + const targetDomains = domains?.length ? domains : Object.keys(DEFAULT_SERVICE_ASSETS); + await Promise.all(targetDomains.map((domain) => this.readServiceAsset(domain, true))); + } + clearCaches(): void { this.sceneCache.clear(); this.objectCache.clear(); + this.serviceCache.clear(); + } + + async readServiceAsset(domain: string, forceReload: boolean = false): Promise<TextAssetData> { + const normalizedDomain = String(domain || '') + .trim() + .toLowerCase(); + if (!normalizedDomain) return {}; + if (!forceReload && this.serviceCache.has(normalizedDomain)) { + return this.serviceCache.get(normalizedDomain) || {}; + } + + const defaults = this.getDefaultServiceDomain(normalizedDomain); + const loaded = await this.fetchJson(this.getServiceAssetUrl(normalizedDomain)); + const merged = { ...defaults, ...(loaded || {}) }; + this.serviceCache.set(normalizedDomain, merged); + return merged; } getResolvedSceneField(scene: Scene, field: string): string | null { @@ -137,6 +199,38 @@ export class TextAssetManager { return this.resolveField(asset, obj?.textRedirects || null, field, fallback); } + getServiceText(key: string, params?: Record<string, string | number>, fallback?: string): string { + const rawKey = String(key || '').trim(); + if (!rawKey) return fallback || ''; + + const dotIndex = rawKey.indexOf('.'); + if (dotIndex === -1) { + console.warn(`[TextAssetManager] Invalid service text key '${rawKey}'.`); + return fallback || rawKey; + } + + const domain = rawKey.slice(0, dotIndex).toLowerCase(); + const entryKey = rawKey.slice(dotIndex + 1); + if (!entryKey) { + console.warn(`[TextAssetManager] Invalid service text key '${rawKey}'.`); + return fallback || rawKey; + } + + if (!this.serviceCache.has(domain)) { + this.serviceCache.set(domain, this.getDefaultServiceDomain(domain)); + void this.readServiceAsset(domain, true); + } + + const domainAsset = this.serviceCache.get(domain) || {}; + const template = domainAsset[entryKey]; + if (typeof template !== 'string') { + console.warn(`[TextAssetManager] Missing service text '${rawKey}'.`); + return fallback || rawKey; + } + + return this.interpolate(template, params); + } + private resolveField( asset: TextAssetData | null | undefined, redirects: Record<string, string> | null | undefined, @@ -175,6 +269,17 @@ export class TextAssetManager { } } + private interpolate( + template: string, + params?: Record<string, string | number> | null | undefined + ): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_match, token: string) => { + const value = params[token]; + return value === undefined || value === null ? `{${token}}` : String(value); + }); + } + private async ensureFile(filePath: string, content: string): Promise<void> { await fetch('/api/ensure-file', { method: 'POST', diff --git a/src/entities/SceneObject.ts b/src/entities/SceneObject.ts index d79aafd..a7bdb6a 100644 --- a/src/entities/SceneObject.ts +++ b/src/entities/SceneObject.ts @@ -88,6 +88,29 @@ export class SceneObject { }); } + setTextRedirect(field: string, targetField: string): void { + const source = String(field || '').trim(); + const target = String(targetField || '').trim(); + if (!source || !target) return; + this.textRedirects[source] = target; + this.notifyTextRedirectChanged(); + } + + clearTextRedirect(field: string): void { + const source = String(field || '').trim(); + if (!source) return; + if (this.textRedirects[source] === undefined) return; + delete this.textRedirects[source]; + this.notifyTextRedirectChanged(); + } + + private notifyTextRedirectChanged(): void { + const game = (this as any).game; + if (game?.editor?.selectionManager) { + game.editor.selectionManager.notifyObjectChanged(this); + } + } + /** * Checks if a World Coordinate point hits this object. * Base implementation returns false. Subclasses should override. diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 7b8f6da..67e5ece 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -56,7 +56,7 @@ export class Parser { const sceneDescription = this.game.textAssets.getResolvedSceneField(scene, 'description') || scene.description || - `You are in ${scene.name}.`; + this.game.text('parser.look_default_scene', { scene: scene.name }); this.game.log(sceneDescription); } else { const entity = scene.findEntity(noun); @@ -71,10 +71,12 @@ export class Parser { const description = this.game.textAssets.getResolvedObjectField(entity, 'description') || entity.description; - this.game.log(description || `You see nothing special about the ${noun}.`); + this.game.log( + description || this.game.text('parser.look_default_object', { target: noun }) + ); } } else { - this.game.log(`You don't see any ${noun} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: noun })); } } break; @@ -82,7 +84,7 @@ export class Parser { case 'GET': case 'PICKUP': if (!noun) { - this.game.log('Take what?'); + this.game.log(this.game.text('parser.take_prompt')); } else { const entity = scene.findEntity(noun); if (entity) { @@ -115,12 +117,16 @@ export class Parser { if (isItem || entity.isTakeable) { scene.removeEntity(entity); this.game.inventory.push(entity); - this.game.log(`You picked up the ${entity.customName || entity.name}.`); + this.game.log( + this.game.text('parser.take_pickup_success', { + item: entity.customName || entity.name, + }) + ); } else { - this.game.log('You cannot take that.'); + this.game.log(this.game.text('parser.take_cannot')); } } else { - this.game.log(`You don't see any ${noun} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: noun })); } } break; @@ -128,22 +134,22 @@ export class Parser { case 'INVENTORY': case 'I': if (this.game.inventory.length === 0) { - this.game.log('You are not carrying anything.'); + this.game.log(this.game.text('parser.inventory_empty')); } else { const items = this.game.inventory.map((e: any) => e.customName || e.name).join(', '); - this.game.log(`You are carrying: ${items}`); + this.game.log(this.game.text('parser.inventory_items', { items })); } break; case 'USE': if (!noun) { - this.game.log('Use what?'); + this.game.log(this.game.text('parser.use_prompt')); } else { // Check if it's "USE [ID] ON [ID]" vs "USE [ID]" if (noun.includes(' ON ')) { // Parse "USE X ON Y" const parts = noun.split(' ON '); if (parts.length !== 2) { - this.game.log('Use what on what? (Format: USE ITEM ON TARGET)'); + this.game.log(this.game.text('parser.use_format_prompt')); } else { const itemName = parts[0].trim(); const targetName = parts[1].trim(); @@ -153,7 +159,7 @@ export class Parser { (i: any) => (i.customName || i.name).toUpperCase() === itemName.toUpperCase() ); if (!item) { - this.game.log(`You don't have the ${itemName}.`); + this.game.log(this.game.text('parser.use_missing_item', { item: itemName })); } else { // Check if target is in the scene const target = scene.findEntity(targetName); @@ -167,10 +173,15 @@ export class Parser { if (interactionId) { ScriptRegistry.execute(interactionId, { game: this.game, entity: target }); } else { - this.game.log(`Using the ${itemName} on the ${targetName} does nothing.`); + this.game.log( + this.game.text('parser.use_no_effect_pair', { + item: itemName, + target: targetName, + }) + ); } } else { - this.game.log(`You don't see any ${targetName} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: targetName })); } } } @@ -183,16 +194,16 @@ export class Parser { if (interactionId) { ScriptRegistry.execute(interactionId, { game: this.game, entity: entity }); } else { - this.game.log(`You try to use the ${noun}, but nothing happens.`); + this.game.log(this.game.text('parser.use_no_effect_single', { target: noun })); } } else { - this.game.log(`You don't see any ${noun} here.`); + this.game.log(this.game.text('parser.look_not_found', { target: noun })); } } } break; default: - this.game.log("I don't understand."); + this.game.log(this.game.text('parser.parse_unknown')); } } } diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 73724cd..c9f7a27 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -12,6 +12,7 @@ import { toVisualPosition } from '../utils/Parallax'; import { updateSceneCamera } from './SceneCamera'; import { resolveSceneTargets, cleanupClosingSubscene } from './SceneSubscene'; import { handleSceneClick, activateSceneObject } from './SceneInteraction'; +import { useEditorStore } from '../store/editorStore'; export interface SceneScaling { enabled: boolean; @@ -165,6 +166,26 @@ export class Scene { }); } + setTextRedirect(field: string, targetField: string): void { + const source = String(field || '').trim(); + const target = String(targetField || '').trim(); + if (!source || !target) return; + this.textRedirects[source] = target; + this.notifyTextRedirectChanged(); + } + + clearTextRedirect(field: string): void { + const source = String(field || '').trim(); + if (!source) return; + if (this.textRedirects[source] === undefined) return; + delete this.textRedirects[source]; + this.notifyTextRedirectChanged(); + } + + private notifyTextRedirectChanged(): void { + useEditorStore.getState().incrementObjectVersion(); + } + getScaling(y: number): number { if (!this.scaling.enabled) return 1.0; diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index 63b0614..49d5014 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -188,7 +188,7 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { const titleOwner = resolveSubtriggerTarget(scene, subsceneHit); const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); if (title && title.trim()) { - scene.game.log(`You see ${title}`); + scene.game.log(scene.game.text('engine.click_you_see', { title })); } activateSceneObject(scene, subsceneHit); return; @@ -203,7 +203,7 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { if (hitObj) { const title = scene.game.textAssets.getResolvedObjectField(hitObj, 'title'); if (title) { - scene.game.log(`You see ${title}`); + scene.game.log(scene.game.text('engine.click_you_see', { title })); } const isWalkBox = hitObj.components && hitObj.components.some((c) => c.type === 'WalkBox'); @@ -223,7 +223,7 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { const titleOwner = resolveSubtriggerTarget(scene, visibleHitObj); const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); if (title && title.trim()) { - scene.game.log(`You see ${title}`); + scene.game.log(scene.game.text('engine.click_you_see', { title })); return; } } diff --git a/src/scripts/DemoScripts.ts b/src/scripts/DemoScripts.ts index 89097c8..a56127f 100644 --- a/src/scripts/DemoScripts.ts +++ b/src/scripts/DemoScripts.ts @@ -3,11 +3,11 @@ import { ScriptRegistry } from '../core/ScriptRegistry'; // We can improve types later to avoid 'any' export function registerDemoScripts() { ScriptRegistry.register('interaction.pillar.key', ({ game, entity }) => { - game.showMessage('You insert the key into a hidden slot in the pillar.'); - game.showMessage('Click! A secret compartment opens!'); + game.showMessage(game.text('scripts.pillar_key_inserted')); + game.showMessage(game.text('scripts.pillar_compartment_opened')); // Update entity state - entity.description = 'The pillar is open, revealing a secret compartment.'; + entity.description = game.text('scripts.pillar_open_description'); // Example of a permanent state change (we'll adding a real state system later) // game.state.set('pillar_opened', true); @@ -18,7 +18,7 @@ export function registerDemoScripts() { }); ScriptRegistry.register('test.audio', ({ game }) => { - game.showMessage('Playing test sound...'); + game.showMessage(game.text('scripts.test_audio_playing')); game.playSound('drawer_open.wav'); // Ensure it exists in public/sounds }); } diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index 4609372..46e065d 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -95,7 +95,8 @@ export class ComponentSystem { // Called when trying to TAKE an item // Returns string (error message) or null (success) static canTakeItem(entity: SceneObject, player: Actor | null): string | null { - if (!entity.components) return 'You cannot take that.'; + const game = (entity as any).game as IGame | undefined; + if (!entity.components) return game?.text('parser.take_cannot') || 'You cannot take that.'; const itemComp = entity.components.find((c: any) => c.type === 'Item') as | ItemComponent @@ -112,7 +113,10 @@ export class ComponentSystem { const allowedDist = (player.width || 30) * 4; // Tolerance if (dist > allowedDist) { - return `You are too far away from the ${entity.name}.`; + return ( + game?.text('engine.too_far_from_entity', { target: entity.name }) || + `You are too far away from the ${entity.name}.` + ); } } @@ -178,7 +182,7 @@ export class ComponentSystem { if (dist > allowedDist) { const game = scene.game as unknown as IGame; if (game && typeof game.showMessage === 'function') { - game.showMessage('You are too far away.'); + game.showMessage(game.text('engine.too_far_generic')); } return true; // Blocked } @@ -208,7 +212,7 @@ export class ComponentSystem { (i) => i.name === sw.idKey || (i as unknown as { id?: string }).id === sw.idKey ); if (!hasKey) { - game.showMessage(`Locked. Needs ${sw.idKey}`); + game.showMessage(game.text('engine.locked_needs', { item: sw.idKey })); return true; // Handled (Blocked) } } From 0d9d643b344fc7177454d0efd89ad3736d6b538a Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 00:59:18 +0200 Subject: [PATCH 03/75] Chore: exclude Markdown from pre-commit formatting Remove .md from the lint-staged prettier rule so Husky no longer rewrites Markdown files during pre-commit. Keep JSON/CSS/SCSS formatting and TypeScript checks unchanged. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 34ea177..1635138 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,6 @@ "prettier --write", "eslint --max-warnings=0 --fix" ], - "*.{json,md,css,scss}": "prettier --write" + "*.{json,css,scss}": "prettier --write" } } From 9f41fca859a4deeb34298fe2e03291983c53cdbe Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 02:17:24 +0200 Subject: [PATCH 04/75] Fix: restore click-to-walk through passive scene objects Only block movement when a clicked object actually handles activation or has a resolved title to show. Passive Static/Quad hits without TA titles no longer swallow mouse movement commands. --- src/scene/SceneInteraction.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index 49d5014..662f03b 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -159,19 +159,22 @@ function resolveSubtriggerTarget(scene: Scene, obj: SceneObject): SceneObject { return target || obj; } -export function activateSceneObject(scene: Scene, obj: SceneObject, depth: number = 0): void { +export function activateSceneObject(scene: Scene, obj: SceneObject, depth: number = 0): boolean { if (depth > 5) { console.warn('[Scene] Recursion limit reached.'); - return; + return false; } if (ComponentSystem.handleActivation(obj, scene, depth)) { - return; + return true; } if (obj instanceof Triggerbox && obj.script) { // Intentionally silent: triggering handled by systems/scripts + return true; } + + return false; } export function handleSceneClick(scene: Scene, x: number, y: number): void { @@ -201,19 +204,16 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { const hitObj = findTopHitObject(scene, x, y); if (hitObj) { - const title = scene.game.textAssets.getResolvedObjectField(hitObj, 'title'); + const titleOwner = resolveSubtriggerTarget(scene, hitObj); + const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + const activated = activateSceneObject(scene, hitObj); + if (title) { scene.game.log(scene.game.text('engine.click_you_see', { title })); + return; } - const isWalkBox = hitObj.components && hitObj.components.some((c) => c.type === 'WalkBox'); - const isMechanism = - hitObj.components && - hitObj.components.some((c) => ['Switch', 'Subscene', 'Subtrigger'].includes(c.type)); - const hasScript = hitObj instanceof Triggerbox && hitObj.script && hitObj.script.length > 0; - - if (!(isWalkBox && !isMechanism && !hasScript)) { - activateSceneObject(scene, hitObj); + if (activated) { return; } } From ddde5ad9b2c8a1552077e123b942f3c33331ab00 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 03:05:21 +0200 Subject: [PATCH 05/75] Fix: stabilize click movement across parallax changes Resolve mouse-walk targeting for actors on non-1.0 parallax layers and keep click movement stable when 3d-parallax updates actor parallax during motion. Mouse clicks now drive actors through a visual-space target that is re-projected each frame using the current parallax, while script-driven walkTo/moveTo remain world-space and backward compatible. --- src/entities/Actor.ts | 37 +++++++++++++++++++++++++++++------ src/scene/SceneInteraction.ts | 29 ++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/entities/Actor.ts b/src/entities/Actor.ts index 78d9866..f4dde7d 100644 --- a/src/entities/Actor.ts +++ b/src/entities/Actor.ts @@ -2,6 +2,7 @@ import { Entity, type EntityData } from './Entity'; import { Animator } from '../core/Animator'; import { useEditorStore } from '../store/editorStore'; import type { IGame } from '../core/IGame'; +import { toWorldPosition } from '../utils/Parallax'; export type ActorState = 'idle' | 'walk' | 'talk' | 'interact' | string; export type ActorDirection = 'up' | 'down' | 'left' | 'right'; @@ -34,6 +35,7 @@ export class Actor extends Entity { speed: number; target: { x: number; y: number } | null; + visualTarget: { x: number; y: number } | null; readonly type: string = 'Actor'; isPlayer: boolean = false; @@ -63,6 +65,7 @@ export class Actor extends Entity { this.state = 'idle'; this.speed = 0.1; this.target = null; + this.visualTarget = null; this.isPlayer = false; this.animSets = {}; @@ -130,12 +133,21 @@ export class Actor extends Entity { moveTo(x: number, y: number): void { this.target = { x, y }; + this.visualTarget = null; + this.setState('walk'); + this.overrideAnimSet = null; + } + + moveToVisual(x: number, y: number): void { + this.visualTarget = { x, y }; + this.target = null; this.setState('walk'); this.overrideAnimSet = null; } stop(): void { this.target = null; + this.visualTarget = null; this.setState('idle'); } @@ -159,9 +171,22 @@ export class Actor extends Entity { this.handlePlayerInput(deltaTime, isWalkable); } - if (this.state === 'walk' && this.target) { - const dx = this.target.x - this.x; - const dy = this.target.y - this.y; + if (this.state === 'walk' && (this.target || this.visualTarget)) { + const currentTarget = this.visualTarget + ? toWorldPosition( + this.visualTarget, + this.scene?.camera || { x: 0, y: 0 }, + this.parallax !== undefined ? this.parallax : 1.0 + ) + : this.target; + + if (!currentTarget) { + this.stop(); + return; + } + + const dx = currentTarget.x - this.x; + const dy = currentTarget.y - this.y; const dist = Math.sqrt(dx * dx + dy * dy); const p = this.parallax !== undefined ? this.parallax : 1.0; @@ -170,8 +195,8 @@ export class Actor extends Entity { const step = this.speed * speedScale * deltaTime; if (dist <= step) { - this.x = this.target.x; - this.y = this.target.y; + this.x = currentTarget.x; + this.y = currentTarget.y; this.stop(); } else { const moveX = (dx / dist) * step; @@ -258,7 +283,7 @@ export class Actor extends Entity { this.y = nextY; } } - } else if (!this.target) { + } else if (!this.target && !this.visualTarget) { this.setState('idle'); } } diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index 662f03b..56802f9 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -15,6 +15,22 @@ function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { }; } +function toWorldForParallax( + scene: Scene, + x: number, + y: number, + parallax: number = 1.0 +): { x: number; y: number } { + const screenW = 420; + const screenH = 300; + const halfW = screenW / 2; + const halfH = screenH / 2; + return { + x: (x - halfW) / scene.camera.zoom + scene.camera.x * parallax, + y: (y - halfH) / scene.camera.zoom + scene.camera.y * parallax, + }; +} + function findVisibleHitObject(scene: Scene, screenX: number, screenY: number): SceneObject | null { const screenW = 420; const screenH = 300; @@ -229,10 +245,17 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { } if (scene.player) { - if (typeof scene.player.walkTo === 'function') { - scene.player.walkTo(world.x, world.y); + const visualTarget = toWorld(scene, x, y); + if (typeof (scene.player as any).moveToVisual === 'function') { + (scene.player as any).moveToVisual(visualTarget.x, visualTarget.y); + } else if (typeof scene.player.walkTo === 'function') { + const playerParallax = scene.player.parallax !== undefined ? scene.player.parallax : 1.0; + const playerTarget = toWorldForParallax(scene, x, y, playerParallax); + scene.player.walkTo(playerTarget.x, playerTarget.y); } else if (typeof scene.player.moveTo === 'function') { - scene.player.moveTo(world.x, world.y); + const playerParallax = scene.player.parallax !== undefined ? scene.player.parallax : 1.0; + const playerTarget = toWorldForParallax(scene, x, y, playerParallax); + scene.player.moveTo(playerTarget.x, playerTarget.y); } } } From fc7d59b6900b8e2084117b9cf3a31d0d996628a7 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 03:07:14 +0200 Subject: [PATCH 06/75] Documentation update --- GDD.md | 50 +++++++++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/GDD.md b/GDD.md index f42e810..c05d21b 100644 --- a/GDD.md +++ b/GDD.md @@ -8,7 +8,7 @@ # Текстовый интерфейс -Наша игра продолжает традиции классических Adventure, которые когда-то были полностью текстовыми. Кроме того, нарратив связан с темой компьютеров. Поэтому у нас есть классическая "консоль терминала" со строкой ввода команд и областью вывода сообщений над ней (буфер консоли). В этот буфер выводятся все введенные пользователем команды и все игровые сообщения (за исключением неигровых, служебных уведомлений движка и редактора сцены, которые выводятся через toast notifications). +Наша игра продолжает традиции классических Adventure, которые изначально когда-то были полностью текстовыми. Кроме того, нарратив связан с темой компьютеров. Поэтому у нас есть классическая "консоль терминала" со строкой ввода команд и областью вывода сообщений над ней (буфер консоли). В этот буфер выводятся все введенные пользователем команды и все игровые сообщения (за исключением неигровых, служебных уведомлений движка и редактора сцены, которые выводятся через toast notifications). Есть два основных формата пользовательского ввода: - **Команда**: указание что нужно сделать, напр. "открой дверь ключом"; @@ -23,8 +23,8 @@ - **закрытое модальное**; - **открытое**. В _закрытом_ состоянии пользователь видит только последние две строки буфера консоли в нижней части экрана, и под ними строку ввода команды. - При нажатии на специальную клавишу на клавиатуре (тильда ~) консоль _открывается_ поверх картинки, почти на весь игровой экран, накладываясь на него с небольшой полупрозрачностью. При этом, в закрытом виде консоль и строка ввода интегрированы в игровую картинку, то есть рисуются на low-res 2d канвасе и поверх накладывается CRT фильтр. В открытом же виде консоль рисуется поверх игровой картинки, в том же слое, что UI редактора, в высоком разрешении, без CRT фильтра,чтобы пользователям было комфортно читать текст. Строка ввода работает и в открытом состоянии, так что пользователи могут вводить команды в консоль не закрывая её. - Для показа важных сообщений, которые не влазят в 2 строки закрытой консоли, она может переходить в _модальный_ режим, когда командная строка убирается, а если текст сообщения не помещается и в три строки, то высота области буфера увеличивается на нужное число строк, чтобы текст сообщения выводился поверх картинки. В модальном режиме после текста сообщения всегда идёт надпись "[Continue]" и ожидается нажатие любой клавиши или клик мыши, после чего происходит переход в обычный режим. + При нажатии на специальную клавишу на клавиатуре (тильда ~) консоль _открывается_ поверх картинки, почти на весь игровой экран, накладываясь на него с небольшой полупрозрачностью. При этом, в закрытом виде консоль и строка ввода интегрированы в игровую картинку, то есть рисуются на low-res канвасе и поверх накладывается CRT фильтр. В открытом же виде консоль рисуется поверх игровой картинки, в том же слое, что UI редактора, в высоком разрешении, без CRT фильтра,чтобы пользователям было комфортно читать текст. Строка ввода работает и в открытом состоянии, так что пользователи могут вводить команды в консоль не закрывая её. + Для показа важных сообщений, которые не влазят в 2 строки закрытой консоли, она может переходить в _модальный_ режим, когда командная строка убирается, а если текст сообщения не помещается и в три строки, то высота области буфера увеличивается на нужное число строк, чтобы текст сообщения выводился поверх картинки. В модальном режиме после текста сообщения всегда идёт мигающая надпись "[Continue]" и ожидается нажатие любой клавиши или клик мыши, после чего происходит переход в обычный режим. Открытая консоль не переходит в модальный режим. Текст открытой консоли можно прокручивать колесом мыши или клавишами Page Up/Down чтобы увидеть более ранние сообщения. Буфер должен быть достаточно большим, порядка 150 Kb. При сохранении игры в файл буфер сохраняется вместе с игрой. @@ -36,12 +36,14 @@ ## Парсер - посредник -Парсер играет роль **посредника** между движком игры и игроком, своеобразного гейм-мастера. Он принимает пользовательский ввод, наряду с контекстом (информацией о сцене, находящихся в ней предметах и NPC, доступны действиях и состояниях). Затем парсер обрабатывает это и даёт команды игровому движку через API, опционально получает возвращаемые API значения и составляет сообщения для пользователя. +Парсер играет роль **посредника** между движком игры и игроком, своеобразного гейм-мастера. Он принимает пользовательский ввод, наряду с контекстом (информацией о сцене, находящихся в ней предметах и NPC, доступныx действиях и состояниях). Затем парсер обрабатывает это и даёт команды игровому движку через API, опционально получает возвращаемые API значения и составляет сообщения для пользователя. + +<Context> ---json---> | | | | +| | ---text--> | <Parser> | ---json--> | <API> | +| <User> | <--text--- | | <--------- | | +| | + -<Context> ---json--> | | | | -| | ---text--> | <Parser> | ---json--> | <API> | -| <User> | <--text--- | | <--------- | | -| | Parser обрабатывает пользовательский ввод каскадно, если каскад не смог обработать команду, она передаётся следующему: @@ -69,11 +71,16 @@ Parser обрабатывает пользовательский ввод кас Сцена это отдельная локация, в которой находится персонаж игрока, и другие объекты. Может занимать один физический экран, либо быть больше его. Сцена может содержать _объекты_ следующих типов: -- _WalkBox_: замкнутый многоугольник, определяющий область, в которой можно перемещаться персонажем игрока (или NPC). Несколько WalkBox могут быть на одной сцене и взаимодействовать друг с другом, в зависимости от их типа: add, substract, invert; -- _TriggerBox_: замкнутый многоугольник, определяющий область, активирующую какие-то события и сюжетную логику, например коллайдер, попав в который персонаж игрока проваливается в люк, переносится в другую сцену, запускает диалог с NPC и т.п; +- _WalkBox_: замкнутый многоугольник, определяющий служебную область, в которой можно перемещаться персонажем игрока (или NPC). Несколько WalkBox могут быть на одной сцене и взаимодействовать друг с другом, в зависимости от их типа: add, substract, invert; + +- _TriggerBox_: замкнутый многоугольник, определяющий служебную область, активирующую какие-то события и сюжетную логику, например коллайдер, попав в который персонаж игрока проваливается в люк, переносится в другую сцену, запускает диалог с NPC и т.п; + - _Static_: прямоугольник с координатами X/Y, размерами X/Y, цветом заполнения и опционально спрайтом/анимацией, отображающимся вместо прямоугольника. Спрайт можно переключать на лету. В основном Static это фоны, декоративные элементы и предметы, которые не перемещаются. + - _Actor_: объект, который помимо свойств Static имеет направление, в котором он повёрнут и, опционально, спрайты/анимации состояний (idle, walk, talk, etc), причём для каждого направления свой набор. Обычно Actor это NPC и анимированные объекты. Персонаж игрока также является разновидностью Actor. +- _Quad_ : четырёхугольный объект, каждая вершина которого обладает отдельным параллаксом. Используется для создания псевдо 3d поверхностей и эффектов типа лучей света и теней. + ### ID Каждая сцена и каждый объект имеют свой уникальный _ID_, который используется для ссылок на них. При этом: @@ -81,7 +88,7 @@ Parser обрабатывает пользовательский ввод кас 1. id (содержимое поля id/file) для сцен, спрайтов, и также префабов (т.е. сохранённых объектов) может включать один или несколько обратных слешей "\". При сохранении такого объекта слеши работают как маркеры подпапок (относительно дефолтной папки для данного типа объектов), например "home\room1" сохранится как файл room.json в папке home. 2. При загрузке такого объекта его id не читается из файла, а формируется с учётом пути относительно дефолтной папки и имени файла, таким образом этот объект загрузится c id "home\room1" а не "room1". Соответственно, если пользователь нажмёт "Save", объект сохранится не в дефолтной папке а в подпапке home как room1. 3. При завершении загрузки объекта сформированный id дополнительно проверяется на предмет совпадения с уже имеющимися в сцене. Если это не уникальный id, то он дополняется до уникального. -4. API при создании объекта или загрузки сцены получает id, трактует его как имя файла с возможным учётом подпапок и загружает его оттуда. +4. Игровой движок при создании объекта или загрузки сцены получает id, трактует его как имя файла с возможным учётом подпапок и загружает его оттуда. ### Свойства сцены @@ -91,21 +98,21 @@ Parser обрабатывает пользовательский ввод кас Сцена имеет свойство, определяющее _положение "камеры"_ (viewport), т.е. задаёт какая область сцены будет отображаться на экране и с каким зумом. Например, при приближении персонажа игрока к краю экрана сцена скроллится. По умолчанию камера позиционируется на персонаже игрока, но позиционированием можно управлять и динамически, например если игрок выходит из дома на улицу, то масштаб изображения может уменьшиться кастомной логикой (скриптом) этой сцены, отдалив камеру чтобы передать ощущение большого открытого пространства. Чтобы облегчить манипуляции с камерой, сцена имеет два значения параметра zoom: дефолтный и текущий. Дефолтный zoom задаётся при создании сцены и применяется при её загрузке, а текущий -- изменяется динамически во время игры и при редактировании. -Важно отметить, что все свойства сцены и всех объектов должны быть доступны для изменения не только в редакторе, но и динамически прямо во время игры, со стороны игровой логики (скриптов). Примерно как свойства в Unity или Unreal Engine. +Важно отметить, что все свойства сцены и всех объектов доступны для изменения не только в редакторе, но и динамически прямо во время игры, со стороны игровой логики (скриптов). Примерно как свойства в Unity или Unreal Engine. ## Объекты сцены ### Структура классов -С точки зрения кода класс _SceneObject_ является прародительским для всех объектов сцены в игре, включая Static, Actor, а также полигональные объекты TriggerBox и WalkBox. +С точки зрения ООП класс _SceneObject_ является прародителем для всех объектов сцены в игре, включая Static, Actor, Quad, а также полигональные служебные области TriggerBox и WalkBox, которые в игре не видны. SceneObject ├── PolygonObject -│ ├── Walkbox -│ └── Triggerbox +│ ├── Walkbox +│ └── Triggerbox ├── QuadObject └── Entity (≈ Static) -└── Actor + └── Actor ### Свойства объектов SceneObject @@ -171,7 +178,8 @@ _Vertices_ Quad имеет 4 вершины. У каждой вершины свои координаты X, Y и свой коэффициент Parallax (P). Это позволяет создавать объекты, которые корректно деформируются при движении камеры, имитируя 3D перспективу. Например, "пол" будет иметь вершины с разным параллаксом: ближние к камере P > 1, дальние P < 1. _Retro Grid Mode_ -Quad может отображаться как "сетка" (Retro-Grid), что соответствует стилистике ретро-футуризма 80х. Настраивается цвет линий, толщина и количество ячеек сетки. Этот режим не отменяет заливку цветом и может использоваться одновременно с ней. +Quad может отображаться как сетка линий (Retro-Grid) в стиле компьютерной графики 80х. Настраивается цвет линий, толщина и количество ячеек сетки. Этот режим не отменяет заливку цветом и может использоваться одновременно с ней. +Помимо эстетической, Retro-Grid несёт и функциональную роль, играя роль сетки для выравнивания объектов относительно друг-друга. Её узлы могут служить точками привязки (когда объект перетаскивается с зажатым <Alt>) наряду с вершинами Quad и Entity. _Sort Mode_ (v0, v1, v2, v3, ignore) Определяет точку сортировки (Z-Sort) для объекта. Поскольку Quad может быть сильно вытянут в глубину (наподобие пола), его центр может быть некорректной точкой для сортировки относительно других объектов (например, персонажа стоящего на этом полу). Режим сортировки позволяет привязать Z-индекс к конкретной вершине (например, самой дальней). @@ -309,7 +317,7 @@ Static и Actor могут содержать скриптовые событи - работа с анимациями объектов (например, персонаж садится на стул); - и тд. -Комплексный пример: игрок подходит к стене, на которой есть кнопка. Если игрок находится рядом с кнопкой и отдаёт команду нажать на неё, сверху спускается лестница, после чего становится доступна новая команда: "лезь по лестнице". Когда игрок переходит в режим лазания по лестнице, то обычный WalkBox отключается, а включается WalkBox для лестницы, который позволяет персонажу перемещаться лишь вверх и вниз. Кроме того, у персонажа игрока заменяются анимации walk для ходьбы вверх и вниз на анимации лазания вверх и вниз по лестнице, а ещё устанавливается запрет на Depth-scaling, чтобы поднимаясь по лестнице персонаж не уменьшался в размере. Когда игрок долазит до TriggerBox вверху лестницы, он оказывается в другой сцене, при этом свойства его персонажа сбрасываются на дефолтные, то есть он вновь масштабируется и ходит, а не лазит. +Комплексный пример: игрок подходит к стене, на которой есть кнопка. Если игрок отдаёт команду "push the button", сверху спускается лестница, после чего становится доступна новая команда: "climb the ladder". Когда игрок переходит в режим лазания по лестнице, то обычный WalkBox отключается, а включается WalkBox для лестницы, который позволяет персонажу перемещаться лишь вверх и вниз. Кроме того, у персонажа игрока заменяются анимации walk для ходьбы вверх и вниз на анимации лазания вверх и вниз по лестнице, а ещё устанавливается запрет на Depth-scaling, чтобы поднимаясь по лестнице персонаж не уменьшался в размере. Когда игрок долазит до TriggerBox вверху лестницы, он оказывается в другой сцене, при этом свойства его персонажа сбрасываются на дефолтные, то есть он вновь масштабируется и ходит, а не лазит. Очевидно, что это требует какой-то системы скриптов. Для этого мы используем тот же язык, на котором написан движок, то есть Typescript с паттерном Script Registry и API для взаимодействия с игрой. @@ -556,7 +564,11 @@ export function registerUserScripts() { } ``` -# Редактор cцены + + + + +# Редактор cцены ################################# Используется для создания/редактирования cцен и объектов. Включается по нажатию клавиши F1. Визуально отображается как набор UI элементов за пределами пользовательского игрового экрана: From dc67afd1c0c29a2640a133cd102ec06ce3f25fa5 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 9 Mar 2026 19:25:42 +0200 Subject: [PATCH 07/75] Feature: add parser mediator v1 Introduce the first parser-mediator pipeline with explicit context, action, and result JSON stages for LOOK, TAKE, and INVENTORY commands. Separate console commands from gameplay parsing, switch console tooling to #RUN/#HALT/#HELP/#CLS, and add #PEEK-ON/#PEEK-OFF for parser execution tracing. Filter parser context to text-relevant entities, update test scene/text assets used for validation, and extend GDD with the current parser architecture, executor contract, console commands, and near-term roadmap. --- GDD.md | 171 +++++++++ public/scenes/test_room.json | 62 +-- public/text/objects/Static_723.json | 4 + public/text/objects/logo_1.json | 2 +- public/text/scenes/test_room.json | 4 + src/components/UIOverlay.tsx | 18 +- src/core/Console.ts | 21 +- src/mechanics/Parser.ts | 568 +++++++++++++++++++--------- 8 files changed, 638 insertions(+), 212 deletions(-) create mode 100644 public/text/objects/Static_723.json create mode 100644 public/text/scenes/test_room.json diff --git a/GDD.md b/GDD.md index c05d21b..14e6d9c 100644 --- a/GDD.md +++ b/GDD.md @@ -34,6 +34,20 @@ Доступна история команд (хранится порядка 50 последних команд), переключаться между которыми можно курсорными стрелками вверх/вниз с зажатой клавишей <Ctrl>. История команд сохранятеся вместе с игрой. Также можно быстро очистить строку ввода, нажав <Ctrl>+<Backspace>. +### Служебные команды консоли + +Помимо внутриигровых команд, игровая консоль поддерживает _служебные команды_, позволяющие разработчикам игры запускать скрипты, получать отладочную информацию и тп. Служебные команды консоли имеют специальный формат и начинаются с символа `#`. Они перехватываются препроцессором консоли до игрового парсера, доступны только тогда, когда доступен редактор сцены, то есть в пользовательском билде игры их нет. + +Поддерживаются следующие служебные команды: + +- `#RUN <script_id> [args...]` : запускает скрипт из реестра скриптов; +- `#HALT` : останавливает все запущенные скрипты; +- `#HALT <script_id>` : останавливает конкретный скрипт; +- `#HELP` : выводит список доступных служебных команд; +- `#CLS` : очищает буфер консоли; +- `#PEEK-ON` : включает режим отладки parser-mediator, при котором после каждой игровой команды в консоль выводятся `Context JSON`, `Action JSON` и `Result JSON`; +- `#PEEK-OFF` : отключает этот режим. + ## Парсер - посредник Парсер играет роль **посредника** между движком игры и игроком, своеобразного гейм-мастера. Он принимает пользовательский ввод, наряду с контекстом (информацией о сцене, находящихся в ней предметах и NPC, доступныx действиях и состояниях). Затем парсер обрабатывает это и даёт команды игровому движку через API, опционально получает возвращаемые API значения и составляет сообщения для пользователя. @@ -56,6 +70,155 @@ Parser обрабатывает пользовательский ввод кас > В данный момент Parser находится в зачаточном состоянии, реализован только первый каскад, и мы отлаживаем на нём взаимодействие с API. Впоследствии остальные каскады будут использовать ту же систему. +### Текущее состояние архитектуры parser v1 + +На текущем этапе parser уже начал переход от прямого command handler к форме **parser-mediator**, но пока остаётся только в первой, самой простой итерации. + +Сейчас это устроено так: + +- строка ввода сначала разделяется на **console commands** и **gameplay commands**; служебные команды консоли в формате `#...` не проходят через gameplay parser; +- gameplay parser получает пользовательский ввод и собирает **Context JSON** — упрощённый снимок текстово значимой части текущей сцены и инвентаря; +- первый каскад остаётся простым regexp/switch parser и строит **Action JSON**; +- отдельный executor обрабатывает этот Action JSON, вызывает API/методы движка и возвращает **Result JSON**; +- затем первый каскад получает Result JSON обратно и либо формирует итоговый ответ игроку, либо выдаёт временный debug handoff для будущих старших каскадов. + +Таким образом, даже при очень простом первом каскаде уже существует правильная форма взаимодействия: + +`input -> context json -> action json -> engine execution -> result json -> player response` + +В первой версии через этот путь проходят только базовые команды: + +- `LOOK` +- `LOOK <target>` +- `LOOK AROUND / HERE / SCENE` +- `TAKE / GET / PICKUP <target>` +- `INV / INVENTORY / I` + +Команда `USE`, speech input (реплики с `-`) и старшие каскады пока в эту версию не входят. + +### Текущая реализация executor + +На этом этапе executor является отдельным runtime-слоем, который получает от parser готовый `Action JSON`, исполняет его через API движка и возвращает parser структурированный `Result JSON`. + +Сейчас executor поддерживает только ограниченный набор action-типов: + +- `lookScene` +- `lookEntity` +- `takeEntity` +- `showInventory` +- `handoff` + +Примеры `Action JSON`, которые parser может передать executor: + +```json +{ + "stage": "regex-v1", + "actions": [ + { "type": "lookScene" } + ], + "debug": { + "rawInput": "look", + "normalizedInput": "LOOK", + "verb": "LOOK", + "noun": "" + } +} +``` + +```json +{ + "stage": "regex-v1", + "actions": [ + { "type": "lookEntity", "target": "lamp" } + ], + "debug": { + "rawInput": "look lamp", + "normalizedInput": "LOOK LAMP", + "verb": "LOOK", + "noun": "lamp" + } +} +``` + +```json +{ + "stage": "regex-v1", + "actions": [ + { "type": "takeEntity", "target": "key" } + ], + "debug": { + "rawInput": "take key", + "normalizedInput": "TAKE KEY", + "verb": "TAKE", + "noun": "key" + } +} +``` + +Примеры `Result JSON`, которые executor возвращает parser: + +```json +{ + "type": "message", + "handled": true, + "messages": ["You are in New Scene."], + "actionsExecuted": ["lookScene"] +} +``` + +```json +{ + "type": "message", + "handled": true, + "messages": ["You picked up key."], + "actionsExecuted": ["takeEntity"] +} +``` + +```json +{ + "type": "scriptDelegated", + "handled": true, + "messages": [], + "actionsExecuted": ["lookEntity"], + "delegatedScriptId": "look_lamp" +} +``` + +```json +{ + "type": "handoff", + "handled": false, + "messages": [], + "actionsExecuted": [], + "reason": "unsupported_by_stage1", + "debug": { + "actionJson": "...", + "action": { "type": "handoff", "reason": "unsupported_by_stage1" } + } +} +``` + +После получения `Result JSON` parser на текущем этапе может сделать одно из трёх действий: + +1. Взять готовое сообщение из `result.messages[0]` и вывести его в игровую консоль. +2. Ничего не выводить напрямую, если исполнение было делегировано скрипту, а сам скрипт уже формирует игровой вывод. +3. При `handoff` выдать игроку fallback-сообщение и, в debug-режиме, напечатать в консоль служебный отчёт (`Context JSON`, `Action JSON`, `Result JSON`) для следующего каскада. + +### Ближайшее направление развития + +Первый приоритет сейчас — не усложнение распознавания языка, а укрепление parser именно как посредника. + +Ближайшие шаги развития: + +1. Расширять и стабилизировать JSON-контракты между context builder, first-stage parser, executor и response builder. +2. Постепенно переносить в executor всю игровую логику, чтобы первый каскад оставался только интерпретатором ввода, а не прямым caller-ом runtime API. +3. Добавить обработку новых команд первого каскада поверх той же схемы `Action JSON -> Result JSON`, начиная с `USE`. +4. После стабилизации этого контура подключать второй каскад, который будет получать тот же Context JSON и тот же Result JSON, но уже пытаться разбирать более свободный ввод. +5. Лишь после этого подключать тяжёлый LLM/SLM каскад и speech/dialogue routing, чтобы они встраивались в уже готовую схему, а не диктовали архитектуру снизу вверх. + +Иными словами, ближайшая цель — сделать первый каскад не умнее, а **архитектурно правильнее**, чтобы все последующие каскады подключались к уже работающему посреднику. + # Основные элементы игры ## Спрайт @@ -279,6 +442,8 @@ Static и Actor могут содержать скриптовые событи > Примечание: События _Always_ и _OnCollide_ зарезервированы в дизайне, но на текущий момент технически не реализованы в движке. + + ## Текстовые ассеты (TA) Наша игра в значительной степени текстовая. Каждый объект или сцена имеет название, а также описания, выдаваемые при различных действиях с объектами, например по методу look ("You see _a desk_"). Есть также тексты, предназначенные не для пользователя, а для SLM/LLM: описывающие возможные действия c предметами, промпты для задания нужной атмосферы и тп. Всё это удобно хранить в виде json файлов. Когда игра загружает сцену и объекты, она читает и текстовые ассеты с ними связанные. @@ -290,10 +455,16 @@ Static и Actor могут содержать скриптовые событи Поскольку ID могут быть составными, ссылаясь на файлы в подпапках, соответствующие TA тоже могут находиться в подпапках. Например: 'public\text\scenes\home\room.json' для сцены 'home\room' +Для сцен соответствующие TA создаются автоматически: + - при создании новой сцены: если нет TA, файл создаётся для дефолтного id новой сцены + - при сохранении сцены: если нет TA с новым id, но был для старого, старый TA копируется в новый + + Текстовый asset содержит стандартные (используемые движком) поля, а также может содержать дополнительные (кастомные). > сейчас стандартными текстовыми полями считаются `title` и `description`. + Кроме текстовых ассетов сцен и объектов, в проекте есть и **служебные TA** для строк самого движка, парсера, UI и скриптов. Они хранятся отдельно, в `public\text\system\`, разбиваются по доменам (`parser.json`, `engine.json`, `scripts.json`, etc) и адресуются по строковым ключам вида `parser.take_prompt` или `engine.click_you_see`. Служебные TA не имеют таблицы переадресации. Это просто словари строк, доступных по ключу. В строках служебных TA допускаются именованные плейсхолдеры, например `{item}` или `{title}`, которые заполняются вызывающим кодом. diff --git a/public/scenes/test_room.json b/public/scenes/test_room.json index f0460bc..991da00 100644 --- a/public/scenes/test_room.json +++ b/public/scenes/test_room.json @@ -1,6 +1,8 @@ { "id": "test_room", "name": "New Scene", + "description": "You are in New Scene.", + "textRedirects": {}, "filename": "test_room", "walkbox": [], "triggerboxes": [], @@ -19,6 +21,7 @@ "disabled": false, "groupID": "#g,#a", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -72,6 +75,7 @@ "disabled": false, "groupID": "#a", "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": -1, @@ -121,6 +125,7 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [ { @@ -133,19 +138,19 @@ ], "layer": 0, "visible": true, - "x": 159.0196542021097, - "y": 443.2970635781322, - "width": 143.88497017161473, - "height": 347.27792183395906, + "x": 95.87250515576761, + "y": 444.191244640698, + "width": 146.00904103865875, + "height": 352.40453732170107, "baseWidth": 162, "baseHeight": 391, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "miles_ds-idle-down.json", "color": "#00ffff", - "scale": 0.8881788282198441, + "scale": 0.9012903767818442, "modelScale": 1.03, - "parallax": 0.7269997060619294, + "parallax": 0.7442508967384815, "ignoreScaling": false, "animationSpeed": 30, "opacity": 1, @@ -178,34 +183,35 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 87.7396949124423, - "y": 443.2105923504103, + "x": 23.61461296023532, + "y": 444.1034969001102, "parallax": 1, "ignoreScaling": false, "vertices": [ { - "x": 87.7396949124423, - "y": 443.2105923504103, - "p": 0.7253314403158566 + "x": 23.61461296023532, + "y": 444.1034969001102, + "p": 0.7425580035788193 }, { - "x": 196.48465665453705, - "y": 443.21612487072593, - "p": 0.7254381777171315 + "x": 130.79310145805096, + "y": 444.10826139683275, + "p": 0.7426499237109707 }, { - "x": 197.82134195961072, - "y": 449.4326708990447, - "p": 0.8453722999813154 + "x": 121.97350541619949, + "y": 449.4618274910352, + "p": 0.8459348101360973 }, { - "x": 95.34720342352023, - "y": 449.095953404058, - "p": 0.8388761014446592 + "x": 21.516082555040327, + "y": 449.17185306532133, + "p": 0.8403404128504302 } ], "color": "#000000", @@ -221,12 +227,13 @@ "blur": 4 }, { - "name": "logo", + "name": "logo_1", "type": "Entity", "locked": false, "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, @@ -257,27 +264,28 @@ "disabled": false, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], "layer": 0, "visible": true, - "x": 96, - "y": 340, - "width": 655.1999999999999, - "height": 668.64, + "x": 79, + "y": 417, + "width": 778.4081632653061, + "height": 794.3755102040816, "baseWidth": 585, "baseHeight": 597, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "scanline_logo", "color": "#AAAAAA", - "scale": 1.1199999999999999, + "scale": 1.3306122448979592, "modelScale": 2.8, "parallax": 0.2, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, - "blendMode": "source-over", + "blendMode": "lighter", "blur": 32 } ], diff --git a/public/text/objects/Static_723.json b/public/text/objects/Static_723.json new file mode 100644 index 0000000..41cf279 --- /dev/null +++ b/public/text/objects/Static_723.json @@ -0,0 +1,4 @@ +{ + "title": "Static_723", + "description": "You see nothing special." +} diff --git a/public/text/objects/logo_1.json b/public/text/objects/logo_1.json index 8697379..cc3720e 100644 --- a/public/text/objects/logo_1.json +++ b/public/text/objects/logo_1.json @@ -1,4 +1,4 @@ { "title": "logo", - "description": "You see nothing special." + "description": "You see Scanline Engine logo." } diff --git a/public/text/scenes/test_room.json b/public/text/scenes/test_room.json new file mode 100644 index 0000000..faa8b8b --- /dev/null +++ b/public/text/scenes/test_room.json @@ -0,0 +1,4 @@ +{ + "title": "New Scene", + "description": "You are in New Scene." +} diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index c0d8821..8b07105 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -112,14 +112,20 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { // GDD: "Input command... displayed in buffer... then sent to parser" if (val && game) { - // 1. Log Command to Buffer - game.console.log(val, 'command'); + const firstWord = val.split(/\s+/)[0] || ''; - // 2. Add to History - game.console.addHistory(val); + if (firstWord.startsWith('#')) { + game.console.processCommand(val); + } else { + // 1. Log Command to Buffer + game.console.log(val, 'command'); + + // 2. Add to History + game.console.addHistory(val); - // 3. Send to Parser (Parser handles command casing, Console handles arguments) - game.parser.parse(val); + // 3. Send to gameplay parser + game.parser.parse(val); + } e.currentTarget.value = ''; setHistoryIndex(-1); // Reset history index on submit diff --git a/src/core/Console.ts b/src/core/Console.ts index 80f52ef..b8e8092 100644 --- a/src/core/Console.ts +++ b/src/core/Console.ts @@ -18,6 +18,7 @@ export class Console { buffer: ConsoleLine[] = []; history: string[] = []; isOpen: boolean = false; + parserPeekEnabled: boolean = false; // Configuration readonly MAX_BUFFER_LINES = 2000; // Approx 150KB of text depending on length @@ -80,15 +81,15 @@ export class Console { } private registerDefaultCommands() { - this.registerCommand('CLEAR', () => this.clear()); - this.registerCommand('HELP', () => { + this.registerCommand('#CLS', () => this.clear()); + this.registerCommand('#HELP', () => { this.log('Available commands:', 'info'); this.log(Array.from(this.commands.keys()).join(', '), 'info'); }); - this.registerCommand('RUN', (args) => { + this.registerCommand('#RUN', (args) => { if (args.length === 0) { - this.log('Usage: RUN <script_id> [args...]', 'error'); + this.log('Usage: #RUN <script_id> [args...]', 'error'); return; } const scriptId = args[0]; @@ -96,7 +97,7 @@ export class Console { this.runScript(scriptId, args.slice(1)); }); - this.registerCommand('HALT', (args) => { + this.registerCommand('#HALT', (args) => { if (args.length === 0) { // Halt all ScriptRegistry.stopAll(); @@ -108,6 +109,16 @@ export class Console { this.log(`Stopped script '${scriptId}'.`, 'info'); } }); + + this.registerCommand('#PEEK-ON', () => { + this.parserPeekEnabled = true; + this.log('Parser peek enabled.', 'info'); + }); + + this.registerCommand('#PEEK-OFF', () => { + this.parserPeekEnabled = false; + this.log('Parser peek disabled.', 'info'); + }); } private runScript(id: string, args: string[]) { diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 67e5ece..f748f60 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -1,6 +1,78 @@ import { ScriptRegistry } from '../core/ScriptRegistry'; import { ComponentSystem } from '../systems/ComponentSystem'; +type ParserEntityContext = { + id: string; + type: string; + title: string | null; + description: string | null; + interactions: string[]; +}; + +type ParserInventoryItemContext = { + id: string; + title: string | null; +}; + +type ParserContext = { + rawInput: string; + normalizedInput: string; + scene: { + id: string; + name: string; + title: string | null; + description: string | null; + } | null; + entities: ParserEntityContext[]; + inventory: ParserInventoryItemContext[]; +}; + +type ParserAction = + | { type: 'lookScene' } + | { type: 'lookEntity'; target: string } + | { type: 'takeEntity'; target: string | null } + | { type: 'showInventory' } + | { type: 'handoff'; reason: string; verb: string; noun: string; rawInput: string }; + +type ParserActionEnvelope = { + stage: 'regex-v1'; + actions: ParserAction[]; + debug: { + rawInput: string; + normalizedInput: string; + verb: string; + noun: string; + }; +}; + +type ParserResult = + | { + type: 'message'; + handled: true; + messages: string[]; + actionsExecuted: string[]; + } + | { + type: 'scriptDelegated'; + handled: true; + messages: string[]; + actionsExecuted: string[]; + delegatedScriptId: string; + } + | { + type: 'handoff'; + handled: false; + messages: string[]; + actionsExecuted: string[]; + reason: string; + debug: Record<string, unknown>; + }; + +type ParserResponse = { + playerMessage?: string; + debugMessages?: string[]; +}; + export class Parser { game: any; inputField: HTMLInputElement | null; @@ -11,199 +83,349 @@ export class Parser { } parse(input: string): void { - // Integration with Console Commands - // If the input starts with a known console command, delegate to Console. - // Or better: Console should handle EVERYTHING if it's open, but Parser is for GAMEPLAY commands. - // In this architecture, they share the same input line. - // Let's check if it IS a console command first. - - const firstWord = input.split(' ')[0].toUpperCase(); - - // This requires Parser to know about Console commands or Console processing return value. - // Let's try to pass it to Console.processCommand. - // If Console handles it, we stop. - // But Console.processCommand currently logs "Unknown command" if not found. - // We need a way to check "Is this a console command?" - - if (this.game.console && this.game.console.hasCommand(firstWord)) { - this.game.console.processCommand(input); - return; - } + const trimmed = input.trim(); + if (!trimmed) return; - const words = input.trim().split(/\s+/); - const verb = words[0].toUpperCase(); - const noun = words.slice(1).join(' '); + const contextJson = this.buildContextJson(trimmed); + const actionJson = this.runStage1(trimmed, contextJson); + const resultJson = this.executeActionJson(actionJson, contextJson); + const response = this.buildResponse(resultJson, actionJson, contextJson); + + if (response.playerMessage) { + this.game.log(response.playerMessage); + } - this.execute(verb, noun); + if (response.debugMessages?.length) { + for (const message of response.debugMessages) { + this.game.console?.log(message, 'info'); + } + } } - execute(verb: string, noun: string): void { + private buildContextJson(rawInput: string): string { const scene = this.game.sceneManager.currentScene; - if (!scene) return; - const normalizedNoun = noun.trim().toUpperCase(); - const isSceneLook = - !normalizedNoun || - normalizedNoun === 'AROUND' || - normalizedNoun === 'HERE' || - normalizedNoun === 'SCENE'; - - // Basic command handling + const normalizedInput = rawInput.trim().toUpperCase(); + + const context: ParserContext = { + rawInput, + normalizedInput, + scene: scene + ? { + id: scene.id, + name: scene.name, + title: this.game.textAssets.getResolvedSceneField(scene, 'title'), + description: this.game.textAssets.getResolvedSceneField(scene, 'description'), + } + : null, + entities: scene + ? (scene.entities || []) + .map((entity: any) => ({ + id: entity.name, + type: entity.type, + title: this.game.textAssets.getResolvedObjectField(entity, 'title'), + description: this.game.textAssets.getResolvedObjectField(entity, 'description'), + interactions: Object.keys(entity.interactions || {}), + })) + .filter((entity: ParserEntityContext) => !!entity.title?.trim()) + : [], + inventory: (this.game.inventory || []) + .map((entity: any) => ({ + id: entity.name, + title: this.game.textAssets.getResolvedObjectField(entity, 'title'), + })) + .filter((entity: ParserInventoryItemContext) => !!entity.title?.trim()), + }; + + return JSON.stringify(context); + } + + private runStage1(input: string, _contextJson: string): string { + const words = input.trim().split(/\s+/); + const verb = (words[0] || '').toUpperCase(); + const noun = words.slice(1).join(' ').trim(); + const normalizedNoun = noun.toUpperCase(); + + let actions: ParserAction[]; + switch (verb) { case 'LOOK': case 'EXAMINE': - case 'X': // Common shortcut - if (isSceneLook) { - const sceneDescription = - this.game.textAssets.getResolvedSceneField(scene, 'description') || - scene.description || - this.game.text('parser.look_default_scene', { scene: scene.name }); - this.game.log(sceneDescription); + case 'X': + if ( + !normalizedNoun || + normalizedNoun === 'AROUND' || + normalizedNoun === 'HERE' || + normalizedNoun === 'SCENE' + ) { + actions = [{ type: 'lookScene' }]; } else { - const entity = scene.findEntity(noun); - if (entity) { - // Check for script interaction first - const interactionId = - entity.interactions && (entity.interactions['look'] || entity.interactions['LOOK']); - if (interactionId) { - ScriptRegistry.execute(interactionId, { game: this.game, entity: entity }); - } else { - // Fallback to description - const description = - this.game.textAssets.getResolvedObjectField(entity, 'description') || - entity.description; - this.game.log( - description || this.game.text('parser.look_default_object', { target: noun }) - ); - } - } else { - this.game.log(this.game.text('parser.look_not_found', { target: noun })); - } + actions = [{ type: 'lookEntity', target: noun }]; } break; case 'TAKE': case 'GET': case 'PICKUP': - if (!noun) { - this.game.log(this.game.text('parser.take_prompt')); - } else { - const entity = scene.findEntity(noun); - if (entity) { - // Check for script interaction first - const interactionId = - entity.interactions && - (entity.interactions['pickup'] || entity.interactions['PICKUP']); - if (interactionId) { - ScriptRegistry.execute(interactionId, { game: this.game, entity: entity }); - return; - } - - // Check for Item Component - // Refactored to ComponentSystem - const errorMsg = ComponentSystem.canTakeItem(entity, scene.player); - if (errorMsg) { - this.game.log(errorMsg); - return; - } - - // Legacy Check (isTakeable or Item Component Existence is implicit if we want to allow taking) - // But ComponentSystem.canTakeItem returns null for "Component Checks Passed". - // It returns 'null' if it's NOT an item component? Explicitly checked. - // Wait, my ComponentSystem implementation returns null if it IS an item and checks pass. - // It returns null if it's NOT an item? - // Let's rely on component check. - - const isItem = - entity.components && entity.components.find((c: any) => c.type === 'Item'); - if (isItem || entity.isTakeable) { - scene.removeEntity(entity); - this.game.inventory.push(entity); - this.game.log( - this.game.text('parser.take_pickup_success', { - item: entity.customName || entity.name, - }) - ); - } else { - this.game.log(this.game.text('parser.take_cannot')); - } - } else { - this.game.log(this.game.text('parser.look_not_found', { target: noun })); - } - } + actions = [{ type: 'takeEntity', target: noun || null }]; break; case 'INV': case 'INVENTORY': case 'I': - if (this.game.inventory.length === 0) { - this.game.log(this.game.text('parser.inventory_empty')); - } else { - const items = this.game.inventory.map((e: any) => e.customName || e.name).join(', '); - this.game.log(this.game.text('parser.inventory_items', { items })); - } - break; - case 'USE': - if (!noun) { - this.game.log(this.game.text('parser.use_prompt')); - } else { - // Check if it's "USE [ID] ON [ID]" vs "USE [ID]" - if (noun.includes(' ON ')) { - // Parse "USE X ON Y" - const parts = noun.split(' ON '); - if (parts.length !== 2) { - this.game.log(this.game.text('parser.use_format_prompt')); - } else { - const itemName = parts[0].trim(); - const targetName = parts[1].trim(); - - // Check if player has the item - const item = this.game.inventory.find( - (i: any) => (i.customName || i.name).toUpperCase() === itemName.toUpperCase() - ); - if (!item) { - this.game.log(this.game.text('parser.use_missing_item', { item: itemName })); - } else { - // Check if target is in the scene - const target = scene.findEntity(targetName); - if (target) { - // Perform interaction - // We look for a key like "item_name" in the target's interactions - // This requires the interaction key to match the item name, which might be tricky. - // For now, let's stick to the convention: interaction key = ITEM_ID (or name) - const interactionId = - target.interactions && target.interactions[item.name.toUpperCase()]; - if (interactionId) { - ScriptRegistry.execute(interactionId, { game: this.game, entity: target }); - } else { - this.game.log( - this.game.text('parser.use_no_effect_pair', { - item: itemName, - target: targetName, - }) - ); - } - } else { - this.game.log(this.game.text('parser.look_not_found', { target: targetName })); - } - } - } - } else { - // Simple "USE [Target]" - const entity = scene.findEntity(noun); - if (entity) { - const interactionId = - entity.interactions && (entity.interactions['use'] || entity.interactions['USE']); - if (interactionId) { - ScriptRegistry.execute(interactionId, { game: this.game, entity: entity }); - } else { - this.game.log(this.game.text('parser.use_no_effect_single', { target: noun })); - } - } else { - this.game.log(this.game.text('parser.look_not_found', { target: noun })); - } - } - } + actions = [{ type: 'showInventory' }]; break; default: - this.game.log(this.game.text('parser.parse_unknown')); + actions = [ + { + type: 'handoff', + reason: 'unsupported_by_stage1', + verb, + noun, + rawInput: input, + }, + ]; + break; } + + const envelope: ParserActionEnvelope = { + stage: 'regex-v1', + actions, + debug: { + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + verb, + noun, + }, + }; + + return JSON.stringify(envelope); + } + + private executeActionJson(actionJson: string, _contextJson: string): string { + const envelope = JSON.parse(actionJson) as ParserActionEnvelope; + const scene = this.game.sceneManager.currentScene; + const executedActions: string[] = []; + + if (!scene) { + const result: ParserResult = { + type: 'handoff', + handled: false, + messages: [], + actionsExecuted: executedActions, + reason: 'no_current_scene', + debug: { actionJson }, + }; + return JSON.stringify(result); + } + + const firstAction = envelope.actions[0]; + if (!firstAction) { + const result: ParserResult = { + type: 'handoff', + handled: false, + messages: [], + actionsExecuted: executedActions, + reason: 'empty_action_plan', + debug: { actionJson }, + }; + return JSON.stringify(result); + } + + switch (firstAction.type) { + case 'lookScene': { + executedActions.push('lookScene'); + const sceneDescription = + this.game.textAssets.getResolvedSceneField(scene, 'description') || + scene.description || + this.game.text('parser.look_default_scene', { scene: scene.name }); + const result: ParserResult = { + type: 'message', + handled: true, + messages: [sceneDescription], + actionsExecuted: executedActions, + }; + return JSON.stringify(result); + } + case 'lookEntity': { + executedActions.push('lookEntity'); + const entity = scene.findEntity(firstAction.target); + if (!entity) { + const result: ParserResult = { + type: 'message', + handled: true, + messages: [this.game.text('parser.look_not_found', { target: firstAction.target })], + actionsExecuted: executedActions, + }; + return JSON.stringify(result); + } + + const interactionId = + entity.interactions && (entity.interactions.look || entity.interactions.LOOK); + if (interactionId) { + ScriptRegistry.execute(interactionId, { game: this.game, entity }); + const result: ParserResult = { + type: 'scriptDelegated', + handled: true, + messages: [], + actionsExecuted: executedActions, + delegatedScriptId: interactionId, + }; + return JSON.stringify(result); + } + + const description = + this.game.textAssets.getResolvedObjectField(entity, 'description') || entity.description; + const result: ParserResult = { + type: 'message', + handled: true, + messages: [ + description || + this.game.text('parser.look_default_object', { target: firstAction.target }), + ], + actionsExecuted: executedActions, + }; + return JSON.stringify(result); + } + case 'takeEntity': { + executedActions.push('takeEntity'); + if (!firstAction.target) { + const result: ParserResult = { + type: 'message', + handled: true, + messages: [this.game.text('parser.take_prompt')], + actionsExecuted: executedActions, + }; + return JSON.stringify(result); + } + + const entity = scene.findEntity(firstAction.target); + if (!entity) { + const result: ParserResult = { + type: 'message', + handled: true, + messages: [this.game.text('parser.look_not_found', { target: firstAction.target })], + actionsExecuted: executedActions, + }; + return JSON.stringify(result); + } + + const interactionId = + entity.interactions && (entity.interactions.pickup || entity.interactions.PICKUP); + if (interactionId) { + ScriptRegistry.execute(interactionId, { game: this.game, entity }); + const result: ParserResult = { + type: 'scriptDelegated', + handled: true, + messages: [], + actionsExecuted: executedActions, + delegatedScriptId: interactionId, + }; + return JSON.stringify(result); + } + + const errorMsg = ComponentSystem.canTakeItem(entity, scene.player); + if (errorMsg) { + const result: ParserResult = { + type: 'message', + handled: true, + messages: [errorMsg], + actionsExecuted: executedActions, + }; + return JSON.stringify(result); + } + + const isItem = entity.components && entity.components.find((c: any) => c.type === 'Item'); + if (isItem || entity.isTakeable) { + scene.removeEntity(entity); + this.game.inventory.push(entity); + const result: ParserResult = { + type: 'message', + handled: true, + messages: [ + this.game.text('parser.take_pickup_success', { + item: entity.customName || entity.name, + }), + ], + actionsExecuted: executedActions, + }; + return JSON.stringify(result); + } + + const result: ParserResult = { + type: 'message', + handled: true, + messages: [this.game.text('parser.take_cannot')], + actionsExecuted: executedActions, + }; + return JSON.stringify(result); + } + case 'showInventory': { + executedActions.push('showInventory'); + const result: ParserResult = { + type: 'message', + handled: true, + messages: + this.game.inventory.length === 0 + ? [this.game.text('parser.inventory_empty')] + : [ + this.game.text('parser.inventory_items', { + items: this.game.inventory.map((e: any) => e.customName || e.name).join(', '), + }), + ], + actionsExecuted: executedActions, + }; + return JSON.stringify(result); + } + case 'handoff': + default: { + const result: ParserResult = { + type: 'handoff', + handled: false, + messages: [], + actionsExecuted: executedActions, + reason: firstAction.type === 'handoff' ? firstAction.reason : 'unsupported_action_type', + debug: { + actionJson, + action: firstAction, + }, + }; + return JSON.stringify(result); + } + } + } + + private buildResponse( + resultJson: string, + actionJson: string, + contextJson: string + ): ParserResponse { + const result = JSON.parse(resultJson) as ParserResult; + const peekMessages = this.game.console?.parserPeekEnabled + ? [ + `[Parser peek] context=${contextJson}`, + `[Parser peek] actions=${actionJson}`, + `[Parser peek] result=${resultJson}`, + ] + : undefined; + + if (result.type === 'message') { + return { + playerMessage: result.messages[0], + debugMessages: peekMessages, + }; + } + + if (result.type === 'scriptDelegated') { + return result.messages.length > 0 + ? { playerMessage: result.messages[0], debugMessages: peekMessages } + : { debugMessages: peekMessages }; + } + + return { + playerMessage: this.game.text('parser.parse_unknown'), + debugMessages: peekMessages || [ + `[Parser handoff] context=${contextJson}`, + `[Parser handoff] actions=${actionJson}`, + `[Parser handoff] result=${resultJson}`, + ], + }; } } From 917c2e2a0786a6d7e96fddf6f8cabf2318953e66 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Tue, 10 Mar 2026 00:10:47 +0200 Subject: [PATCH 08/75] Fix: tighten TA handling for walkboxes and scene save-as Hide TA controls for Walkbox objects and hard-block Walkbox text asset operations in the text asset manager. Preserve scene text assets on Save As by carrying the previous scene TA to the new scene id when needed, while still creating a default TA when no prior asset exists. --- src/components/editor/PropertiesPanel.tsx | 79 ++++++++++++-------- src/core/TextAssetManager.ts | 43 ++++++++++- src/tools/editor/EditorPersistenceManager.ts | 9 ++- 3 files changed, 94 insertions(+), 37 deletions(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index de79f86..f6a0142 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -35,6 +35,11 @@ export const PropertiesPanel: React.FC = () => { } const uiScale = game?.settings?.editor?.uiScale || 1.0; + const supportsTextAsset = + selectedObjectType === 'SCENE' || + (selectedObjectType !== 'MULTI' && + selectedObjectType !== 'SETTINGS' && + game?.editor?.selectedObject?.type !== 'Walkbox'); const multiObjects = game?.editor?.selectionManager?.hasMultiSelection() ? game.editor.selectionManager.getSelectedObjects() : []; @@ -68,6 +73,14 @@ export const PropertiesPanel: React.FC = () => { if (!game || !obj || selectedObjectType === 'MULTI' || selectedObjectType === 'SETTINGS') { setResolvedTitle(''); setTextAssetPath(''); + setHasTextAsset(false); + return; + } + + if (!supportsTextAsset) { + setResolvedTitle(''); + setTextAssetPath(''); + setHasTextAsset(false); return; } @@ -93,7 +106,7 @@ export const PropertiesPanel: React.FC = () => { setTextAssetPath(game.textAssets.getObjectAssetProjectPath(selected.name)); } }, - [game, obj, selectedObjectType] + [game, obj, selectedObjectType, supportsTextAsset] ); React.useEffect(() => { @@ -874,38 +887,40 @@ export const PropertiesPanel: React.FC = () => { }} /> </div> - <div className="e-row"> - <label className="e-label">Title</label> - <input - type="text" - className="e-input" - value={resolvedTitle} - readOnly - tabIndex={-1} - onFocus={(e) => e.currentTarget.blur()} - style={{ pointerEvents: 'none', color: '#888' }} - /> - {textAssetPath && ( - <> - <div style={{ display: 'flex', gap: '6px', marginTop: '4px' }}> - <button className="e-btn" onClick={handleOpenTA}> - {hasTextAsset ? 'Open TA' : 'Create TA'} - </button> - <button className="e-btn" onClick={handleReadTA} disabled={isReadingTA}> - {isReadingTA ? 'Syncing...' : 'Sync TA'} - </button> - {hasTextAsset && ( - <button className="e-btn" onClick={handleDeleteTA}> - Delete TA + {supportsTextAsset && ( + <div className="e-row"> + <label className="e-label">Title</label> + <input + type="text" + className="e-input" + value={resolvedTitle} + readOnly + tabIndex={-1} + onFocus={(e) => e.currentTarget.blur()} + style={{ pointerEvents: 'none', color: '#888' }} + /> + {textAssetPath && ( + <> + <div style={{ display: 'flex', gap: '6px', marginTop: '4px' }}> + <button className="e-btn" onClick={handleOpenTA}> + {hasTextAsset ? 'Open TA' : 'Create TA'} </button> - )} - </div> - <div className="e-label" style={{ color: '#888', fontSize: '0.8em' }}> - {textAssetPath} - </div> - </> - )} - </div> + <button className="e-btn" onClick={handleReadTA} disabled={isReadingTA}> + {isReadingTA ? 'Syncing...' : 'Sync TA'} + </button> + {hasTextAsset && ( + <button className="e-btn" onClick={handleDeleteTA}> + Delete TA + </button> + )} + </div> + <div className="e-label" style={{ color: '#888', fontSize: '0.8em' }}> + {textAssetPath} + </div> + </> + )} + </div> + )} </> )} diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 9f1df12..1936969 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -98,7 +98,7 @@ export class TextAssetManager { } async ensureObjectAssetFile(obj: SceneObject): Promise<void> { - if (!obj?.name) return; + if (!obj?.name || obj.type === 'Walkbox') return; const assetPath = this.getObjectAssetProjectPath(obj.name); const content = JSON.stringify(this.buildDefaultObjectAsset(obj), null, 2); await this.ensureFile(assetPath, content); @@ -111,6 +111,7 @@ export class TextAssetManager { } async openObjectAsset(obj: SceneObject): Promise<void> { + if (!obj?.name || obj.type === 'Walkbox') return; const assetPath = this.getObjectAssetProjectPath(obj.name); const content = JSON.stringify(this.buildDefaultObjectAsset(obj), null, 2); await this.openFile(assetPath, content); @@ -122,6 +123,7 @@ export class TextAssetManager { } async deleteObjectAsset(obj: SceneObject): Promise<void> { + if (!obj?.name || obj.type === 'Walkbox') return; await this.deleteFile(this.getObjectAssetProjectPath(obj.name)); this.objectCache.delete(this.normalizeId(obj.name)); } @@ -141,6 +143,7 @@ export class TextAssetManager { obj: SceneObject, forceReload: boolean = false ): Promise<TextAssetData | null> { + if (!obj?.name || obj.type === 'Walkbox') return null; const objectId = this.normalizeId(obj?.name || ''); if (!objectId) return null; if (!forceReload && this.objectCache.has(objectId)) { @@ -288,6 +291,17 @@ export class TextAssetManager { }); } + private async saveFile(filePath: string, content: string): Promise<void> { + const response = await fetch('/api/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath, content }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + } + private async openFile(filePath: string, content: string): Promise<void> { const response = await fetch('/api/open-file', { method: 'POST', @@ -319,6 +333,33 @@ export class TextAssetManager { this.objectCache.set(this.normalizeId(targetObjectId), sourceData); } + async carrySceneAssetIfNeeded( + previousSceneId: string | null | undefined, + scene: Scene + ): Promise<void> { + const targetSceneId = this.normalizeId(scene?.id || ''); + const sourceSceneId = this.normalizeId(previousSceneId || ''); + + if (!targetSceneId) return; + + if (sourceSceneId && sourceSceneId !== targetSceneId) { + const targetData = await this.fetchJson(this.getSceneAssetUrl(targetSceneId)); + if (!targetData) { + const sourceData = await this.fetchJson(this.getSceneAssetUrl(sourceSceneId)); + if (sourceData) { + await this.saveFile( + this.getSceneAssetProjectPath(targetSceneId), + JSON.stringify(sourceData, null, 2) + ); + this.sceneCache.set(targetSceneId, sourceData); + return; + } + } + } + + await this.ensureSceneAssetFile(scene); + } + private async deleteFile(filePath: string): Promise<void> { const response = await fetch('/api/delete-file', { method: 'POST', diff --git a/src/tools/editor/EditorPersistenceManager.ts b/src/tools/editor/EditorPersistenceManager.ts index 4b414bc..853ab8f 100644 --- a/src/tools/editor/EditorPersistenceManager.ts +++ b/src/tools/editor/EditorPersistenceManager.ts @@ -15,6 +15,7 @@ export class EditorPersistenceManager { async saveScene(saveAs: boolean = false): Promise<void> { const scene = this.editor.game.sceneManager.currentScene; if (!scene) return; + const previousSceneId = scene.id || ''; const id = scene.id || ''; // Allow backslashes for subfolders @@ -24,7 +25,7 @@ export class EditorPersistenceManager { // Smart Save // Ensure filename property matches ID (normalized for file system) scene.filename = id.replace(/\\/g, '/'); - this.performSaveScene(scene.filename); + this.performSaveScene(scene.filename, previousSceneId); return; } @@ -40,11 +41,11 @@ export class EditorPersistenceManager { scene.id = idFromName; this.editor.syncUI(); // Refresh UI to show new Filename - this.performSaveScene(scene.filename); + this.performSaveScene(scene.filename, previousSceneId); }); } - async performSaveScene(filenameId: string): Promise<void> { + async performSaveScene(filenameId: string, previousSceneId?: string): Promise<void> { const scene = this.editor.game.sceneManager.currentScene; if (!scene) return; @@ -63,7 +64,7 @@ export class EditorPersistenceManager { }); if (response.ok) { - await this.editor.game.textAssets.ensureSceneAssetFile(scene); + await this.editor.game.textAssets.carrySceneAssetIfNeeded(previousSceneId, scene); // Use Toast Message this.editor.game.showNotification(`Scene saved as ${normalizedPath}.json`); } else { From d3615ab156c8a813dfead46bd6fe4958c2500ae4 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Thu, 12 Mar 2026 00:51:29 +0200 Subject: [PATCH 09/75] Feature: evolve parser toward orchestrator model Shift the parser from a message-producing executor pipeline toward an orchestrator flow built around semantic game API calls and structured outcomes. Add game.look, game.take, game.showInventory, and game.goTo with shared outcome contracts, introduce pending clarification state in the parser, and update parser service text plus GDD to match the new architecture. --- GDD.md | 205 ++++++++++++--- public/text/system/parser.json | 3 + src/core/Game.ts | 232 +++++++++++++++++ src/core/GameActionTypes.ts | 11 + src/core/IGame.ts | 5 + src/core/TextAssetManager.ts | 3 + src/mechanics/Parser.ts | 447 +++++++++++++++++---------------- 7 files changed, 648 insertions(+), 258 deletions(-) create mode 100644 src/core/GameActionTypes.ts diff --git a/GDD.md b/GDD.md index 14e6d9c..efea490 100644 --- a/GDD.md +++ b/GDD.md @@ -13,7 +13,7 @@ - **Команда**: указание что нужно сделать, напр. "открой дверь ключом"; - **Реплика**: прямой текст, который произносит герой и который "слышат" все NPC находяшиеся в сцене. Начинается с символа "-". - > Режим команд является основным, и пользователь может давать команду парсеру сегенерировать реплику за него, напр. "Расскажи Линде про то, что видел по дороге". +> Режим команд является основным, и пользователь может давать команду парсеру сегенерировать реплику за него, напр. "Расскажи Линде про то, что видел по дороге". ## Виртуальная консоль @@ -61,9 +61,9 @@ Parser обрабатывает пользовательский ввод каскадно, если каскад не смог обработать команду, она передаётся следующему: -1. Простой разбор через regexps, распознающий самые элементарные команды типа look [at], take [up], drop, use <> with <>, i (inventory), а также служебные команды save, load, run, etc. (мгновенно); +1. Простой разбор через regexps, распознающий самые элементарные команды типа look [at], take [up], drop, use <> with <>, i (inventory): (мгновенно); 2. Малая локальная нейросеть, например nlp.js (быстро, порядка 50 ms); -3. Средняя (или малая) языковая модель, работающая локально или через API. (очень медленно и "дорого") +3. Средняя (или малая) языковая модель (LM), работающая локально или через API. (очень медленно и "дорого") Первые два каскада парсят почти весь нормальный пользовательский ввод, а LLM/SLM подключается только если ввод не распознан ни одним из них, либо надо сгенерировать текст, в том числе при прямых диалогах с NPC. В любом случае, на выходе парсера получается json с набором вызовов API. Затем парсер анализирует их результат (то, что возвращают методы API) и осуществляет какое-то действие: либо отправляет новый json c вызовами API и уходит на новую итерацию, либо отправляет пользователю итоговое сообщение в консоль и заканчивает обработку команды. @@ -78,9 +78,10 @@ Parser обрабатывает пользовательский ввод кас - строка ввода сначала разделяется на **console commands** и **gameplay commands**; служебные команды консоли в формате `#...` не проходят через gameplay parser; - gameplay parser получает пользовательский ввод и собирает **Context JSON** — упрощённый снимок текстово значимой части текущей сцены и инвентаря; -- первый каскад остаётся простым regexp/switch parser и строит **Action JSON**; -- отдельный executor обрабатывает этот Action JSON, вызывает API/методы движка и возвращает **Result JSON**; -- затем первый каскад получает Result JSON обратно и либо формирует итоговый ответ игроку, либо выдаёт временный debug handoff для будущих старших каскадов. +- parser хранит собственное runtime-состояние, включая pending clarification, если предыдущая команда не была завершена и требует уточнения; +- первый каскад остаётся простым regexp/switch parser и строит **Action JSON**, но теперь это план вызовов semantic runtime API (`game.look`, `game.take`, `game.showInventory`, `game.goTo`); +- отдельный tool layer / API adapter исполняет эти вызовы и возвращает parser структурированный **Result JSON** в виде outcomes, а не готового сценарного ответа; +- parser анализирует outcomes и сам решает, что делать дальше: завершить ответ игроку, сохранить pending state, передать кейс на старший каскад или выполнить следующую команду API. Таким образом, даже при очень простом первом каскаде уже существует правильная форма взаимодействия: @@ -93,28 +94,40 @@ Parser обрабатывает пользовательский ввод кас - `LOOK AROUND / HERE / SCENE` - `TAKE / GET / PICKUP <target>` - `INV / INVENTORY / I` +- `GO / WALK / MOVE <target>` +- `GO TO <target>` + +Для `TAKE` и `GO` уже поддерживается базовый pending clarification: если обязательная цель не указана, parser может задать уточняющий вопрос и ожидать следующий ввод как продолжение текущей команды. Команда `USE`, speech input (реплики с `-`) и старшие каскады пока в эту версию не входят. -### Текущая реализация executor +### Текущая реализация tool layer / API adapter + +На этом этапе parser уже не перекладывает на исполнитель решение о том, что говорить игроку. Он использует runtime API как набор инструментов и сам остаётся оркестратором команды. + +Сейчас первый каскад parser может строить action-план из вызовов следующих semantic API-методов: -На этом этапе executor является отдельным runtime-слоем, который получает от parser готовый `Action JSON`, исполняет его через API движка и возвращает parser структурированный `Result JSON`. +- `game.look(target?)` +- `game.take(target?)` +- `game.showInventory()` +- `game.goTo(target?)` -Сейчас executor поддерживает только ограниченный набор action-типов: +Каждый такой вызов возвращает structured outcome: -- `lookScene` -- `lookEntity` -- `takeEntity` -- `showInventory` -- `handoff` +- `status`: `ok | failed | needs_clarification | escalate` +- `code`: машинный код результата +- `message`: fallback-текст, который parser может вывести игроку, переиспользовать или проигнорировать +- `data`: структурированные данные +- `effects`: список побочных эффектов (`scene_changed`, `moved_to_inventory`, `script_executed` и тп) +- `recoverable`: можно ли пытаться продолжать обработку -Примеры `Action JSON`, которые parser может передать executor: +Примеры `Action JSON`, которые parser может передать tool layer: ```json { "stage": "regex-v1", "actions": [ - { "type": "lookScene" } + { "type": "callGameMethod", "method": "look", "args": [null] } ], "debug": { "rawInput": "look", @@ -129,7 +142,7 @@ Parser обрабатывает пользовательский ввод кас { "stage": "regex-v1", "actions": [ - { "type": "lookEntity", "target": "lamp" } + { "type": "callGameMethod", "method": "look", "args": ["lamp"] } ], "debug": { "rawInput": "look lamp", @@ -144,7 +157,7 @@ Parser обрабатывает пользовательский ввод кас { "stage": "regex-v1", "actions": [ - { "type": "takeEntity", "target": "key" } + { "type": "callGameMethod", "method": "take", "args": ["key"] } ], "debug": { "rawInput": "take key", @@ -155,33 +168,69 @@ Parser обрабатывает пользовательский ввод кас } ``` -Примеры `Result JSON`, которые executor возвращает parser: +Пример pending-resolution, когда parser ждёт уточнение и трактует следующий ввод как продолжение предыдущей команды: ```json { - "type": "message", + "stage": "pending-resolution", + "actions": [ + { "type": "callGameMethod", "method": "take", "args": ["key"] } + ], + "debug": { + "rawInput": "key", + "normalizedInput": "KEY", + "verb": "TAKE", + "noun": "key", + "pendingIntent": "take" + } +} +``` + +Примеры `Result JSON`, которые tool layer возвращает parser: + +```json +{ + "type": "outcomes", "handled": true, - "messages": ["You are in New Scene."], - "actionsExecuted": ["lookScene"] + "outcomes": [ + { + "status": "ok", + "code": "scene_description", + "message": "You are in New Scene." + } + ], + "actionsExecuted": ["look"] } ``` ```json { - "type": "message", + "type": "outcomes", "handled": true, - "messages": ["You picked up key."], - "actionsExecuted": ["takeEntity"] + "outcomes": [ + { + "status": "ok", + "code": "item_taken", + "message": "You picked up the key.", + "effects": ["moved_to_inventory"] + } + ], + "actionsExecuted": ["take"] } ``` ```json { - "type": "scriptDelegated", + "type": "outcomes", "handled": true, - "messages": [], - "actionsExecuted": ["lookEntity"], - "delegatedScriptId": "look_lamp" + "outcomes": [ + { + "status": "needs_clarification", + "code": "missing_destination", + "message": "Where do you want to go?" + } + ], + "actionsExecuted": ["goTo"] } ``` @@ -189,7 +238,7 @@ Parser обрабатывает пользовательский ввод кас { "type": "handoff", "handled": false, - "messages": [], + "outcomes": [], "actionsExecuted": [], "reason": "unsupported_by_stage1", "debug": { @@ -199,11 +248,12 @@ Parser обрабатывает пользовательский ввод кас } ``` -После получения `Result JSON` parser на текущем этапе может сделать одно из трёх действий: +После получения `Result JSON` parser на текущем этапе может сделать одно из четырёх действий: -1. Взять готовое сообщение из `result.messages[0]` и вывести его в игровую консоль. -2. Ничего не выводить напрямую, если исполнение было делегировано скрипту, а сам скрипт уже формирует игровой вывод. -3. При `handoff` выдать игроку fallback-сообщение и, в debug-режиме, напечатать в консоль служебный отчёт (`Context JSON`, `Action JSON`, `Result JSON`) для следующего каскада. +1. Вывести игроку `outcome.message` как итог ответа. +2. Сохранить pending state и задать уточняющий вопрос, если `status = needs_clarification`. +3. Передать кейс на старший каскад, если `status = escalate` или stage1 не смог построить plan. +4. При `handoff` выдать игроку fallback-сообщение и, в debug-режиме, напечатать в консоль служебный отчёт (`Context JSON`, `Action JSON`, `Result JSON`) для следующего каскада. ### Ближайшее направление развития @@ -211,11 +261,11 @@ Parser обрабатывает пользовательский ввод кас Ближайшие шаги развития: -1. Расширять и стабилизировать JSON-контракты между context builder, first-stage parser, executor и response builder. -2. Постепенно переносить в executor всю игровую логику, чтобы первый каскад оставался только интерпретатором ввода, а не прямым caller-ом runtime API. -3. Добавить обработку новых команд первого каскада поверх той же схемы `Action JSON -> Result JSON`, начиная с `USE`. -4. После стабилизации этого контура подключать второй каскад, который будет получать тот же Context JSON и тот же Result JSON, но уже пытаться разбирать более свободный ввод. -5. Лишь после этого подключать тяжёлый LLM/SLM каскад и speech/dialogue routing, чтобы они встраивались в уже готовую схему, а не диктовали архитектуру снизу вверх. +1. Расширять и стабилизировать JSON-контракты между context builder, first-stage parser, tool layer и response builder. +2. Постепенно добавлять новые semantic API-методы (`game.use`, `game.open`, `game.talkTo`, etc), чтобы parser не реализовывал типовые действия через ad hoc low-level логику. +3. Укреплять pending clarification и parser session state, чтобы он мог вести короткий диалог с игроком внутри одной незавершённой команды. +4. После стабилизации этого контура подключать второй каскад, который будет получать тот же Context JSON и тот же Result JSON, но уже пытаться разбирать более свободный ввод и строить многошаговые планы. +5. Затем подключать тяжёлый LLM/SLM каскад и speech/dialogue routing, чтобы parser мог не только генерировать тексты, но и строить plan’ы для сложных команд. Иными словами, ближайшая цель — сделать первый каскад не умнее, а **архитектурно правильнее**, чтобы все последующие каскады подключались к уже работающему посреднику. @@ -543,6 +593,10 @@ Hero.walkTo(100, 100); - `game.showMessage(text: string)`: выводит сообщение в игровую консоль; - `game.log(text: string)`: выводит сообщение напрямую в буфер консоли; - `game.text(key: string, params?: Record<string, string | number>)`: получает строку из служебного TA по ключу; +- `game.look(target?: string | null)`: semantic API для осмотра сцены или объекта; +- `game.take(target?: string | null)`: semantic API для подбора предмета; +- `game.showInventory()`: semantic API для получения описания инвентаря; +- `game.goTo(target?: string | null)`: semantic API для перехода в сцену или перемещения игрока к объекту; - `game.playSound(filename: string)`: проигрывает звук из `public/sounds`; - `game.sceneManager.currentScene`: ссылка на текущую сцену; - `game.sceneManager.switchTo(sceneId: string)`: переключает игру на другую сцену; @@ -556,6 +610,79 @@ ScriptRegistry.register('door.locked', ({ game }) => { }); ``` +`game.look / game.take / game.showInventory / game.goTo` на текущем этапе возвращают **structured outcome**, а не только текст. В нём есть: + +- `status`: `ok | failed | needs_clarification | escalate` +- `code`: машинный код результата +- `message`: fallback-текст, который parser может показать игроку или использовать как промежуточный ответ +- `data`: структурированные данные о результате +- `effects`: список побочных эффектов (`scene_changed`, `moved_to_inventory`, `script_executed`, etc) +- `recoverable`: можно ли продолжать обработку + +Пример: + +```typescript +const outcome = game.look('lamp'); + +if (outcome.status === 'ok' && outcome.message) { + game.showMessage(outcome.message); +} +``` + +#### Подробности по `game.goTo()` + +`game.goTo(target?: string | null)` в текущей реализации является **semantic API-заготовкой** для команд перемещения высокого уровня. Это ещё не полноценный travel planner, но уже единая точка входа, через которую parser может пробовать реализовывать команды вида `GO TO OFFICE`. + +Поведение метода сейчас такое: + +1. Если цель не указана, метод не падает и не завершает команду ошибкой, а возвращает outcome: + - `status = needs_clarification` + - `code = missing_destination` + - `message = "Where do you want to go?"` + +2. Если цель указана, метод сначала пытается найти **сцену** среди уже загруженных сцен. Поиск выполняется по: + - `scene.id` + - `scene.name` + - `scene title` из связанного TA + +3. Если сцена найдена: + - вызывается `game.sceneManager.switchTo(scene.id)` + - возвращается outcome со статусом `ok` + - в `message` попадает описание сцены (`description` из TA или fallback) + - в `effects` может быть `scene_changed` + +4. Если сцена не найдена, метод пытается найти **объект текущей сцены** через `scene.findEntity(target)`. + +5. Если объект найден и у него есть координаты: + - персонажу игрока отдаётся команда идти к этому объекту + - возвращается outcome со статусом `ok` + - в `message` используется fallback вроде `You go to <target>.` + - в `effects` может быть `player_move_started` + +6. Если не найдена ни сцена, ни объект: + - возвращается outcome со статусом `failed` + - `code = destination_not_found` + - `message = "You can't get to <target> from here."` + +Важно: текущий `game.goTo()` **не** делает сложного планирования и не проверяет сюжетные условия высокого уровня. Например, он пока не умеет сам решать кейсы вида: + +- доступен ли сейчас офис по сюжету; +- есть ли у игрока машина; +- есть ли ключи; +- надо ли выполнить цепочку промежуточных действий перед переходом. + +Именно такие кейсы в будущем должен будет разруливать parser-оркестратор и старшие каскады. Тогда `game.goTo()` станет для них не “полным решателем задачи”, а semantic runtime инструментом, который либо может выполнить шаг, либо возвращает structured outcome, на основании которого parser решает, что делать дальше. + +Пример: + +```typescript +const outcome = game.goTo('office'); + +if (outcome.status === 'needs_clarification' && outcome.message) { + game.showMessage(outcome.message); +} +``` + ### Доступ через `api` `api` — это удобная script-side обёртка. Она не заменяет `game`, а сокращает наиболее частые операции. diff --git a/public/text/system/parser.json b/public/text/system/parser.json index 8f8e12c..5ce4b6a 100644 --- a/public/text/system/parser.json +++ b/public/text/system/parser.json @@ -7,6 +7,9 @@ "take_cannot": "You cannot take that.", "inventory_empty": "You are not carrying anything.", "inventory_items": "You are carrying: {items}", + "go_to_prompt": "Where do you want to go?", + "go_to_not_found": "You can't get to {target} from here.", + "go_to_success": "You go to {target}.", "use_prompt": "Use what?", "use_format_prompt": "Use what on what? (Format: USE ITEM ON TARGET)", "use_missing_item": "You don't have the {item}.", diff --git a/src/core/Game.ts b/src/core/Game.ts index 932e7ff..cfdcdb0 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -10,8 +10,11 @@ import { registerDemoScripts } from '../scripts/DemoScripts'; import { registerUserScripts } from '../scripts/main'; import { AudioManager } from './AudioManager'; import { TextAssetManager } from './TextAssetManager'; +import type { GameActionOutcome } from './GameActionTypes'; import { Console } from './Console'; +import { ScriptRegistry } from './ScriptRegistry'; +import { ComponentSystem } from '../systems/ComponentSystem'; import type { IGame } from './IGame'; @@ -420,6 +423,235 @@ export class Game implements IGame { return this.textAssets.getServiceText(key, params); } + look(target?: string | null): GameActionOutcome { + const scene = this.sceneManager.currentScene; + if (!scene) { + return { + status: 'failed', + code: 'no_current_scene', + message: this.text('parser.parse_unknown'), + recoverable: false, + }; + } + + const normalizedTarget = String(target || '').trim(); + const normalizedUpper = normalizedTarget.toUpperCase(); + + if ( + !normalizedTarget || + normalizedUpper === 'AROUND' || + normalizedUpper === 'HERE' || + normalizedUpper === 'SCENE' + ) { + const sceneDescription = + this.textAssets.getResolvedSceneField(scene, 'description') || + scene.description || + this.text('parser.look_default_scene', { scene: scene.name }); + return { + status: 'ok', + code: 'scene_description', + message: sceneDescription, + data: { targetType: 'scene', sceneId: scene.id }, + }; + } + + const entity = scene.findEntity(normalizedTarget); + if (!entity) { + return { + status: 'failed', + code: 'entity_not_found', + message: this.text('parser.look_not_found', { target: normalizedTarget }), + data: { target: normalizedTarget }, + recoverable: true, + }; + } + + const interactionId = entity.interactions && (entity.interactions.look || entity.interactions.LOOK); + if (interactionId) { + ScriptRegistry.execute(interactionId, { game: this, entity }); + return { + status: 'ok', + code: 'delegated_script', + data: { targetType: 'entity', entityId: entity.name, scriptId: interactionId }, + effects: ['script_executed'], + }; + } + + const description = this.textAssets.getResolvedObjectField(entity, 'description') || entity.description; + if (description && description.trim()) { + return { + status: 'ok', + code: 'entity_description', + message: description, + data: { targetType: 'entity', entityId: entity.name }, + }; + } + + return { + status: 'escalate', + code: 'missing_description', + message: this.text('parser.look_default_object', { target: normalizedTarget }), + data: { targetType: 'entity', entityId: entity.name }, + recoverable: true, + }; + } + + take(target?: string | null): GameActionOutcome { + const scene = this.sceneManager.currentScene; + if (!scene) { + return { + status: 'failed', + code: 'no_current_scene', + message: this.text('parser.parse_unknown'), + recoverable: false, + }; + } + + const normalizedTarget = String(target || '').trim(); + if (!normalizedTarget) { + return { + status: 'needs_clarification', + code: 'missing_take_target', + message: this.text('parser.take_prompt'), + recoverable: true, + }; + } + + const entity = scene.findEntity(normalizedTarget); + if (!entity) { + return { + status: 'failed', + code: 'entity_not_found', + message: this.text('parser.look_not_found', { target: normalizedTarget }), + data: { target: normalizedTarget }, + recoverable: true, + }; + } + + const interactionId = + entity.interactions && (entity.interactions.pickup || entity.interactions.PICKUP); + if (interactionId) { + ScriptRegistry.execute(interactionId, { game: this, entity }); + return { + status: 'ok', + code: 'delegated_script', + data: { targetType: 'entity', entityId: entity.name, scriptId: interactionId }, + effects: ['script_executed'], + }; + } + + const errorMsg = ComponentSystem.canTakeItem(entity, scene.player); + if (errorMsg) { + return { + status: 'failed', + code: 'cannot_take', + message: errorMsg, + data: { entityId: entity.name }, + recoverable: true, + }; + } + + const isItem = entity.components && entity.components.find((c: any) => c.type === 'Item'); + if (isItem || entity.isTakeable) { + scene.removeEntity(entity); + this.inventory.push(entity); + return { + status: 'ok', + code: 'item_taken', + message: this.text('parser.take_pickup_success', { + item: entity.customName || entity.name, + }), + data: { entityId: entity.name }, + effects: ['moved_to_inventory'], + }; + } + + return { + status: 'failed', + code: 'not_takeable', + message: this.text('parser.take_cannot'), + data: { entityId: entity.name }, + recoverable: true, + }; + } + + showInventory(): GameActionOutcome { + return { + status: 'ok', + code: 'inventory_list', + message: + this.inventory.length === 0 + ? this.text('parser.inventory_empty') + : this.text('parser.inventory_items', { + items: this.inventory.map((e: any) => e.customName || e.name).join(', '), + }), + data: { + count: this.inventory.length, + }, + }; + } + + goTo(target?: string | null): GameActionOutcome { + const normalizedTarget = String(target || '').trim(); + if (!normalizedTarget) { + return { + status: 'needs_clarification', + code: 'missing_destination', + message: this.text('parser.go_to_prompt'), + recoverable: true, + }; + } + + const currentScene = this.sceneManager.currentScene; + const sceneMatch = Array.from(this.sceneManager.scenes.values()).find((scene) => { + const resolvedTitle = this.textAssets.getResolvedSceneField(scene, 'title'); + const normalized = normalizedTarget.toUpperCase(); + return ( + scene.id.toUpperCase() === normalized || + scene.name.toUpperCase() === normalized || + (!!resolvedTitle && resolvedTitle.toUpperCase() === normalized) + ); + }); + + if (sceneMatch) { + this.sceneManager.switchTo(sceneMatch.id); + return { + status: 'ok', + code: 'scene_switched', + message: + this.textAssets.getResolvedSceneField(sceneMatch, 'description') || + sceneMatch.description || + this.text('parser.go_to_success', { target: sceneMatch.name }), + data: { targetType: 'scene', sceneId: sceneMatch.id }, + effects: currentScene?.id !== sceneMatch.id ? ['scene_changed'] : [], + }; + } + + if (currentScene?.player) { + const entity = currentScene.findEntity(normalizedTarget); + if (entity && 'x' in entity && 'y' in entity) { + currentScene.player.walkTo((entity as any).x, (entity as any).y); + return { + status: 'ok', + code: 'player_moving', + message: this.text('parser.go_to_success', { + target: this.textAssets.getResolvedObjectField(entity, 'title') || entity.name, + }), + data: { targetType: 'entity', entityId: entity.name }, + effects: ['player_move_started'], + }; + } + } + + return { + status: 'failed', + code: 'destination_not_found', + message: this.text('parser.go_to_not_found', { target: normalizedTarget }), + data: { target: normalizedTarget }, + recoverable: true, + }; + } + showNotification(text: string): void { if (this.onMessage) { this.onMessage(text); diff --git a/src/core/GameActionTypes.ts b/src/core/GameActionTypes.ts new file mode 100644 index 0000000..007d32b --- /dev/null +++ b/src/core/GameActionTypes.ts @@ -0,0 +1,11 @@ +export type GameActionStatus = 'ok' | 'failed' | 'needs_clarification' | 'escalate'; + +export interface GameActionOutcome { + status: GameActionStatus; + code: string; + message?: string; + data?: Record<string, unknown>; + effects?: string[]; + recoverable?: boolean; +} + diff --git a/src/core/IGame.ts b/src/core/IGame.ts index 752bda8..100fb79 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -4,6 +4,7 @@ import { SceneManager } from '../scene/SceneManager'; import { SceneEditor } from '../tools/SceneEditor'; import { Entity } from '../entities/Entity'; import { TextAssetManager } from './TextAssetManager'; +import type { GameActionOutcome } from './GameActionTypes'; export interface IGame { assets: AssetLoader; @@ -16,6 +17,10 @@ export interface IGame { showMessage(text: string): void; log(text: string): void; text(key: string, params?: Record<string, string | number>): string; + look(target?: string | null): GameActionOutcome; + take(target?: string | null): GameActionOutcome; + showInventory(): GameActionOutcome; + goTo(target?: string | null): GameActionOutcome; showNotification?(text: string): void; // Optional onSceneChange?(sceneName: string): void; playSound(name: string): void; diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 1936969..7447359 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -13,6 +13,9 @@ const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { take_cannot: 'You cannot take that.', inventory_empty: 'You are not carrying anything.', inventory_items: 'You are carrying: {items}', + go_to_prompt: 'Where do you want to go?', + go_to_not_found: "You can't get to {target} from here.", + go_to_success: 'You go to {target}.', use_prompt: 'Use what?', use_format_prompt: 'Use what on what? (Format: USE ITEM ON TARGET)', use_missing_item: "You don't have the {item}.", diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index f748f60..94ef705 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -1,5 +1,4 @@ -import { ScriptRegistry } from '../core/ScriptRegistry'; -import { ComponentSystem } from '../systems/ComponentSystem'; +import type { GameActionOutcome } from '../core/GameActionTypes'; type ParserEntityContext = { id: string; @@ -14,6 +13,12 @@ type ParserInventoryItemContext = { title: string | null; }; +type ParserPendingState = { + intent: 'take' | 'goTo'; + question: string; + originalInput: string; +}; + type ParserContext = { rawInput: string; normalizedInput: string; @@ -25,44 +30,46 @@ type ParserContext = { } | null; entities: ParserEntityContext[]; inventory: ParserInventoryItemContext[]; + pending: ParserPendingState | null; }; -type ParserAction = - | { type: 'lookScene' } - | { type: 'lookEntity'; target: string } - | { type: 'takeEntity'; target: string | null } - | { type: 'showInventory' } - | { type: 'handoff'; reason: string; verb: string; noun: string; rawInput: string }; +type ParserToolAction = + | { + type: 'callGameMethod'; + method: 'look' | 'take' | 'showInventory' | 'goTo'; + args: Array<string | null>; + } + | { + type: 'handoff'; + reason: string; + verb: string; + noun: string; + rawInput: string; + }; type ParserActionEnvelope = { - stage: 'regex-v1'; - actions: ParserAction[]; + stage: 'regex-v1' | 'pending-resolution'; + actions: ParserToolAction[]; debug: { rawInput: string; normalizedInput: string; verb: string; noun: string; + pendingIntent?: string; }; }; type ParserResult = | { - type: 'message'; - handled: true; - messages: string[]; + type: 'outcomes'; + handled: boolean; + outcomes: GameActionOutcome[]; actionsExecuted: string[]; } - | { - type: 'scriptDelegated'; - handled: true; - messages: string[]; - actionsExecuted: string[]; - delegatedScriptId: string; - } | { type: 'handoff'; handled: false; - messages: string[]; + outcomes: GameActionOutcome[]; actionsExecuted: string[]; reason: string; debug: Record<string, unknown>; @@ -71,35 +78,57 @@ type ParserResult = type ParserResponse = { playerMessage?: string; debugMessages?: string[]; + nextPendingState?: ParserPendingState | null; }; +const STAGE1_COMMAND_WORDS = new Set([ + 'LOOK', + 'EXAMINE', + 'X', + 'TAKE', + 'GET', + 'PICKUP', + 'INV', + 'INVENTORY', + 'I', + 'GO', + 'WALK', + 'MOVE', +]); + export class Parser { game: any; inputField: HTMLInputElement | null; + pendingState: ParserPendingState | null; constructor(game: any) { this.game = game; this.inputField = null; + this.pendingState = null; } parse(input: string): void { const trimmed = input.trim(); if (!trimmed) return; + const actionEnvelope = this.resolvePendingAction(trimmed); const contextJson = this.buildContextJson(trimmed); - const actionJson = this.runStage1(trimmed, contextJson); - const resultJson = this.executeActionJson(actionJson, contextJson); + const actionJson = actionEnvelope || this.runStage1(trimmed); + const resultJson = this.executeActionJson(actionJson); const response = this.buildResponse(resultJson, actionJson, contextJson); - if (response.playerMessage) { - this.game.log(response.playerMessage); - } - if (response.debugMessages?.length) { for (const message of response.debugMessages) { this.game.console?.log(message, 'info'); } } + + this.pendingState = + response.nextPendingState === undefined ? this.pendingState : response.nextPendingState; + + if (response.playerMessage) { + this.game.log(response.playerMessage); + } } private buildContextJson(rawInput: string): string { @@ -134,44 +163,93 @@ export class Parser { title: this.game.textAssets.getResolvedObjectField(entity, 'title'), })) .filter((entity: ParserInventoryItemContext) => !!entity.title?.trim()), + pending: this.pendingState + ? { + intent: this.pendingState.intent, + question: this.pendingState.question, + originalInput: this.pendingState.originalInput, + } + : null, }; return JSON.stringify(context); } - private runStage1(input: string, _contextJson: string): string { + private resolvePendingAction(input: string): string | null { + if (!this.pendingState) return null; + if (this.looksLikeFreshCommand(input)) { + this.pendingState = null; + return null; + } + + const action: ParserToolAction = { + type: 'callGameMethod', + method: this.pendingState.intent, + args: [input.trim()], + }; + + const envelope: ParserActionEnvelope = { + stage: 'pending-resolution', + actions: [action], + debug: { + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + verb: this.pendingState.intent.toUpperCase(), + noun: input.trim(), + pendingIntent: this.pendingState.intent, + }, + }; + + return JSON.stringify(envelope); + } + + private runStage1(input: string): string { const words = input.trim().split(/\s+/); const verb = (words[0] || '').toUpperCase(); const noun = words.slice(1).join(' ').trim(); const normalizedNoun = noun.toUpperCase(); - let actions: ParserAction[]; + let actions: ParserToolAction[]; switch (verb) { case 'LOOK': case 'EXAMINE': case 'X': - if ( - !normalizedNoun || - normalizedNoun === 'AROUND' || - normalizedNoun === 'HERE' || - normalizedNoun === 'SCENE' - ) { - actions = [{ type: 'lookScene' }]; - } else { - actions = [{ type: 'lookEntity', target: noun }]; - } + actions = [ + { + type: 'callGameMethod', + method: 'look', + args: [ + !normalizedNoun || + normalizedNoun === 'AROUND' || + normalizedNoun === 'HERE' || + normalizedNoun === 'SCENE' + ? null + : noun, + ], + }, + ]; break; case 'TAKE': case 'GET': case 'PICKUP': - actions = [{ type: 'takeEntity', target: noun || null }]; + actions = [{ type: 'callGameMethod', method: 'take', args: [noun || null] }]; break; case 'INV': case 'INVENTORY': case 'I': - actions = [{ type: 'showInventory' }]; + actions = [{ type: 'callGameMethod', method: 'showInventory', args: [] }]; + break; + case 'GO': + case 'WALK': + case 'MOVE': { + let target = noun; + if (target.toUpperCase().startsWith('TO ')) { + target = target.slice(3).trim(); + } + actions = [{ type: 'callGameMethod', method: 'goTo', args: [target || null] }]; break; + } default: actions = [ { @@ -199,29 +277,15 @@ export class Parser { return JSON.stringify(envelope); } - private executeActionJson(actionJson: string, _contextJson: string): string { + private executeActionJson(actionJson: string): string { const envelope = JSON.parse(actionJson) as ParserActionEnvelope; - const scene = this.game.sceneManager.currentScene; const executedActions: string[] = []; - if (!scene) { + if (!envelope.actions.length) { const result: ParserResult = { type: 'handoff', handled: false, - messages: [], - actionsExecuted: executedActions, - reason: 'no_current_scene', - debug: { actionJson }, - }; - return JSON.stringify(result); - } - - const firstAction = envelope.actions[0]; - if (!firstAction) { - const result: ParserResult = { - type: 'handoff', - handled: false, - messages: [], + outcomes: [], actionsExecuted: executedActions, reason: 'empty_action_plan', debug: { actionJson }, @@ -229,166 +293,62 @@ export class Parser { return JSON.stringify(result); } - switch (firstAction.type) { - case 'lookScene': { - executedActions.push('lookScene'); - const sceneDescription = - this.game.textAssets.getResolvedSceneField(scene, 'description') || - scene.description || - this.game.text('parser.look_default_scene', { scene: scene.name }); - const result: ParserResult = { - type: 'message', - handled: true, - messages: [sceneDescription], - actionsExecuted: executedActions, - }; - return JSON.stringify(result); - } - case 'lookEntity': { - executedActions.push('lookEntity'); - const entity = scene.findEntity(firstAction.target); - if (!entity) { - const result: ParserResult = { - type: 'message', - handled: true, - messages: [this.game.text('parser.look_not_found', { target: firstAction.target })], - actionsExecuted: executedActions, - }; - return JSON.stringify(result); - } - - const interactionId = - entity.interactions && (entity.interactions.look || entity.interactions.LOOK); - if (interactionId) { - ScriptRegistry.execute(interactionId, { game: this.game, entity }); - const result: ParserResult = { - type: 'scriptDelegated', - handled: true, - messages: [], - actionsExecuted: executedActions, - delegatedScriptId: interactionId, - }; - return JSON.stringify(result); - } - - const description = - this.game.textAssets.getResolvedObjectField(entity, 'description') || entity.description; - const result: ParserResult = { - type: 'message', - handled: true, - messages: [ - description || - this.game.text('parser.look_default_object', { target: firstAction.target }), - ], - actionsExecuted: executedActions, - }; - return JSON.stringify(result); - } - case 'takeEntity': { - executedActions.push('takeEntity'); - if (!firstAction.target) { - const result: ParserResult = { - type: 'message', - handled: true, - messages: [this.game.text('parser.take_prompt')], - actionsExecuted: executedActions, - }; - return JSON.stringify(result); - } - - const entity = scene.findEntity(firstAction.target); - if (!entity) { - const result: ParserResult = { - type: 'message', - handled: true, - messages: [this.game.text('parser.look_not_found', { target: firstAction.target })], - actionsExecuted: executedActions, - }; - return JSON.stringify(result); - } - - const interactionId = - entity.interactions && (entity.interactions.pickup || entity.interactions.PICKUP); - if (interactionId) { - ScriptRegistry.execute(interactionId, { game: this.game, entity }); - const result: ParserResult = { - type: 'scriptDelegated', - handled: true, - messages: [], - actionsExecuted: executedActions, - delegatedScriptId: interactionId, - }; - return JSON.stringify(result); - } - - const errorMsg = ComponentSystem.canTakeItem(entity, scene.player); - if (errorMsg) { - const result: ParserResult = { - type: 'message', - handled: true, - messages: [errorMsg], - actionsExecuted: executedActions, - }; - return JSON.stringify(result); - } - - const isItem = entity.components && entity.components.find((c: any) => c.type === 'Item'); - if (isItem || entity.isTakeable) { - scene.removeEntity(entity); - this.game.inventory.push(entity); - const result: ParserResult = { - type: 'message', - handled: true, - messages: [ - this.game.text('parser.take_pickup_success', { - item: entity.customName || entity.name, - }), - ], - actionsExecuted: executedActions, - }; - return JSON.stringify(result); - } + const outcomes: GameActionOutcome[] = []; - const result: ParserResult = { - type: 'message', - handled: true, - messages: [this.game.text('parser.take_cannot')], - actionsExecuted: executedActions, - }; - return JSON.stringify(result); - } - case 'showInventory': { - executedActions.push('showInventory'); - const result: ParserResult = { - type: 'message', - handled: true, - messages: - this.game.inventory.length === 0 - ? [this.game.text('parser.inventory_empty')] - : [ - this.game.text('parser.inventory_items', { - items: this.game.inventory.map((e: any) => e.customName || e.name).join(', '), - }), - ], - actionsExecuted: executedActions, - }; - return JSON.stringify(result); - } - case 'handoff': - default: { + for (const action of envelope.actions) { + if (action.type === 'handoff') { const result: ParserResult = { type: 'handoff', handled: false, - messages: [], + outcomes, actionsExecuted: executedActions, - reason: firstAction.type === 'handoff' ? firstAction.reason : 'unsupported_action_type', + reason: action.reason, debug: { actionJson, - action: firstAction, + action, }, }; return JSON.stringify(result); } + + executedActions.push(action.method); + const outcome = this.callGameMethod(action.method, action.args); + outcomes.push(outcome); + + if (outcome.status !== 'ok') { + break; + } + } + + const result: ParserResult = { + type: 'outcomes', + handled: true, + outcomes, + actionsExecuted: executedActions, + }; + return JSON.stringify(result); + } + + private callGameMethod( + method: 'look' | 'take' | 'showInventory' | 'goTo', + args: Array<string | null> + ): GameActionOutcome { + switch (method) { + case 'look': + return this.game.look(args[0] || null); + case 'take': + return this.game.take(args[0] || null); + case 'showInventory': + return this.game.showInventory(); + case 'goTo': + return this.game.goTo(args[0] || null); + default: + return { + status: 'escalate', + code: 'unknown_game_method', + message: this.game.text('parser.parse_unknown'), + recoverable: false, + }; } } @@ -406,26 +366,75 @@ export class Parser { ] : undefined; - if (result.type === 'message') { + if (result.type === 'handoff') { return { - playerMessage: result.messages[0], + playerMessage: this.game.text('parser.parse_unknown'), + nextPendingState: null, + debugMessages: peekMessages || [ + `[Parser handoff] context=${contextJson}`, + `[Parser handoff] actions=${actionJson}`, + `[Parser handoff] result=${resultJson}`, + ], + }; + } + + const clarification = result.outcomes.find((outcome) => outcome.status === 'needs_clarification'); + if (clarification) { + return { + playerMessage: clarification.message || this.game.text('parser.parse_unknown'), + nextPendingState: { + intent: clarification.code === 'missing_destination' ? 'goTo' : 'take', + question: clarification.message || this.game.text('parser.parse_unknown'), + originalInput: this.extractRawInput(actionJson), + }, debugMessages: peekMessages, }; } - if (result.type === 'scriptDelegated') { - return result.messages.length > 0 - ? { playerMessage: result.messages[0], debugMessages: peekMessages } - : { debugMessages: peekMessages }; + const escalation = result.outcomes.find((outcome) => outcome.status === 'escalate'); + if (escalation) { + return { + playerMessage: escalation.message || this.game.text('parser.parse_unknown'), + nextPendingState: null, + debugMessages: peekMessages || [ + `[Parser handoff] context=${contextJson}`, + `[Parser handoff] actions=${actionJson}`, + `[Parser handoff] result=${resultJson}`, + ], + }; + } + + const firstFailure = result.outcomes.find((outcome) => outcome.status === 'failed'); + if (firstFailure) { + return { + playerMessage: firstFailure.message || this.game.text('parser.parse_unknown'), + nextPendingState: null, + debugMessages: peekMessages, + }; } + const finalOutcomeWithMessage = [...result.outcomes].reverse().find((outcome) => !!outcome.message); return { - playerMessage: this.game.text('parser.parse_unknown'), - debugMessages: peekMessages || [ - `[Parser handoff] context=${contextJson}`, - `[Parser handoff] actions=${actionJson}`, - `[Parser handoff] result=${resultJson}`, - ], + playerMessage: finalOutcomeWithMessage?.message, + nextPendingState: null, + debugMessages: peekMessages, }; } + + private extractRawInput(actionJson: string): string { + try { + const envelope = JSON.parse(actionJson) as ParserActionEnvelope; + return envelope.debug.rawInput; + } catch { + return ''; + } + } + + private looksLikeFreshCommand(input: string): boolean { + const trimmed = input.trim(); + if (!trimmed) return false; + if (trimmed.startsWith('#') || trimmed.startsWith('-')) return true; + const firstWord = trimmed.split(/\s+/)[0]?.toUpperCase() || ''; + return STAGE1_COMMAND_WORDS.has(firstWord); + } } From 3d51ed80b215d046cfc309af4611fd49c63bd4aa Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Thu, 12 Mar 2026 01:20:55 +0200 Subject: [PATCH 10/75] Feature: add adaptive scene cache management Introduce scene registry plus adaptive cache eviction for loaded scenes.\nChoose cache budget from device memory class and log the detected profile on startup.\nShow editor cache stats as MEM x | y in the bottom bar.\nUpdate scene save/save-as flow and game.goTo() to work with the new registry/cache model. --- progress.md | 13 + src/components/editor/EditorBottomMenu.tsx | 6 + src/core/Game.ts | 15 +- src/editor.css | 8 + src/scene/SceneManager.ts | 526 +++++++++++++++---- src/tools/editor/EditorPersistenceManager.ts | 2 + 6 files changed, 464 insertions(+), 106 deletions(-) create mode 100644 progress.md diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..0b2523b --- /dev/null +++ b/progress.md @@ -0,0 +1,13 @@ +Original prompt: Давай временно переключимся с парсера к архитектуре приложения и придумаем способ автоматического управления загруженными сценами, чтобы не получалось так, что каждая открытая сцена остаётся в RAM. Возможно, разумно хранить несколько наиболее часто загружаемых сцен, а редко загружаемые из памяти выгружать. И вообще мониторить кол-во памяти занятое сценами, и регулировать кол-во сцен в кэше в зависимости от этого + +- Решение: вводим Scene Registry + adaptive scene cache. +- Scene cache policy: единая для runtime и editor. +- Budget: по estimated scene weight, не по browser memory API. +- UI: в нижней строке editor слева показывать `MEM x | y`, где `x` — estimated memory, `y` — число сцен в cache. +- Важно не трогать локальные артефакты пользователя: `public/scenes/bug.json`, `public/scenes/ttt.json`, `public/scenes/test_room.json`, `public/text/scenes/new_scene.json`, `tasks.md`, `.nvimlog`. +- Реализовано: SceneManager разделён на registry + cache metadata; добавлены estimated weight, eviction, cache stats и registry scan по `public/scenes`. +- Реализовано: `game.goTo()` теперь ищет сцену через `sceneRegistry`, а не только среди живых scene instances. +- Реализовано: bottom menu editor показывает `MEM x | y` слева. +- Реализовано: save/save-as синхронизирует scene registration, чтобы cache/registry не теряли сцену после смены id. +- Проверки: `npm run -s typecheck` и `npm run -s build` проходят. +- Ограничение среды: локальный `npm run dev` smoke test в этой сессии блокируется sandbox-ошибкой `vite -> esbuild spawn EPERM`, поэтому браузерный прогон не был надёжно выполнен. diff --git a/src/components/editor/EditorBottomMenu.tsx b/src/components/editor/EditorBottomMenu.tsx index 633ad1b..06b17f2 100644 --- a/src/components/editor/EditorBottomMenu.tsx +++ b/src/components/editor/EditorBottomMenu.tsx @@ -7,10 +7,15 @@ export const EditorBottomMenu: React.FC = () => { const { toggle, toggleSpriteEditor } = useEditorStore(); const [fps, setFps] = React.useState(0); + const [sceneMem, setSceneMem] = React.useState(0); + const [sceneCount, setSceneCount] = React.useState(0); React.useEffect(() => { const interval = setInterval(() => { setFps(game.fps); + const stats = game.sceneManager.getSceneCacheStats(); + setSceneMem(stats.estimatedMemory); + setSceneCount(stats.loadedScenes); }, 500); return () => clearInterval(interval); }, [game]); @@ -54,6 +59,7 @@ export const EditorBottomMenu: React.FC = () => { return ( <div className="editor-bottom-menu" style={{ zIndex: 2000 }}> + <div className="mem-counter">{`MEM ${sceneMem} | ${sceneCount}`}</div> {keys.map((k) => ( <button key={k.label} className="e-menu-btn" onClick={() => handleAction(k.action)}> <span className="hotkey-accent">{k.label.split(' ')[0]}</span> diff --git a/src/core/Game.ts b/src/core/Game.ts index cfdcdb0..0be79d4 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -603,24 +603,17 @@ export class Game implements IGame { } const currentScene = this.sceneManager.currentScene; - const sceneMatch = Array.from(this.sceneManager.scenes.values()).find((scene) => { - const resolvedTitle = this.textAssets.getResolvedSceneField(scene, 'title'); - const normalized = normalizedTarget.toUpperCase(); - return ( - scene.id.toUpperCase() === normalized || - scene.name.toUpperCase() === normalized || - (!!resolvedTitle && resolvedTitle.toUpperCase() === normalized) - ); - }); + const sceneMatch = this.sceneManager.findSceneDescriptorByTarget(normalizedTarget); if (sceneMatch) { this.sceneManager.switchTo(sceneMatch.id); + const activeScene = this.sceneManager.currentScene; return { status: 'ok', code: 'scene_switched', message: - this.textAssets.getResolvedSceneField(sceneMatch, 'description') || - sceneMatch.description || + (activeScene && this.textAssets.getResolvedSceneField(activeScene, 'description')) || + activeScene?.description || this.text('parser.go_to_success', { target: sceneMatch.name }), data: { targetType: 'scene', sceneId: sceneMatch.id }, effects: currentScene?.id !== sceneMatch.id ? ['scene_changed'] : [], diff --git a/src/editor.css b/src/editor.css index bc12552..130d0af 100644 --- a/src/editor.css +++ b/src/editor.css @@ -220,6 +220,14 @@ color: var(--ui-text-dim, #666); } +.mem-counter { + position: absolute; + left: 10px; + font-family: monospace; + font-weight: bold; + color: var(--ui-text-dim, #666); +} + /* ... existing code ... */ /* Entity List Items */ diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index 02078ce..4940c34 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -6,30 +6,74 @@ import { Walkbox } from '../entities/Walkbox'; import { Triggerbox } from '../entities/Triggerbox'; import { QuadObject } from '../entities/QuadObject'; +export interface SceneDescriptor { + id: string; + path: string; + name: string; + title: string | null; + sourceData: any | null; + lastIndexed: number; +} + +export interface SceneCacheStats { + estimatedMemory: number; + loadedScenes: number; + budget: number; +} + +type DeviceMemoryProfile = { + className: string; + deviceMemoryGb: number | null; + sceneCacheBudget: number; +}; + +type CachedSceneEntry = { + scene: Scene; + estimatedWeight: number; + lastAccessed: number; + pinned: boolean; +}; + export class SceneManager { game: IGame; currentScene: Scene | null; scenes: Map<string, Scene>; + sceneRegistry: Map<string, SceneDescriptor>; + private sceneCacheMeta: Map<string, CachedSceneEntry>; + private sceneCacheBudget: number; constructor(game: IGame) { this.game = game; this.currentScene = null; this.scenes = new Map(); + this.sceneRegistry = new Map(); + this.sceneCacheMeta = new Map(); + const memoryProfile = this.detectDeviceMemoryProfile(); + this.sceneCacheBudget = memoryProfile.sceneCacheBudget; + console.log( + `[SceneManager] Device class: ${memoryProfile.className}` + + ` (deviceMemory=${memoryProfile.deviceMemoryGb ?? 'unknown'}GB)` + + `, scene cache budget: ${this.sceneCacheBudget}` + ); + void this.refreshSceneRegistry(); } addScene(scene: Scene): void { - this.scenes.set(scene.id, scene); + this.syncSceneRegistration(scene); + this.cacheScene(scene, false); } switchTo(sceneId: string): void { - const scene = this.scenes.get(sceneId); + const scene = this.ensureSceneLoaded(sceneId); if (scene) { this.currentScene = scene; + this.touchScene(scene.id); + this.pinCurrentScene(); this.exposeEntitiesToWindow(); - // Optional: Notify UI provided by Game if (this.game.onSceneChange) { this.game.onSceneChange(scene.name); } + this.evictScenesIfNeeded(); } else { console.error(`Scene ${sceneId} not found!`); } @@ -42,7 +86,6 @@ export class SceneManager { (window as { __QUEST_EXPOSE_GLOBALS__?: boolean }).__QUEST_EXPOSE_GLOBALS__ === true; if (!shouldExpose) return; - // Expose all entities by Name to window for Console API usage this.currentScene.entities.forEach((entity) => { if (entity.name) { (window as unknown as Record<string, unknown>)[entity.name] = entity; @@ -53,6 +96,7 @@ export class SceneManager { update(deltaTime: number): void { if (this.currentScene) { this.currentScene.update(deltaTime); + this.refreshCurrentSceneWeight(); } } @@ -64,123 +108,415 @@ export class SceneManager { async loadScene(filename: string): Promise<void> { try { - // Filename comes from FileBrowser as 'path/to/file.json' or 'file.json' - // We want ID to be 'path\to\file' const idFromPath = filename.replace('.json', '').replace(/\//g, '\\'); - - const response = await fetch(`/scenes/${filename}?t=${Date.now()}`); // Burst cache + const response = await fetch(`/scenes/${filename}?t=${Date.now()}`); if (!response.ok) throw new Error('File not found'); const data = await response.json(); - - // Pass the derived ID to loadSceneData - await this.loadSceneData(data, idFromPath); + await this.loadSceneData(data, idFromPath, filename); } catch (e) { console.error(e); this.game.showNotification?.('Failed to load scene'); } } - async loadSceneData(data: any, filename?: string): Promise<void> { + async loadSceneData(data: any, filename?: string, explicitPath?: string): Promise<void> { try { - // Priority: - // 1. filename argument (derived from path: "sub\scene") - // 2. data.id (from json) - // 3. Fallback const sceneId = filename || data.id || 'loaded_scene'; - const newScene = new Scene(this.game, sceneId, data.name || 'Untitled'); - - if (filename) { - // Determine filename for saving (forward slashes) - newScene.filename = filename.replace(/\\/g, '/'); - } else if (data.filename) newScene.filename = data.filename; - - // If ID was missing in File but provided by filename, ensure consistency - newScene.id = sceneId; - if (data.description !== undefined) newScene.description = data.description; - if (data.textRedirects) newScene.textRedirects = { ...data.textRedirects }; - - // Restore Camera - if (data.camera) { - newScene.defaultCamera = { ...data.camera }; - newScene.camera = { ...data.camera }; // Apply default to runtime immediately + const pathValue = explicitPath || `${sceneId.replace(/\\/g, '/')}.json`; + const newScene = this.instantiateScene(sceneId, data, pathValue); + + this.syncSceneRegistration(newScene, undefined, data); + this.cacheScene(newScene, false); + this.switchTo(newScene.id); + await this.game.textAssets.preloadScene(newScene); + this.syncSceneRegistration(newScene, undefined, newScene.toJSON()); + this.refreshCurrentSceneWeight(); + + if (this.game.editor) { + this.game.editor.refreshHierarchy(); } + } catch (e) { + console.error('Failed to load scene:', e); + if (this.game.showNotification) this.game.showNotification('Error loading JSON'); + } + } - if (data.autoCenter !== undefined) { - newScene.autoCenter = data.autoCenter; + syncSceneRegistration(scene: Scene, previousId?: string, sourceData?: any): void { + const sceneId = scene.id; + const pathValue = this.getScenePathFromScene(scene); + const descriptor: SceneDescriptor = { + id: sceneId, + path: pathValue, + name: scene.name, + title: this.game.textAssets.getResolvedSceneField(scene, 'title') || scene.name || sceneId, + sourceData: sourceData ? JSON.parse(JSON.stringify(sourceData)) : null, + lastIndexed: Date.now(), + }; + + if (previousId && previousId !== sceneId) { + this.sceneRegistry.delete(previousId); + const previousScene = this.scenes.get(previousId); + const previousMeta = this.sceneCacheMeta.get(previousId); + if (previousScene) { + this.scenes.delete(previousId); + this.scenes.set(sceneId, previousScene); } - if (data.cameraSpeed !== undefined) { - newScene.cameraSpeed = data.cameraSpeed; + if (previousMeta) { + this.sceneCacheMeta.delete(previousId); + this.sceneCacheMeta.set(sceneId, { + ...previousMeta, + scene, + estimatedWeight: this.estimateSceneWeight(scene), + }); } - if (data.camDeadzoneX !== undefined) newScene.camDeadzoneX = data.camDeadzoneX; - if (data.camDeadzoneY !== undefined) newScene.camDeadzoneY = data.camDeadzoneY; - if (data.camMinX !== undefined) newScene.camMinX = data.camMinX; - if (data.camMaxX !== undefined) newScene.camMaxX = data.camMaxX; - if (data.camMinY !== undefined) newScene.camMinY = data.camMinY; - if (data.camMaxY !== undefined) newScene.camMaxY = data.camMaxY; - - // Restore Cameraling - if (data.scaling) { - newScene.scaling = data.scaling; + } + + this.sceneRegistry.set(sceneId, descriptor); + } + + getSceneCacheStats(): SceneCacheStats { + let estimatedMemory = 0; + for (const entry of this.sceneCacheMeta.values()) { + estimatedMemory += entry.estimatedWeight; + } + return { + estimatedMemory: Math.round(estimatedMemory), + loadedScenes: this.scenes.size, + budget: this.sceneCacheBudget, + }; + } + + estimateSceneWeight(scene: Scene): number { + let weight = 16; + + weight += (scene.walkbox?.length || 0) * 6; + weight += (scene.triggerboxes?.length || 0) * 8; + + for (const entity of scene.entities || []) { + switch (entity.type) { + case 'Actor': + weight += 24; + break; + case 'Quad': + weight += 18 + (((entity as any).vertices?.length || 0) * 3); + break; + default: + weight += 12; + break; } - // Restore Walkboxes - if (data.walkbox) { - newScene.walkbox = (data.walkbox || []).map((wb: any) => { - const poly = wb.poly.map((p: any) => ({ x: Number(p.x), y: Number(p.y) })); - const w = new Walkbox(poly, wb.name || 'Walkbox'); - w.load(wb); - return w; - }); + weight += ((entity.components || []).length || 0) * 4; + weight += Object.keys(entity.interactions || {}).length * 2; + if ((entity as any).animSets) { + weight += Object.keys((entity as any).animSets).length * 4; } + } - // Restore Triggerboxes - if (data.triggerboxes) { - newScene.triggerboxes = (data.triggerboxes || []).map((t: any) => { - const poly = t.poly.map((p: any) => ({ x: Number(p.x), y: Number(p.y) })); - const tb = new Triggerbox(poly, t.name || 'Triggerbox', t.script || ''); - tb.load(t); - return tb; - }); + for (const wb of scene.walkbox || []) { + weight += (wb.poly?.length || 0) * 2; + } + + for (const tb of scene.triggerboxes || []) { + weight += (tb.poly?.length || 0) * 2; + weight += ((tb.components || []).length || 0) * 4; + } + + return weight; + } + + findSceneDescriptorByTarget(target: string): SceneDescriptor | null { + const normalized = String(target || '') + .trim() + .toUpperCase(); + if (!normalized) return null; + + for (const descriptor of this.sceneRegistry.values()) { + if ( + descriptor.id.toUpperCase() === normalized || + descriptor.name.toUpperCase() === normalized || + (!!descriptor.title && descriptor.title.toUpperCase() === normalized) + ) { + return descriptor; } + } - if (data.entities) { - data.entities.forEach((entityData: any) => { - let entity: Entity; - - if (entityData.type === 'Player') { - // Legacy: Convert Player to Actor - entity = Actor.fromJSON(this.game, { ...entityData, type: 'Actor', isPlayer: true }); - } else if (entityData.type === 'Actor') { - entity = Actor.fromJSON(this.game, entityData); - } else if (entityData.type === 'Quad' || entityData.type === 'Rect') { - entity = QuadObject.fromJSON(this.game, entityData); - } else { - entity = Entity.fromJSON(this.game, entityData); - } - - newScene.addEntity(entity); - }); + return null; + } + + async refreshSceneRegistry(): Promise<void> { + try { + const files = await this.listSceneFiles('public/scenes'); + const seenIds = new Set<string>(); + + for (const file of files) { + const sceneId = file.replace('.json', '').replace(/\//g, '\\'); + seenIds.add(sceneId); + const response = await fetch(`/scenes/${file}?t=${Date.now()}`); + if (!response.ok) continue; + const data = await response.json(); + const descriptor: SceneDescriptor = { + id: sceneId, + path: file, + name: data.name || sceneId, + title: (await this.readSceneTitle(sceneId)) || data.name || sceneId, + sourceData: data, + lastIndexed: Date.now(), + }; + this.sceneRegistry.set(sceneId, descriptor); } - this.addScene(newScene); - this.switchTo(newScene.id); - await this.game.textAssets.preloadScene(newScene); + for (const [sceneId, descriptor] of [...this.sceneRegistry.entries()]) { + if (!seenIds.has(sceneId) && !this.scenes.has(sceneId)) { + this.sceneRegistry.delete(sceneId); + } else if (!seenIds.has(sceneId) && this.scenes.has(sceneId)) { + this.sceneRegistry.set(sceneId, { + ...descriptor, + lastIndexed: Date.now(), + }); + } + } + } catch (error) { + console.warn('[SceneManager] Failed to refresh scene registry:', error); + } + } - // If Editor is active, it needs to know - if (this.game.editor) { - // But SceneManager shouldn't know about Editor details. - // Game loop handles binding? - // Editor handles its own updates via polling or observing currentScene - this.game.editor.refreshHierarchy(); // Optional: Explicit hook if game.editor exists - // Better: SceneManager emits event. + private instantiateScene(sceneId: string, data: any, pathValue?: string): Scene { + const newScene = new Scene(this.game, sceneId, data.name || 'Untitled'); + + if (pathValue) { + newScene.filename = pathValue.replace('.json', ''); + } else if (data.filename) { + newScene.filename = data.filename; + } + + newScene.id = sceneId; + if (data.description !== undefined) newScene.description = data.description; + if (data.textRedirects) newScene.textRedirects = { ...data.textRedirects }; + + if (data.camera) { + newScene.defaultCamera = { ...data.camera }; + newScene.camera = { ...data.camera }; + } + + if (data.autoCenter !== undefined) newScene.autoCenter = data.autoCenter; + if (data.cameraSpeed !== undefined) newScene.cameraSpeed = data.cameraSpeed; + if (data.camDeadzoneX !== undefined) newScene.camDeadzoneX = data.camDeadzoneX; + if (data.camDeadzoneY !== undefined) newScene.camDeadzoneY = data.camDeadzoneY; + if (data.camMinX !== undefined) newScene.camMinX = data.camMinX; + 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.walkbox) { + newScene.walkbox = (data.walkbox || []).map((wb: any) => { + const poly = wb.poly.map((p: any) => ({ x: Number(p.x), y: Number(p.y) })); + const w = new Walkbox(poly, wb.name || 'Walkbox'); + w.load(wb); + return w; + }); + } + + if (data.triggerboxes) { + newScene.triggerboxes = (data.triggerboxes || []).map((t: any) => { + const poly = t.poly.map((p: any) => ({ x: Number(p.x), y: Number(p.y) })); + const tb = new Triggerbox(poly, t.name || 'Triggerbox', t.script || ''); + tb.load(t); + return tb; + }); + } + + if (data.entities) { + data.entities.forEach((entityData: any) => { + let entity: Entity; + + if (entityData.type === 'Player') { + entity = Actor.fromJSON(this.game, { ...entityData, type: 'Actor', isPlayer: true }); + } else if (entityData.type === 'Actor') { + entity = Actor.fromJSON(this.game, entityData); + } else if (entityData.type === 'Quad' || entityData.type === 'Rect') { + entity = QuadObject.fromJSON(this.game, entityData); + } else { + entity = Entity.fromJSON(this.game, entityData); + } + + newScene.addEntity(entity); + }); + } + + return newScene; + } + + private ensureSceneLoaded(sceneId: string): Scene | null { + const cached = this.scenes.get(sceneId); + if (cached) return cached; + + const descriptor = this.sceneRegistry.get(sceneId); + if (!descriptor?.sourceData) return null; + + const scene = this.instantiateScene(sceneId, descriptor.sourceData, descriptor.path); + this.cacheScene(scene, false); + void this.game.textAssets.preloadScene(scene); + return scene; + } + + private cacheScene(scene: Scene, pinned: boolean): void { + this.scenes.set(scene.id, scene); + const existing = this.sceneCacheMeta.get(scene.id); + this.sceneCacheMeta.set(scene.id, { + scene, + estimatedWeight: this.estimateSceneWeight(scene), + lastAccessed: Date.now(), + pinned: existing?.pinned || pinned, + }); + this.evictScenesIfNeeded(); + } + + private touchScene(sceneId: string): void { + const entry = this.sceneCacheMeta.get(sceneId); + if (!entry) return; + entry.lastAccessed = Date.now(); + } + + private pinCurrentScene(): void { + for (const [sceneId, entry] of this.sceneCacheMeta.entries()) { + entry.pinned = this.currentScene?.id === sceneId; + } + } + + private refreshCurrentSceneWeight(): void { + if (!this.currentScene) return; + const entry = this.sceneCacheMeta.get(this.currentScene.id); + if (!entry) return; + entry.estimatedWeight = this.estimateSceneWeight(this.currentScene); + } + + private evictScenesIfNeeded(): void { + let stats = this.getSceneCacheStats(); + if (stats.estimatedMemory <= this.sceneCacheBudget) return; + + const candidates = [...this.sceneCacheMeta.entries()] + .filter(([sceneId, entry]) => sceneId !== this.currentScene?.id && !entry.pinned) + .sort((a, b) => a[1].lastAccessed - b[1].lastAccessed); + + for (const [sceneId] of candidates) { + if (stats.estimatedMemory <= this.sceneCacheBudget) break; + this.evictScene(sceneId); + stats = this.getSceneCacheStats(); + } + } + + private evictScene(sceneId: string): void { + const scene = this.scenes.get(sceneId); + if (!scene) return; + + const descriptor = this.sceneRegistry.get(sceneId) || { + id: sceneId, + path: this.getScenePathFromScene(scene), + name: scene.name, + title: this.game.textAssets.getResolvedSceneField(scene, 'title') || scene.name, + sourceData: null, + lastIndexed: Date.now(), + }; + + descriptor.name = scene.name; + descriptor.title = this.game.textAssets.getResolvedSceneField(scene, 'title') || scene.name; + descriptor.sourceData = scene.toJSON(); + descriptor.lastIndexed = Date.now(); + this.sceneRegistry.set(sceneId, descriptor); + + this.scenes.delete(sceneId); + this.sceneCacheMeta.delete(sceneId); + } + + private getScenePathFromScene(scene: Scene): string { + const filename = scene.filename || scene.id.replace(/\\/g, '/'); + return `${filename.replace(/\.json$/i, '')}.json`; + } + + private async listSceneFiles(relativeDir: string): Promise<string[]> { + const response = await fetch('/api/list', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: relativeDir }), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + + const payload = (await response.json()) as { files?: Array<{ name: string; isDir: boolean }> }; + const files: string[] = []; + + for (const item of payload.files || []) { + const joined = `${relativeDir}/${item.name}`.replace(/\\/g, '/'); + if (item.isDir) { + const nested = await this.listSceneFiles(joined); + files.push(...nested); + } else if (item.name.toLowerCase().endsWith('.json')) { + files.push(joined.replace(/^public\/scenes\//, '')); } + } - // Only notify if explicit? Or maybe "Scene loaded" is good debug. - // But GDD implies "Scene saved..." is notification. "Scene loaded..." might be noise or notification. - // Let's use notification. - } catch (e) { - console.error('Failed to load scene:', e); - if (this.game.showNotification) this.game.showNotification('Error loading JSON'); + return files; + } + + private async readSceneTitle(sceneId: string): Promise<string | null> { + try { + const scenePath = sceneId.replace(/\\/g, '/'); + const response = await fetch(`/text/scenes/${scenePath}.json?t=${Date.now()}`); + if (!response.ok) return null; + const data = (await response.json()) as Record<string, unknown>; + return typeof data.title === 'string' ? data.title : null; + } catch { + return null; + } + } + + private detectDeviceMemoryProfile(): DeviceMemoryProfile { + const navigatorLike = + typeof navigator !== 'undefined' ? (navigator as Navigator & { deviceMemory?: number }) : null; + const deviceMemoryRaw = + navigatorLike && typeof navigatorLike.deviceMemory === 'number' + ? navigatorLike.deviceMemory + : null; + const deviceMemoryGb = deviceMemoryRaw && Number.isFinite(deviceMemoryRaw) ? deviceMemoryRaw : null; + + if (deviceMemoryGb === null) { + return { + className: 'unknown', + deviceMemoryGb: null, + sceneCacheBudget: 900, + }; } + + if (deviceMemoryGb <= 4) { + return { + className: 'low-memory', + deviceMemoryGb, + sceneCacheBudget: 900, + }; + } + + if (deviceMemoryGb <= 8) { + return { + className: 'mid-memory', + deviceMemoryGb, + sceneCacheBudget: 1500, + }; + } + + if (deviceMemoryGb <= 16) { + return { + className: 'high-memory', + deviceMemoryGb, + sceneCacheBudget: 2400, + }; + } + + return { + className: 'very-high-memory', + deviceMemoryGb, + sceneCacheBudget: 3200, + }; } } diff --git a/src/tools/editor/EditorPersistenceManager.ts b/src/tools/editor/EditorPersistenceManager.ts index 853ab8f..32afe10 100644 --- a/src/tools/editor/EditorPersistenceManager.ts +++ b/src/tools/editor/EditorPersistenceManager.ts @@ -39,6 +39,7 @@ export class EditorPersistenceManager { scene.filename = name; scene.id = idFromName; + this.editor.game.sceneManager.syncSceneRegistration(scene, previousSceneId); this.editor.syncUI(); // Refresh UI to show new Filename this.performSaveScene(scene.filename, previousSceneId); @@ -64,6 +65,7 @@ export class EditorPersistenceManager { }); if (response.ok) { + this.editor.game.sceneManager.syncSceneRegistration(scene, previousSceneId, data); await this.editor.game.textAssets.carrySceneAssetIfNeeded(previousSceneId, scene); // Use Toast Message this.editor.game.showNotification(`Scene saved as ${normalizedPath}.json`); From 9c1a7afa47429f64ecba45e419fc50f943c1cf95 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Thu, 12 Mar 2026 03:07:50 +0200 Subject: [PATCH 11/75] Feature: unify scene and image cache management Rework scene caching around texture-first memory weighting so scene eviction better tracks real image cost.\nAdd scene-aware image cache metadata with active/warm/cold states and cold-first LRU eviction.\nExpose updated scene memory profiler data and document the profiler workflow in GDD technical implementation notes. --- GDD.md | 47 +++++ progress.md | 13 ++ src/core/AssetLoader.ts | 277 +++++++++++++++++++++++++++-- src/core/Game.ts | 11 ++ src/scene/SceneManager.ts | 364 ++++++++++++++++++++++++++++++++++---- tasks.md | 61 ------- 6 files changed, 661 insertions(+), 112 deletions(-) delete mode 100644 tasks.md diff --git a/GDD.md b/GDD.md index efea490..4522b22 100644 --- a/GDD.md +++ b/GDD.md @@ -1179,3 +1179,50 @@ ctx.fillStyle = Theme.backgroundColor; // Получает значение --ui 3. **Данные**: - В `QuadObject` добавляются свойства: `textureName` (string), `tileX` (number), `tileY` (number). - В редактор добавляется выбор текстуры и настройка тайлинга. + +## Отладочный profiler памяти сцен + +В движок встроен debug-profiler для оценки веса сцен и кэшей. Он доступен из DevTools Console через глобальный объект: + +```js +window.__QUEST_DEBUG__ +``` + +Доступные методы: + +```js +await __QUEST_DEBUG__.profileCurrentSceneMemory() +await __QUEST_DEBUG__.profileScenes(['test_room', 'home\\room']) +``` + +`profileCurrentSceneMemory()` снимает снимок по текущей сцене, а `profileScenes([...])` последовательно загружает перечисленные сцены и печатает `console.table(...)` с их метриками. + +Основные поля profiler-отчёта: + +- `weightUnits` — итоговый вес сцены в условных единицах cache policy. +- `graphWeightUnits` — вклад структуры сцены (объекты, компоненты, полигоны). +- `textureWeightUnits` — вклад текстур, доминирующий в общей оценке. +- `estimatedTextureMb` / `textureMb` — грубая оценка объёма уникальных изображений сцены. +- `jsHeapMb` — текущий объём JS heap после загрузки сцены. +- `jsHeapDeltaMb` / `deltaMb` — прирост JS heap между шагами batch-профилирования. +- `bytesPerUnit` / `kbPerUnit` — приблизительная стоимость одной unit-единицы в JS heap. +- `imageCacheMb` — текущий общий вес image cache. + +Пример использования: + +```js +const current = await __QUEST_DEBUG__.profileCurrentSceneMemory() +console.log(current.weightUnits, current.estimatedTextureMb) +``` + +```js +const batch = await __QUEST_DEBUG__.profileScenes(['test_room', 'home\\room']) +console.table(batch.map((row) => ({ + scene: row.sceneId, + units: row.weightUnits, + texMb: row.estimatedTextureMb, + heapDeltaMb: row.jsHeapDeltaMb, +}))) +``` + +Profiler предназначен для инженерной оценки и калибровки cache policy. Его числа не являются точным измерением всей RAM приложения: `jsHeap` отражает память JavaScript, а `estimatedTextureMb` является вычисляемой оценкой по размерам изображений. diff --git a/progress.md b/progress.md index 0b2523b..1cf1d40 100644 --- a/progress.md +++ b/progress.md @@ -11,3 +11,16 @@ Original prompt: Давай временно переключимся с пар - Реализовано: save/save-as синхронизирует scene registration, чтобы cache/registry не теряли сцену после смены id. - Проверки: `npm run -s typecheck` и `npm run -s build` проходят. - Ограничение среды: локальный `npm run dev` smoke test в этой сессии блокируется sandbox-ошибкой `vite -> esbuild spawn EPERM`, поэтому браузерный прогон не был надёжно выполнен. +- Новое: добавлен debug-profiler сцен в `SceneManager`. +- `profileCurrentSceneMemory()` снимает snapshot по текущей сцене: weight units, heap snapshot, texture estimate, bytes-per-unit. +- `profileScenes([...])` прогоняет серию сцен, по очереди загружает их и печатает `console.table(...)` с `deltaMb`, `textureMb`, `kbPerUnit`. +- Debug API проброшен в `window.__QUEST_DEBUG__`: + - `__QUEST_DEBUG__.profileCurrentSceneMemory()` + - `__QUEST_DEBUG__.profileScenes([...])` + - `__QUEST_DEBUG__.game` +- Browser smoke (MCP): `__QUEST_DEBUG__.profileCurrentSceneMemory()` и `__QUEST_DEBUG__.profileScenes(['test_room'])` отрабатывают, новых console errors нет. +- Новый этап: scene cache и image cache сведены в согласованную texture-first модель. +- Scene weight теперь должен доминирующе учитывать texture bytes, а старый graph-weight используется как малый корректирующий вклад. +- Budgets scene cache подняты в 3 раза; image cache получил отдельный budget по device class. +- В `GDD.md` в конец раздела `Техническая реализация` добавлено описание profiler и примеры вызова через `window.__QUEST_DEBUG__`. +- Проверка browser loop: skill client не запустился, потому что в окружении нет пакета 'playwright'; для smoke test использован встроенный browser MCP. diff --git a/src/core/AssetLoader.ts b/src/core/AssetLoader.ts index 8089ee0..9020439 100644 --- a/src/core/AssetLoader.ts +++ b/src/core/AssetLoader.ts @@ -3,26 +3,178 @@ export interface SpriteData { json: any; } +export type ImageCacheState = 'active' | 'warm' | 'cold'; + +export interface ImageCacheStats { + estimatedBytes: number; + imageCount: number; + activeCount: number; + warmCount: number; + coldCount: number; + budgetBytes: number; +} + +type ImageCacheEntry = { + image: HTMLImageElement; + estimatedBytes: number; + lastAccessed: number; + refSceneIds: Set<string>; + state: ImageCacheState; +}; + export class AssetLoader { private jsonCache: Map<string, any> = new Map(); - private imageCache: Map<string, HTMLImageElement> = new Map(); + private imageCache: Map<string, ImageCacheEntry> = new Map(); private pending: Map<string, Promise<any>> = new Map(); + private spriteToImagePath: Map<string, string> = new Map(); + private sceneToSpriteKeys: Map<string, Set<string>> = new Map(); + private currentSceneId: string | null = null; + private loadedSceneIds: Set<string> = new Set(); + private imageCacheBudgetBytes: number = 128 * 1024 * 1024; + + setImageCacheBudget(bytes: number): void { + this.imageCacheBudgetBytes = Math.max(8 * 1024 * 1024, Math.round(bytes || 0)); + this.evictUnusedImagesIfNeeded(); + } + + getImageCacheStats(): ImageCacheStats { + let estimatedBytes = 0; + let activeCount = 0; + let warmCount = 0; + let coldCount = 0; + + for (const entry of this.imageCache.values()) { + estimatedBytes += entry.estimatedBytes; + if (entry.state === 'active') activeCount++; + else if (entry.state === 'warm') warmCount++; + else coldCount++; + } + + return { + estimatedBytes, + imageCount: this.imageCache.size, + activeCount, + warmCount, + coldCount, + budgetBytes: this.imageCacheBudgetBytes, + }; + } + + markSceneSpriteRefs(sceneId: string, spriteKeys: Iterable<string>): void { + const normalizedSceneId = String(sceneId || '').trim(); + if (!normalizedSceneId) return; + + const previousKeys = this.sceneToSpriteKeys.get(normalizedSceneId) || new Set<string>(); + const nextKeys = new Set( + [...spriteKeys].map((spriteKey) => this.normalizeSpriteKey(spriteKey)).filter(Boolean) + ); + + for (const spriteKey of previousKeys) { + if (!nextKeys.has(spriteKey)) { + this.detachSceneRefFromSprite(normalizedSceneId, spriteKey); + } + } + + this.sceneToSpriteKeys.set(normalizedSceneId, nextKeys); + for (const spriteKey of nextKeys) { + this.attachSceneRefToSprite(normalizedSceneId, spriteKey); + } + + this.updateImageStates(); + } + + renameSceneSpriteRefs(previousSceneId: string, nextSceneId: string): void { + const oldId = String(previousSceneId || '').trim(); + const newId = String(nextSceneId || '').trim(); + if (!oldId || !newId || oldId === newId) return; + + const spriteKeys = this.sceneToSpriteKeys.get(oldId); + if (!spriteKeys) return; + + this.sceneToSpriteKeys.delete(oldId); + this.sceneToSpriteKeys.set(newId, new Set(spriteKeys)); + + for (const entry of this.imageCache.values()) { + if (entry.refSceneIds.delete(oldId)) { + entry.refSceneIds.add(newId); + } + } + + if (this.currentSceneId === oldId) { + this.currentSceneId = newId; + } + if (this.loadedSceneIds.delete(oldId)) { + this.loadedSceneIds.add(newId); + } + + this.updateImageStates(); + } + + releaseSceneSpriteRefs(sceneId: string): void { + const normalizedSceneId = String(sceneId || '').trim(); + if (!normalizedSceneId) return; + + const spriteKeys = this.sceneToSpriteKeys.get(normalizedSceneId); + if (!spriteKeys) return; + + for (const spriteKey of spriteKeys) { + this.detachSceneRefFromSprite(normalizedSceneId, spriteKey); + } + + this.sceneToSpriteKeys.delete(normalizedSceneId); + this.loadedSceneIds.delete(normalizedSceneId); + if (this.currentSceneId === normalizedSceneId) { + this.currentSceneId = null; + } + + this.updateImageStates(); + this.evictUnusedImagesIfNeeded(); + } + + syncSceneCacheState(currentSceneId: string | null, loadedSceneIds: Iterable<string>): void { + this.currentSceneId = currentSceneId ? String(currentSceneId) : null; + this.loadedSceneIds = new Set( + [...loadedSceneIds].map((sceneId) => String(sceneId || '').trim()).filter(Boolean) + ); + this.updateImageStates(); + this.evictUnusedImagesIfNeeded(); + } + + async estimateSpritesTextureBytes( + spriteKeys: Iterable<string> + ): Promise<{ bytes: number; count: number; imagePaths: string[] }> { + const uniqueImages = new Set<string>(); + let bytes = 0; + + for (const rawSpriteKey of spriteKeys) { + const spriteKey = this.normalizeSpriteKey(rawSpriteKey); + if (!spriteKey) continue; + const { image } = await this.loadSprite(spriteKey); + const imagePath = this.spriteToImagePath.get(spriteKey) || image.currentSrc || image.src || spriteKey; + if (uniqueImages.has(imagePath)) continue; + uniqueImages.add(imagePath); + bytes += (image.naturalWidth || image.width || 0) * (image.naturalHeight || image.height || 0) * 4; + } + + return { + bytes, + count: uniqueImages.size, + imagePaths: [...uniqueImages], + }; + } /** * Loads a JSON file with caching and request deduplication. */ async loadJson(path: string): Promise<any> { if (this.jsonCache.has(path)) { - // console.log(`[AssetLoader] JSON Cache Hit: ${path}`); return this.jsonCache.get(path); } if (this.pending.has(path)) { - // console.log(`[AssetLoader] Joining pending JSON request: ${path}`); return this.pending.get(path); } - // console.log(`[AssetLoader] Fetching JSON: ${path}`); const promise = fetch(path) .then((res) => { if (!res.ok) throw new Error(`Failed to load JSON: ${res.statusText}`); @@ -44,27 +196,35 @@ export class AssetLoader { * Loads an Image with caching and request deduplication. */ async loadImage(path: string): Promise<HTMLImageElement> { - if (this.imageCache.has(path)) { - // console.log(`[AssetLoader] Image Cache Hit: ${path}`); - return this.imageCache.get(path)!; + const cached = this.imageCache.get(path); + if (cached) { + cached.lastAccessed = Date.now(); + return cached.image; } if (this.pending.has(path)) { - // console.log(`[AssetLoader] Joining pending Image request: ${path}`); return this.pending.get(path); } - // console.log(`[AssetLoader] Loading Image: ${path}`); const promise = new Promise<HTMLImageElement>((resolve, reject) => { const img = new Image(); img.src = path; img.onload = () => { - this.imageCache.set(path, img); + const entry: ImageCacheEntry = { + image: img, + estimatedBytes: (img.naturalWidth || img.width || 0) * (img.naturalHeight || img.height || 0) * 4, + lastAccessed: Date.now(), + refSceneIds: new Set<string>(), + state: 'cold', + }; + this.imageCache.set(path, entry); + this.rehydrateImageRefs(path); resolve(img); }; img.onerror = (e) => reject(e); }).finally(() => { this.pending.delete(path); + this.evictUnusedImagesIfNeeded(); }); this.pending.set(path, promise); @@ -76,13 +236,9 @@ export class AssetLoader { * Handles path resolution logic (legacy vs public). */ async loadSprite(spriteName: string): Promise<SpriteData> { - // Strict JSON support (legacy Entity logic) - let filename = spriteName; - if (!filename.toLowerCase().endsWith('.json')) { - filename += '.json'; - } + const normalizedSpriteKey = this.normalizeSpriteKey(spriteName); - // Path Resolution + let filename = normalizedSpriteKey; let jsonPath = filename; if (jsonPath.startsWith('public/')) { jsonPath = '/' + jsonPath.substring(7); @@ -90,10 +246,8 @@ export class AssetLoader { jsonPath = '/sprites/' + filename; } - // 1. Load JSON const data = await this.loadJson(jsonPath); - // 2. Resolve Image Path from JSON data let imagePath = data.imageFile; if (imagePath.startsWith('public/')) { imagePath = '/' + imagePath.substring(7); @@ -101,9 +255,94 @@ export class AssetLoader { imagePath = '/assets/' + imagePath; } - // 3. Load Image + this.spriteToImagePath.set(normalizedSpriteKey, imagePath); + this.rehydrateImageRefs(imagePath); + const image = await this.loadImage(imagePath); + const entry = this.imageCache.get(imagePath); + if (entry) { + entry.lastAccessed = Date.now(); + } return { json: data, image }; } + + private normalizeSpriteKey(spriteName: string): string { + let filename = String(spriteName || '').trim(); + if (!filename) return ''; + if (!filename.toLowerCase().endsWith('.json')) { + filename += '.json'; + } + return filename; + } + + private attachSceneRefToSprite(sceneId: string, spriteKey: string): void { + const imagePath = this.spriteToImagePath.get(spriteKey); + if (!imagePath) return; + const entry = this.imageCache.get(imagePath); + if (!entry) return; + entry.refSceneIds.add(sceneId); + } + + private detachSceneRefFromSprite(sceneId: string, spriteKey: string): void { + const imagePath = this.spriteToImagePath.get(spriteKey); + if (!imagePath) return; + const entry = this.imageCache.get(imagePath); + if (!entry) return; + entry.refSceneIds.delete(sceneId); + } + + private rehydrateImageRefs(imagePath: string): void { + const entry = this.imageCache.get(imagePath); + if (!entry) return; + + const referencedSceneIds = new Set<string>(); + for (const [sceneId, spriteKeys] of this.sceneToSpriteKeys.entries()) { + for (const spriteKey of spriteKeys) { + if (this.spriteToImagePath.get(spriteKey) === imagePath) { + referencedSceneIds.add(sceneId); + } + } + } + + entry.refSceneIds = referencedSceneIds; + this.updateEntryState(entry); + } + + private updateImageStates(): void { + for (const entry of this.imageCache.values()) { + this.updateEntryState(entry); + } + } + + private updateEntryState(entry: ImageCacheEntry): void { + if (this.currentSceneId && entry.refSceneIds.has(this.currentSceneId)) { + entry.state = 'active'; + return; + } + + for (const sceneId of entry.refSceneIds) { + if (this.loadedSceneIds.has(sceneId)) { + entry.state = 'warm'; + return; + } + } + + entry.state = 'cold'; + } + + private evictUnusedImagesIfNeeded(): void { + let stats = this.getImageCacheStats(); + if (stats.estimatedBytes <= this.imageCacheBudgetBytes) return; + + const coldEntries = [...this.imageCache.entries()] + .filter(([, entry]) => entry.state === 'cold') + .sort((a, b) => a[1].lastAccessed - b[1].lastAccessed); + + for (const [imagePath] of coldEntries) { + if (stats.estimatedBytes <= this.imageCacheBudgetBytes) break; + this.imageCache.delete(imagePath); + stats = this.getImageCacheStats(); + } + } } diff --git a/src/core/Game.ts b/src/core/Game.ts index 0be79d4..05cea0a 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -157,6 +157,17 @@ export class Game implements IGame { this.textAssets = new TextAssetManager(); void this.textAssets.preloadServiceAssets(); this.sceneManager = new SceneManager(this); + if (typeof window !== 'undefined') { + const debugWindow = window as Window & { + __QUEST_DEBUG__?: Record<string, unknown>; + }; + debugWindow.__QUEST_DEBUG__ = { + ...(debugWindow.__QUEST_DEBUG__ || {}), + game: this, + profileCurrentSceneMemory: () => this.sceneManager.profileCurrentSceneMemory(), + profileScenes: (sceneIds: string[]) => this.sceneManager.profileScenes(sceneIds), + }; + } this.editor = new SceneEditor(this); this.spriteEditor = new SpriteEditor(this); diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index 4940c34..d70842c 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -6,6 +6,9 @@ import { Walkbox } from '../entities/Walkbox'; import { Triggerbox } from '../entities/Triggerbox'; import { QuadObject } from '../entities/QuadObject'; +const GRAPH_WEIGHT_FACTOR = 0.15; +const TEXTURE_BYTES_PER_UNIT = 64 * 1024; + export interface SceneDescriptor { id: string; path: string; @@ -13,23 +16,55 @@ export interface SceneDescriptor { title: string | null; sourceData: any | null; lastIndexed: number; + graphWeightUnits?: number; + textureBytes?: number; + textureWeightUnits?: number; + totalWeightUnits?: number; } export interface SceneCacheStats { estimatedMemory: number; loadedScenes: number; budget: number; + graphUnits: number; + textureMb: number; + imageBudgetMb: number; +} + +export interface SceneMemoryProfile { + sceneId: string; + sceneName: string; + weightUnits: number; + graphWeightUnits: number; + textureWeightUnits: number; + loadedScenes: number; + estimatedCacheUnits: number; + jsHeapUsedBytes: number | null; + jsHeapUsedMb: number | null; + jsHeapDeltaBytes: number | null; + jsHeapDeltaMb: number | null; + bytesPerUnit: number | null; + estimatedTextureBytes: number; + estimatedTextureMb: number; + textureCount: number; + imageCacheBytes: number; + imageCacheMb: number; } type DeviceMemoryProfile = { className: string; deviceMemoryGb: number | null; sceneCacheBudget: number; + imageCacheBudgetBytes: number; }; type CachedSceneEntry = { scene: Scene; - estimatedWeight: number; + graphWeightUnits: number; + textureBytes: number; + textureWeightUnits: number; + totalWeightUnits: number; + spriteKeys: Set<string>; lastAccessed: number; pinned: boolean; }; @@ -48,13 +83,17 @@ export class SceneManager { this.scenes = new Map(); this.sceneRegistry = new Map(); this.sceneCacheMeta = new Map(); + const memoryProfile = this.detectDeviceMemoryProfile(); this.sceneCacheBudget = memoryProfile.sceneCacheBudget; + this.game.assets.setImageCacheBudget(memoryProfile.imageCacheBudgetBytes); console.log( `[SceneManager] Device class: ${memoryProfile.className}` + ` (deviceMemory=${memoryProfile.deviceMemoryGb ?? 'unknown'}GB)` + - `, scene cache budget: ${this.sceneCacheBudget}` + `, scene cache budget: ${this.sceneCacheBudget}` + + `, image cache budget: ${this.bytesToMb(memoryProfile.imageCacheBudgetBytes)}MB` ); + void this.refreshSceneRegistry(); } @@ -65,18 +104,20 @@ export class SceneManager { switchTo(sceneId: string): void { const scene = this.ensureSceneLoaded(sceneId); - if (scene) { - this.currentScene = scene; - this.touchScene(scene.id); - this.pinCurrentScene(); - this.exposeEntitiesToWindow(); - if (this.game.onSceneChange) { - this.game.onSceneChange(scene.name); - } - this.evictScenesIfNeeded(); - } else { + if (!scene) { console.error(`Scene ${sceneId} not found!`); + return; + } + + this.currentScene = scene; + this.touchScene(scene.id); + this.pinCurrentScene(); + this.syncAssetCacheState(); + this.exposeEntitiesToWindow(); + if (this.game.onSceneChange) { + this.game.onSceneChange(scene.name); } + this.evictScenesIfNeeded(); } exposeEntitiesToWindow(): void { @@ -94,10 +135,9 @@ export class SceneManager { } update(deltaTime: number): void { - if (this.currentScene) { - this.currentScene.update(deltaTime); - this.refreshCurrentSceneWeight(); - } + if (!this.currentScene) return; + this.currentScene.update(deltaTime); + this.refreshCurrentSceneGraphWeight(); } render(ctx: CanvasRenderingContext2D): void { @@ -130,7 +170,7 @@ export class SceneManager { this.switchTo(newScene.id); await this.game.textAssets.preloadScene(newScene); this.syncSceneRegistration(newScene, undefined, newScene.toJSON()); - this.refreshCurrentSceneWeight(); + await this.refreshSceneFootprint(newScene.id); if (this.game.editor) { this.game.editor.refreshHierarchy(); @@ -144,6 +184,7 @@ export class SceneManager { syncSceneRegistration(scene: Scene, previousId?: string, sourceData?: any): void { const sceneId = scene.id; const pathValue = this.getScenePathFromScene(scene); + const existingMeta = this.sceneCacheMeta.get(sceneId); const descriptor: SceneDescriptor = { id: sceneId, path: pathValue, @@ -151,6 +192,10 @@ export class SceneManager { title: this.game.textAssets.getResolvedSceneField(scene, 'title') || scene.name || sceneId, sourceData: sourceData ? JSON.parse(JSON.stringify(sourceData)) : null, lastIndexed: Date.now(), + graphWeightUnits: existingMeta?.graphWeightUnits, + textureBytes: existingMeta?.textureBytes, + textureWeightUnits: existingMeta?.textureWeightUnits, + totalWeightUnits: existingMeta?.totalWeightUnits, }; if (previousId && previousId !== sceneId) { @@ -163,12 +208,10 @@ export class SceneManager { } if (previousMeta) { this.sceneCacheMeta.delete(previousId); - this.sceneCacheMeta.set(sceneId, { - ...previousMeta, - scene, - estimatedWeight: this.estimateSceneWeight(scene), - }); + this.sceneCacheMeta.set(sceneId, { ...previousMeta, scene }); } + this.game.assets.renameSceneSpriteRefs(previousId, sceneId); + this.syncAssetCacheState(); } this.sceneRegistry.set(sceneId, descriptor); @@ -176,17 +219,26 @@ export class SceneManager { getSceneCacheStats(): SceneCacheStats { let estimatedMemory = 0; + let graphUnits = 0; + let textureBytes = 0; + for (const entry of this.sceneCacheMeta.values()) { - estimatedMemory += entry.estimatedWeight; + estimatedMemory += entry.totalWeightUnits; + graphUnits += entry.graphWeightUnits; + textureBytes += entry.textureBytes; } + return { estimatedMemory: Math.round(estimatedMemory), loadedScenes: this.scenes.size, budget: this.sceneCacheBudget, + graphUnits: Math.round(graphUnits), + textureMb: this.bytesToMb(textureBytes), + imageBudgetMb: this.bytesToMb(this.game.assets.getImageCacheStats().budgetBytes), }; } - estimateSceneWeight(scene: Scene): number { + estimateSceneGraphWeight(scene: Scene): number { let weight = 16; weight += (scene.walkbox?.length || 0) * 6; @@ -254,6 +306,7 @@ export class SceneManager { const response = await fetch(`/scenes/${file}?t=${Date.now()}`); if (!response.ok) continue; const data = await response.json(); + const existingMeta = this.sceneCacheMeta.get(sceneId); const descriptor: SceneDescriptor = { id: sceneId, path: file, @@ -261,6 +314,10 @@ export class SceneManager { title: (await this.readSceneTitle(sceneId)) || data.name || sceneId, sourceData: data, lastIndexed: Date.now(), + graphWeightUnits: existingMeta?.graphWeightUnits, + textureBytes: existingMeta?.textureBytes, + textureWeightUnits: existingMeta?.textureWeightUnits, + totalWeightUnits: existingMeta?.totalWeightUnits, }; this.sceneRegistry.set(sceneId, descriptor); } @@ -280,6 +337,69 @@ export class SceneManager { } } + async profileCurrentSceneMemory(): Promise<SceneMemoryProfile | null> { + const scene = this.currentScene; + if (!scene) { + console.warn('[SceneManager] profileCurrentSceneMemory(): no current scene'); + return null; + } + + await this.waitForSceneToSettle(); + await this.refreshSceneFootprint(scene.id); + const heapBytes = this.readUsedJsHeapBytes(); + const entry = this.sceneCacheMeta.get(scene.id); + const stats = this.getSceneCacheStats(); + const imageStats = this.game.assets.getImageCacheStats(); + const profile = this.buildMemoryProfile(scene, entry, stats.estimatedMemory, heapBytes, null, imageStats.estimatedBytes); + + console.table([this.formatMemoryProfileForConsole(profile)]); + return profile; + } + + async profileScenes(sceneIds: string[]): Promise<SceneMemoryProfile[]> { + const requested = [...new Set((sceneIds || []).map((id) => String(id || '').trim()).filter(Boolean))]; + if (requested.length === 0) { + console.warn('[SceneManager] profileScenes(): no scene ids provided'); + return []; + } + + const results: SceneMemoryProfile[] = []; + const originalSceneId = this.currentScene?.id ?? null; + + for (const sceneId of requested) { + const beforeHeap = this.readUsedJsHeapBytes(); + await this.loadProfileScene(sceneId); + await this.waitForSceneToSettle(); + await this.refreshSceneFootprint(sceneId); + + const scene = this.currentScene; + if (!scene || scene.id !== sceneId) { + console.warn(`[SceneManager] profileScenes(): failed to load scene '${sceneId}'`); + continue; + } + + const afterHeap = this.readUsedJsHeapBytes(); + const imageStats = this.game.assets.getImageCacheStats(); + const profile = this.buildMemoryProfile( + scene, + this.sceneCacheMeta.get(scene.id), + this.getSceneCacheStats().estimatedMemory, + afterHeap, + beforeHeap !== null && afterHeap !== null ? Math.max(0, afterHeap - beforeHeap) : null, + imageStats.estimatedBytes + ); + results.push(profile); + } + + if (originalSceneId && originalSceneId !== this.currentScene?.id) { + this.switchTo(originalSceneId); + await this.waitForSceneToSettle(); + } + + console.table(results.map((profile) => this.formatMemoryProfileForConsole(profile))); + return results; + } + private instantiateScene(sceneId: string, data: any, pathValue?: string): Scene { const newScene = new Scene(this.game, sceneId, data.name || 'Untitled'); @@ -357,18 +477,33 @@ export class SceneManager { const scene = this.instantiateScene(sceneId, descriptor.sourceData, descriptor.path); this.cacheScene(scene, false); void this.game.textAssets.preloadScene(scene); + void this.refreshSceneFootprint(scene.id); return scene; } private cacheScene(scene: Scene, pinned: boolean): void { this.scenes.set(scene.id, scene); const existing = this.sceneCacheMeta.get(scene.id); + const graphWeightUnits = this.estimateSceneGraphWeight(scene); + const spriteKeys = this.collectSceneSpriteNames(scene); + this.game.assets.markSceneSpriteRefs(scene.id, spriteKeys); + + const textureBytes = existing?.textureBytes || 0; + const textureWeightUnits = existing?.textureWeightUnits || this.textureBytesToUnits(textureBytes); + this.sceneCacheMeta.set(scene.id, { scene, - estimatedWeight: this.estimateSceneWeight(scene), + graphWeightUnits, + textureBytes, + textureWeightUnits, + totalWeightUnits: this.computeTotalWeightUnits(graphWeightUnits, textureBytes), + spriteKeys, lastAccessed: Date.now(), pinned: existing?.pinned || pinned, }); + + this.syncAssetCacheState(); + void this.refreshSceneFootprint(scene.id); this.evictScenesIfNeeded(); } @@ -382,13 +517,42 @@ export class SceneManager { for (const [sceneId, entry] of this.sceneCacheMeta.entries()) { entry.pinned = this.currentScene?.id === sceneId; } + this.syncAssetCacheState(); } - private refreshCurrentSceneWeight(): void { + private refreshCurrentSceneGraphWeight(): void { if (!this.currentScene) return; const entry = this.sceneCacheMeta.get(this.currentScene.id); if (!entry) return; - entry.estimatedWeight = this.estimateSceneWeight(this.currentScene); + entry.graphWeightUnits = this.estimateSceneGraphWeight(this.currentScene); + entry.totalWeightUnits = this.computeTotalWeightUnits(entry.graphWeightUnits, entry.textureBytes); + } + + private async refreshSceneFootprint(sceneId: string): Promise<void> { + const entry = this.sceneCacheMeta.get(sceneId); + const scene = this.scenes.get(sceneId); + if (!entry || !scene) return; + + const spriteKeys = this.collectSceneSpriteNames(scene); + this.game.assets.markSceneSpriteRefs(sceneId, spriteKeys); + const textureEstimate = await this.game.assets.estimateSpritesTextureBytes(spriteKeys); + entry.spriteKeys = spriteKeys; + entry.graphWeightUnits = this.estimateSceneGraphWeight(scene); + entry.textureBytes = textureEstimate.bytes; + entry.textureWeightUnits = this.textureBytesToUnits(textureEstimate.bytes); + entry.totalWeightUnits = this.computeTotalWeightUnits(entry.graphWeightUnits, entry.textureBytes); + + const descriptor = this.sceneRegistry.get(sceneId); + if (descriptor) { + descriptor.graphWeightUnits = entry.graphWeightUnits; + descriptor.textureBytes = entry.textureBytes; + descriptor.textureWeightUnits = entry.textureWeightUnits; + descriptor.totalWeightUnits = entry.totalWeightUnits; + descriptor.lastIndexed = Date.now(); + } + + this.syncAssetCacheState(); + this.evictScenesIfNeeded(); } private evictScenesIfNeeded(): void { @@ -410,6 +574,7 @@ export class SceneManager { const scene = this.scenes.get(sceneId); if (!scene) return; + const entry = this.sceneCacheMeta.get(sceneId); const descriptor = this.sceneRegistry.get(sceneId) || { id: sceneId, path: this.getScenePathFromScene(scene), @@ -423,10 +588,16 @@ export class SceneManager { descriptor.title = this.game.textAssets.getResolvedSceneField(scene, 'title') || scene.name; descriptor.sourceData = scene.toJSON(); descriptor.lastIndexed = Date.now(); + descriptor.graphWeightUnits = entry?.graphWeightUnits; + descriptor.textureBytes = entry?.textureBytes; + descriptor.textureWeightUnits = entry?.textureWeightUnits; + descriptor.totalWeightUnits = entry?.totalWeightUnits; this.sceneRegistry.set(sceneId, descriptor); this.scenes.delete(sceneId); this.sceneCacheMeta.delete(sceneId); + this.game.assets.releaseSceneSpriteRefs(sceneId); + this.syncAssetCacheState(); } private getScenePathFromScene(scene: Scene): string { @@ -485,7 +656,8 @@ export class SceneManager { return { className: 'unknown', deviceMemoryGb: null, - sceneCacheBudget: 900, + sceneCacheBudget: 2700, + imageCacheBudgetBytes: 256 * 1024 * 1024, }; } @@ -493,7 +665,8 @@ export class SceneManager { return { className: 'low-memory', deviceMemoryGb, - sceneCacheBudget: 900, + sceneCacheBudget: 2700, + imageCacheBudgetBytes: 256 * 1024 * 1024, }; } @@ -501,7 +674,8 @@ export class SceneManager { return { className: 'mid-memory', deviceMemoryGb, - sceneCacheBudget: 1500, + sceneCacheBudget: 4500, + imageCacheBudgetBytes: 512 * 1024 * 1024, }; } @@ -509,14 +683,140 @@ export class SceneManager { return { className: 'high-memory', deviceMemoryGb, - sceneCacheBudget: 2400, + sceneCacheBudget: 7200, + imageCacheBudgetBytes: 1024 * 1024 * 1024, }; } return { className: 'very-high-memory', deviceMemoryGb, - sceneCacheBudget: 3200, + sceneCacheBudget: 9600, + imageCacheBudgetBytes: 1536 * 1024 * 1024, + }; + } + + private async loadProfileScene(sceneId: string): Promise<void> { + const descriptor = this.sceneRegistry.get(sceneId); + if (descriptor) { + this.switchTo(descriptor.id); + return; + } + + const guessedFile = `${sceneId.replace(/\\/g, '/')}.json`; + await this.loadScene(guessedFile); + } + + private collectSceneSpriteNames(scene: Scene): Set<string> { + const spriteNames = new Set<string>(); + + for (const entity of scene.entities || []) { + if (entity.spriteName) { + spriteNames.add(entity.spriteName); + } + + const animSets = (entity as any).animSets as Record<string, Record<string, string | null>> | undefined; + if (!animSets) continue; + + for (const set of Object.values(animSets)) { + for (const key of ['up', 'down', 'left', 'right'] as const) { + const spriteName = set?.[key]; + if (typeof spriteName === 'string' && spriteName.trim()) { + spriteNames.add(spriteName); + } + } + } + } + + return spriteNames; + } + + private syncAssetCacheState(): void { + this.game.assets.syncSceneCacheState( + this.currentScene?.id || null, + [...this.scenes.keys()] + ); + } + + private computeTotalWeightUnits(graphWeightUnits: number, textureBytes: number): number { + return Math.max( + 1, + Math.round(this.textureBytesToUnits(textureBytes) + graphWeightUnits * GRAPH_WEIGHT_FACTOR) + ); + } + + private textureBytesToUnits(textureBytes: number): number { + return textureBytes / TEXTURE_BYTES_PER_UNIT; + } + + private buildMemoryProfile( + scene: Scene, + entry: CachedSceneEntry | undefined, + estimatedCacheUnits: number, + heapBytes: number | null, + deltaBytes: number | null, + imageCacheBytes: number + ): SceneMemoryProfile { + const graphWeightUnits = Math.round(entry?.graphWeightUnits ?? this.estimateSceneGraphWeight(scene)); + const textureBytes = entry?.textureBytes ?? 0; + const totalWeightUnits = Math.round(entry?.totalWeightUnits ?? this.computeTotalWeightUnits(graphWeightUnits, textureBytes)); + const bytesPerUnit = + heapBytes !== null && totalWeightUnits > 0 ? heapBytes / totalWeightUnits : null; + + return { + sceneId: scene.id, + sceneName: scene.name, + weightUnits: totalWeightUnits, + graphWeightUnits, + textureWeightUnits: Math.round(entry?.textureWeightUnits ?? this.textureBytesToUnits(textureBytes)), + loadedScenes: this.scenes.size, + estimatedCacheUnits, + jsHeapUsedBytes: heapBytes, + jsHeapUsedMb: heapBytes !== null ? this.bytesToMb(heapBytes) : null, + jsHeapDeltaBytes: deltaBytes, + jsHeapDeltaMb: deltaBytes !== null ? this.bytesToMb(deltaBytes) : null, + bytesPerUnit, + estimatedTextureBytes: textureBytes, + estimatedTextureMb: this.bytesToMb(textureBytes), + textureCount: entry?.spriteKeys.size ?? this.collectSceneSpriteNames(scene).size, + imageCacheBytes, + imageCacheMb: this.bytesToMb(imageCacheBytes), + }; + } + + private readUsedJsHeapBytes(): number | null { + const performanceLike = + typeof performance !== 'undefined' + ? (performance as Performance & { memory?: { usedJSHeapSize?: number } }) + : null; + const used = performanceLike?.memory?.usedJSHeapSize; + return typeof used === 'number' && Number.isFinite(used) ? used : null; + } + + private async waitForSceneToSettle(delayMs: number = 300): Promise<void> { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + private bytesToMb(bytes: number): number { + return Math.round((bytes / (1024 * 1024)) * 100) / 100; + } + + private formatMemoryProfileForConsole(profile: SceneMemoryProfile): Record<string, string | number | null> { + return { + sceneId: profile.sceneId, + sceneName: profile.sceneName, + weightUnits: profile.weightUnits, + graphUnits: profile.graphWeightUnits, + textureUnits: profile.textureWeightUnits, + loadedScenes: profile.loadedScenes, + cacheUnits: profile.estimatedCacheUnits, + jsHeapMb: profile.jsHeapUsedMb, + deltaMb: profile.jsHeapDeltaMb, + textureMb: profile.estimatedTextureMb, + imageCacheMb: profile.imageCacheMb, + textureCount: profile.textureCount, + kbPerUnit: + profile.bytesPerUnit !== null ? Math.round((profile.bytesPerUnit / 1024) * 100) / 100 : null, }; } } diff --git a/tasks.md b/tasks.md deleted file mode 100644 index a74cb1c..0000000 --- a/tasks.md +++ /dev/null @@ -1,61 +0,0 @@ -# tasks.md - -## Текущий фокус (блокер перед Parser) - -Закрыть долг по операциям с множественным выделением в Scene Editor: - -- copy/paste группы; -- duplicate группы (`Ctrl + D`); -- save/load prefab для single и group; -- единая логика размещения при вставке. - -## Критерии успеха - -- [x] `Ctrl + C` копирует single/group выделение как сериализуемый payload. -- [x] `Ctrl + V` вставляет single/group с сохранением относительных позиций группы. -- [x] `Ctrl + D` дублирует single/group с тем же pipeline, что и paste. -- [x] `Ctrl + S` сохраняет single prefab и group prefab. -- [x] Group prefab поддерживает все selectable-типы (`Entity`, `Actor`, `Quad`, `Walkbox`, `Triggerbox`) со свойствами. -- [x] `Ctrl + O` загружает prefab и вставляет в позицию курсора (или в центр вида, если курсор вне canvas). -- [x] Toolbar `Load` сохраняет стандартное поведение загрузки (без cursor-only режима hotkey). -- [x] После paste/duplicate/load снимается старое выделение и выделяются только новые объекты. -- [x] Порядок объектов группы после вставки/дублирования сохраняется. - -## Реализация - -### Приоритет 1. Сериализация и instantiate pipeline - -- [x] Ввести общий формат payload (`single/group`) для clipboard. -- [x] Поддержать legacy single JSON без `kind/version`. -- [x] Реализовать общий pipeline создания объектов из payload (single/group). -- [x] Ввести remap имён и ссылок внутри вставляемой группы. - -### Приоритет 2. Hotkeys и редакторные операции - -- [x] Перевести `copySelectedObjectToClipboard` на новый pipeline. -- [x] Реализовать duplicate для группы через тот же pipeline. -- [x] Перевести paste на общий pipeline с fallback в центр вида. -- [x] Исправить рендер подсветки multi-selection для Entity (использовать текущий объект цикла). - -### Приоритет 3. Prefab single/group - -- [x] Расширить `saveObject` на single/group. -- [x] Добавить формат `group_prefab`. -- [x] Расширить `loadObject` на single/group с backward compatibility. -- [x] Разделить режимы загрузки: `Ctrl + O` (`cursor`) и toolbar (`default`). - -### Приоритет 4. Документация и приемка - -- [x] Обновить GDD по copy/paste/duplicate и prefab single/group. -- [x] Прогнать `npm run typecheck`. -- [x] Прогнать `npm run build`. -- [ ] Ручной smoke-тест в браузере (single/group copy/paste/duplicate/save/load). - -## Следующий этап (после закрытия блока) - -- [ ] Вернуться к задачам Parser/Text resources. - -## Правило сопровождения плана - -- [ ] Перед началом новой задачи сверяться с `tasks.md`. -- [ ] При изменении приоритетов обновлять статусы и критерии. From 7d8e65977629fb424ba44460553a558d7e7b6088 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Thu, 12 Mar 2026 03:31:15 +0200 Subject: [PATCH 12/75] Fix: align game.goTo movement with click-to-walk Use player.moveTo() instead of player.walkTo() when game.goTo targets an in-scene object, so unreachable targets behave like mouse clicks and the actor walks as close as possible instead of failing immediately. --- src/core/Game.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/Game.ts b/src/core/Game.ts index 05cea0a..2f841d7 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -477,7 +477,8 @@ export class Game implements IGame { }; } - const interactionId = entity.interactions && (entity.interactions.look || entity.interactions.LOOK); + const interactionId = + entity.interactions && (entity.interactions.look || entity.interactions.LOOK); if (interactionId) { ScriptRegistry.execute(interactionId, { game: this, entity }); return { @@ -488,7 +489,8 @@ export class Game implements IGame { }; } - const description = this.textAssets.getResolvedObjectField(entity, 'description') || entity.description; + const description = + this.textAssets.getResolvedObjectField(entity, 'description') || entity.description; if (description && description.trim()) { return { status: 'ok', @@ -634,7 +636,7 @@ export class Game implements IGame { if (currentScene?.player) { const entity = currentScene.findEntity(normalizedTarget); if (entity && 'x' in entity && 'y' in entity) { - currentScene.player.walkTo((entity as any).x, (entity as any).y); + currentScene.player.moveTo((entity as any).x, (entity as any).y); return { status: 'ok', code: 'player_moving', From 87ff5b402566149bd122500404dfd78993941f2d Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Thu, 12 Mar 2026 12:39:24 +0200 Subject: [PATCH 13/75] Improvement: centralize parser target resolution Centralize entity resolution for look, take, and goTo so parser-facing commands share the same matching rules across exact title/id matches and partial title matches. Add clarification prompts for ambiguous matches, preserve pending clarification flows in the parser, and make disabled entities invisible to parser/runtime object lookup as required by the GDD. --- public/text/system/parser.json | 3 + src/core/Game.ts | 163 +++++++++++++++++++++++++++++++-- src/core/TextAssetManager.ts | 3 + src/mechanics/Parser.ts | 31 ++++++- src/scene/Scene.ts | 1 + 5 files changed, 191 insertions(+), 10 deletions(-) diff --git a/public/text/system/parser.json b/public/text/system/parser.json index 5ce4b6a..a619644 100644 --- a/public/text/system/parser.json +++ b/public/text/system/parser.json @@ -2,12 +2,15 @@ "look_default_scene": "You are in {scene}.", "look_default_object": "You see nothing special about the {target}.", "look_not_found": "You don't see any {target} here.", + "look_which_one": "Which one do you mean: {options}?", "take_prompt": "Take what?", + "take_which_one": "Which item do you mean: {options}?", "take_pickup_success": "You picked up the {item}.", "take_cannot": "You cannot take that.", "inventory_empty": "You are not carrying anything.", "inventory_items": "You are carrying: {items}", "go_to_prompt": "Where do you want to go?", + "go_to_which_one": "Where exactly do you want to go: {options}?", "go_to_not_found": "You can't get to {target} from here.", "go_to_success": "You go to {target}.", "use_prompt": "Use what?", diff --git a/src/core/Game.ts b/src/core/Game.ts index 2f841d7..5d3a636 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -17,6 +17,7 @@ import { ScriptRegistry } from './ScriptRegistry'; import { ComponentSystem } from '../systems/ComponentSystem'; import type { IGame } from './IGame'; +import type { Scene } from '../scene/Scene'; export class Game implements IGame { public static instance: Game; @@ -434,6 +435,90 @@ export class Game implements IGame { return this.textAssets.getServiceText(key, params); } + private resolveSceneEntityTarget( + scene: Scene, + rawTarget: string, + options: { + includeTakeablesOnly: boolean; + clarificationKey: string; + } + ): + | { status: 'found'; entity: Entity } + | { status: 'not_found' } + | { status: 'ambiguous'; message: string; options: string[] } { + const normalizedTarget = String(rawTarget || '') + .trim() + .toUpperCase(); + if (!normalizedTarget) return { status: 'not_found' }; + + const allCandidates = (scene.entities || []).filter((entity: Entity) => !entity.disabled); + const partialCandidates = allCandidates.filter((entity: Entity) => { + if (!options.includeTakeablesOnly) return true; + const isItem = entity.components && entity.components.find((c: any) => c.type === 'Item'); + return !!isItem || !!entity.isTakeable; + }); + + const exactMatches = allCandidates.filter((entity: Entity) => + this.getEntityLookupTokens(entity).includes(normalizedTarget) + ); + if (exactMatches.length === 1) { + return { status: 'found', entity: exactMatches[0] }; + } + if (exactMatches.length > 1) { + const optionTitles = this.getResolutionOptionTitles(exactMatches); + return { + status: 'ambiguous', + message: this.text(options.clarificationKey, { options: optionTitles.join(', ') }), + options: optionTitles, + }; + } + + const partialMatches = partialCandidates.filter((entity: Entity) => { + const title = this.textAssets.getResolvedObjectField(entity, 'title'); + if (!title) return false; + return title.toUpperCase().includes(normalizedTarget); + }); + + if (partialMatches.length === 1) { + return { status: 'found', entity: partialMatches[0] }; + } + if (partialMatches.length > 1) { + const optionTitles = this.getResolutionOptionTitles(partialMatches); + return { + status: 'ambiguous', + message: this.text(options.clarificationKey, { options: optionTitles.join(', ') }), + options: optionTitles, + }; + } + + return { status: 'not_found' }; + } + + private getEntityLookupTokens(entity: Entity): string[] { + const tokens = [ + entity.name, + entity.customName, + this.textAssets.getResolvedObjectField(entity, 'title'), + ] + .filter((value): value is string => !!value) + .map((value) => value.toUpperCase()); + + return Array.from(new Set(tokens)); + } + + private getResolutionOptionTitles(entities: Entity[]): string[] { + return Array.from( + new Set( + entities.map( + (entity) => + this.textAssets.getResolvedObjectField(entity, 'title') || + entity.customName || + entity.name + ) + ) + ); + } + look(target?: string | null): GameActionOutcome { const scene = this.sceneManager.currentScene; if (!scene) { @@ -466,8 +551,11 @@ export class Game implements IGame { }; } - const entity = scene.findEntity(normalizedTarget); - if (!entity) { + const resolved = this.resolveSceneEntityTarget(scene, normalizedTarget, { + includeTakeablesOnly: false, + clarificationKey: 'parser.look_which_one', + }); + if (resolved.status === 'not_found') { return { status: 'failed', code: 'entity_not_found', @@ -476,6 +564,16 @@ export class Game implements IGame { recoverable: true, }; } + if (resolved.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_look_target', + message: resolved.message, + data: { target: normalizedTarget, options: resolved.options }, + recoverable: true, + }; + } + const entity = resolved.entity; const interactionId = entity.interactions && (entity.interactions.look || entity.interactions.LOOK); @@ -530,8 +628,37 @@ export class Game implements IGame { }; } - const entity = scene.findEntity(normalizedTarget); - if (!entity) { + const resolved = this.resolveSceneEntityTarget(scene, normalizedTarget, { + includeTakeablesOnly: true, + clarificationKey: 'parser.take_which_one', + }); + const broadResolved = + resolved.status === 'not_found' + ? this.resolveSceneEntityTarget(scene, normalizedTarget, { + includeTakeablesOnly: false, + clarificationKey: 'parser.take_which_one', + }) + : null; + + if (resolved.status === 'not_found') { + if (broadResolved?.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_take_target', + message: broadResolved.message, + data: { target: normalizedTarget, options: broadResolved.options }, + recoverable: true, + }; + } + if (broadResolved?.status === 'found') { + return { + status: 'failed', + code: 'not_takeable', + message: this.text('parser.take_cannot'), + data: { entityId: broadResolved.entity.name }, + recoverable: true, + }; + } return { status: 'failed', code: 'entity_not_found', @@ -540,6 +667,16 @@ export class Game implements IGame { recoverable: true, }; } + if (resolved.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_take_target', + message: resolved.message, + data: { target: normalizedTarget, options: resolved.options }, + recoverable: true, + }; + } + const entity = resolved.entity; const interactionId = entity.interactions && (entity.interactions.pickup || entity.interactions.PICKUP); @@ -634,8 +771,22 @@ export class Game implements IGame { } if (currentScene?.player) { - const entity = currentScene.findEntity(normalizedTarget); - if (entity && 'x' in entity && 'y' in entity) { + const resolved = this.resolveSceneEntityTarget(currentScene, normalizedTarget, { + includeTakeablesOnly: false, + clarificationKey: 'parser.go_to_which_one', + }); + if (resolved.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_destination', + message: resolved.message, + data: { target: normalizedTarget, options: resolved.options }, + recoverable: true, + }; + } + + if (resolved.status === 'found' && 'x' in resolved.entity && 'y' in resolved.entity) { + const entity = resolved.entity; currentScene.player.moveTo((entity as any).x, (entity as any).y); return { status: 'ok', diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 7447359..7fa5651 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -8,12 +8,15 @@ const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { look_default_scene: 'You are in {scene}.', look_default_object: 'You see nothing special about the {target}.', look_not_found: "You don't see any {target} here.", + look_which_one: 'Which one do you mean: {options}?', take_prompt: 'Take what?', + take_which_one: 'Which item do you mean: {options}?', take_pickup_success: 'You picked up the {item}.', take_cannot: 'You cannot take that.', inventory_empty: 'You are not carrying anything.', inventory_items: 'You are carrying: {items}', go_to_prompt: 'Where do you want to go?', + go_to_which_one: 'Where exactly do you want to go: {options}?', go_to_not_found: "You can't get to {target} from here.", go_to_success: 'You go to {target}.', use_prompt: 'Use what?', diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 94ef705..1d3c2e2 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -14,7 +14,7 @@ type ParserInventoryItemContext = { }; type ParserPendingState = { - intent: 'take' | 'goTo'; + intent: 'look' | 'take' | 'goTo'; question: string; originalInput: string; }; @@ -378,12 +378,14 @@ export class Parser { }; } - const clarification = result.outcomes.find((outcome) => outcome.status === 'needs_clarification'); + const clarification = result.outcomes.find( + (outcome) => outcome.status === 'needs_clarification' + ); if (clarification) { return { playerMessage: clarification.message || this.game.text('parser.parse_unknown'), nextPendingState: { - intent: clarification.code === 'missing_destination' ? 'goTo' : 'take', + intent: this.extractPendingIntent(actionJson), question: clarification.message || this.game.text('parser.parse_unknown'), originalInput: this.extractRawInput(actionJson), }, @@ -413,7 +415,9 @@ export class Parser { }; } - const finalOutcomeWithMessage = [...result.outcomes].reverse().find((outcome) => !!outcome.message); + const finalOutcomeWithMessage = [...result.outcomes] + .reverse() + .find((outcome) => !!outcome.message); return { playerMessage: finalOutcomeWithMessage?.message, nextPendingState: null, @@ -430,6 +434,25 @@ export class Parser { } } + private extractPendingIntent(actionJson: string): 'look' | 'take' | 'goTo' { + try { + const envelope = JSON.parse(actionJson) as ParserActionEnvelope; + const firstAction = envelope.actions[0]; + if ( + firstAction && + firstAction.type === 'callGameMethod' && + (firstAction.method === 'look' || + firstAction.method === 'take' || + firstAction.method === 'goTo') + ) { + return firstAction.method; + } + } catch { + // Fall through to default. + } + return 'take'; + } + private looksLikeFreshCommand(input: string): boolean { const trimmed = input.trim(); if (!trimmed) return false; diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index c9f7a27..0d698b8 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -157,6 +157,7 @@ export class Scene { findEntity(name: string): Entity | undefined { const normalized = name.toUpperCase(); return this.entities.find((e) => { + if (e.disabled) return false; const resolvedTitle = this.game.textAssets.getResolvedObjectField(e, 'title'); return ( e.name.toUpperCase() === normalized || From 5b62958da24df0a450a7af09c5e3bb91162ed389 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Thu, 12 Mar 2026 17:31:08 +0200 Subject: [PATCH 14/75] Content update --- GDD.md | 16 + public/scenes/bug.json | 678 ------------------------------ public/scenes/test_room.json | 42 +- public/scenes/ttt.json | 145 ------- public/text/scenes/new_scene.json | 4 +- 5 files changed, 39 insertions(+), 846 deletions(-) delete mode 100644 public/scenes/bug.json delete mode 100644 public/scenes/ttt.json diff --git a/GDD.md b/GDD.md index 4522b22..6afd1c8 100644 --- a/GDD.md +++ b/GDD.md @@ -57,6 +57,22 @@ | <User> | <--text--- | | <--------- | | | | +```mermaid +flowchart LR + +User["User"] +Parser["Parser<br/>(Game Master)"] +Context["Game Context<br/>(Scene, Objects, NPCs, States)"] +API["Game Engine API"] +Preprocessor["Preprocessor"] + +User -- "text input" --> Preprocessor +Preprocessor -- "json" --> Parser +Context -- "json context" --> Parser +Parser -- "json commands" --> API +API -- "state / results" --> Parser +Parser -- "text response" --> User +``` Parser обрабатывает пользовательский ввод каскадно, если каскад не смог обработать команду, она передаётся следующему: diff --git a/public/scenes/bug.json b/public/scenes/bug.json deleted file mode 100644 index d939684..0000000 --- a/public/scenes/bug.json +++ /dev/null @@ -1,678 +0,0 @@ -{ - "id": "bug", - "name": "Test Room", - "filename": "bug", - "walkbox": [ - { - "type": "Walkbox", - "name": "Walk_997", - "locked": false, - "disabled": false, - "layer": 0, - "groupID": null, - "customName": "", - "interactions": {}, - "components": [], - "visible": true, - "poly": [ - { - "x": -79, - "y": 346 - }, - { - "x": -81, - "y": 272 - }, - { - "x": 153, - "y": 272 - }, - { - "x": 153, - "y": 346 - } - ], - "mode": "Add" - }, - { - "type": "Walkbox", - "name": "Walk_176", - "locked": false, - "disabled": false, - "layer": 0, - "groupID": null, - "customName": "", - "interactions": {}, - "components": [], - "visible": true, - "poly": [ - { - "x": 82, - "y": 192 - }, - { - "x": 407, - "y": 195 - }, - { - "x": 408, - "y": 203 - }, - { - "x": 470, - "y": 207 - }, - { - "x": 596, - "y": 220 - }, - { - "x": 624, - "y": 211 - }, - { - "x": 680, - "y": 211 - }, - { - "x": 680, - "y": 297 - }, - { - "x": -234, - "y": 291 - }, - { - "x": -210, - "y": 247 - }, - { - "x": -111, - "y": 249 - }, - { - "x": 77, - "y": 194 - } - ], - "mode": "Add" - } - ], - "triggerboxes": [ - { - "type": "Triggerbox", - "name": "Trig_sub_D", - "locked": false, - "disabled": false, - "layer": 0, - "groupID": null, - "customName": "", - "interactions": {}, - "components": [ - { - "type": "Subscene", - "targetGroupId": "#D", - "name": "" - } - ], - "visible": true, - "poly": [ - { - "x": 27, - "y": 210 - }, - { - "x": 28, - "y": 104 - }, - { - "x": 84, - "y": 94 - }, - { - "x": 83, - "y": 136 - }, - { - "x": 54, - "y": 145 - }, - { - "x": 50, - "y": 160 - }, - { - "x": 76, - "y": 170 - }, - { - "x": 79, - "y": 194 - } - ], - "script": "" - }, - { - "type": "Triggerbox", - "name": "sub_sw_d1", - "locked": false, - "disabled": false, - "layer": 0, - "groupID": "#D ", - "customName": "", - "interactions": {}, - "components": [ - { - "type": "Switch", - "groupId1": "nil", - "groupId2": "#D1", - "state": 1, - "idKey": "", - "sound1": "drawer_open.wav", - "sound2": "drawer_close.wav" - } - ], - "visible": true, - "poly": [ - { - "x": -156.29411764705887, - "y": -171.3529411764706 - }, - { - "x": 425, - "y": -171 - }, - { - "x": 414, - "y": -95 - }, - { - "x": -143, - "y": -95 - } - ], - "script": "" - }, - { - "type": "Triggerbox", - "name": "Trig_834", - "locked": false, - "disabled": false, - "layer": 0, - "groupID": null, - "customName": "", - "interactions": {}, - "components": [], - "visible": true, - "poly": [], - "script": "" - }, - { - "type": "Triggerbox", - "name": "sub_sw_d2", - "locked": false, - "disabled": false, - "layer": 0, - "groupID": "#D ", - "customName": "", - "interactions": {}, - "components": [ - { - "type": "Switch", - "groupId1": "nil", - "groupId2": "#D2", - "state": 1, - "idKey": "", - "sound1": "drawer_open.wav", - "sound2": "drawer_close.wav" - } - ], - "visible": true, - "poly": [ - { - "x": -156.29411764705878, - "y": -87.03921568627447 - }, - { - "x": 424.9999999999999, - "y": -86.68627450980387 - }, - { - "x": 413.9999999999999, - "y": -10.68627450980393 - }, - { - "x": -142.99999999999994, - "y": -10.68627450980393 - } - ], - "script": "" - } - ], - "scaling": { - "enabled": true, - "min": 0.91, - "max": 1, - "horizon": 193, - "front": 269 - }, - "entities": [ - { - "type": "Entity", - "name": "CityView", - "x": 54, - "y": 225, - "width": 821.6, - "height": 551.2, - "baseWidth": 1264, - "baseHeight": 848, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "window-view.json", - "color": "#00ff00", - "scale": 0.65, - "modelScale": 0.65, - "layer": -2, - "parallax": 0.2, - "ignoreScaling": true, - "animationSpeed": 150, - "locked": true, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Entity", - "name": "room", - "x": 199, - "y": 297, - "width": 884.8, - "height": 593.5999999999999, - "baseWidth": 1264, - "baseHeight": 848, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "room2", - "color": "#888888", - "scale": 0.7, - "modelScale": 0.7, - "layer": -1, - "parallax": 1, - "ignoreScaling": true, - "animationSpeed": 150, - "locked": true, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Entity", - "name": "Chair", - "x": 100, - "y": 249, - "width": 116.89999999999999, - "height": 198.79999999999998, - "baseWidth": 167, - "baseHeight": 284, - "colliderWidth": 78, - "colliderHeight": 18, - "spriteName": "chair.json", - "color": "#00ff00", - "scale": 0.7, - "modelScale": 0.7, - "layer": 0, - "parallax": 1, - "ignoreScaling": true, - "animationSpeed": 150, - "locked": true, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Actor", - "name": "Hero_1", - "x": 190.75771978135336, - "y": 255.02787403588582, - "width": 69.86457651889262, - "height": 285.28035411881154, - "baseWidth": 96, - "baseHeight": 392, - "colliderWidth": 88, - "colliderHeight": 4, - "spriteName": "miles_ds-idle-right.json", - "color": "#00ffff", - "scale": 0.7277560054051315, - "modelScale": 0.74, - "layer": 0, - "parallax": 1, - "ignoreScaling": false, - "animationSpeed": 30, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, - "isPlayer": true, - "speed": 0.24, - "direction": "left", - "animSets": { - "idle": { - "id": "idle", - "up": "miles_ds-idle-up.json", - "down": "miles_ds-idle-down.json", - "left": null, - "right": "miles_ds-idle-right.json" - }, - "walk": { - "id": "walk", - "up": "miles_ds-walk-up.json", - "down": "miles_ds-walk-down.json", - "left": null, - "right": "miles_ds-walk-right.json" - } - } - }, - { - "type": "Entity", - "name": "Black_1", - "x": -328, - "y": 307, - "width": 171.2340644206598, - "height": 637.1050459736764, - "baseWidth": 155.6673312915089, - "baseHeight": 579.1864054306149, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": null, - "color": "#000000", - "scale": 1.1, - "modelScale": 1.1, - "layer": 0, - "parallax": 1, - "ignoreScaling": false, - "animationSpeed": 150, - "locked": true, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Entity", - "name": "sofa", - "x": 195, - "y": 293, - "width": 882.6999999999999, - "height": 79.1, - "baseWidth": 1261, - "baseHeight": 113, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "sofa.json", - "color": "#00ff00", - "scale": 0.7, - "modelScale": 0.7, - "layer": 1, - "parallax": 1, - "ignoreScaling": false, - "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Entity", - "name": "sub_D_main", - "x": 135, - "y": 310, - "width": 614.4, - "height": 484.79999999999995, - "baseWidth": 1024, - "baseHeight": 808, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "sub_drawers_main", - "color": "#00ff00", - "scale": 0.6, - "modelScale": 0.6, - "layer": 3, - "parallax": 1, - "ignoreScaling": true, - "animationSpeed": 150, - "locked": true, - "disabled": false, - "customName": "", - "groupID": "#D", - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Entity", - "name": "sub_D2_body", - "x": 134, - "y": 311, - "width": 716.8, - "height": 475.29999999999995, - "baseWidth": 1024, - "baseHeight": 679, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "sub_drawers_d2.json", - "color": "#00ff00", - "scale": 0.7, - "modelScale": 0.7, - "layer": 4, - "parallax": 1, - "ignoreScaling": true, - "animationSpeed": 150, - "locked": false, - "disabled": true, - "customName": "", - "groupID": "#D2", - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Entity", - "name": "sub_D1_body", - "x": 136, - "y": -4, - "width": 614.4, - "height": 177.6, - "baseWidth": 1024, - "baseHeight": 296, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "sub_drawers_d1_body.json", - "color": "#00ff00", - "scale": 0.6, - "modelScale": 0.6, - "layer": 5, - "parallax": 1, - "ignoreScaling": true, - "animationSpeed": 150, - "locked": false, - "disabled": true, - "customName": "", - "groupID": "#D1", - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Entity", - "name": "miles_id", - "x": 93, - "y": 0, - "width": 150.4436263347707, - "height": 93.2343137254902, - "baseWidth": 165.32266630194582, - "baseHeight": 102.45528980823099, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "sub_drawers_d1_id.json", - "color": "#AAAAAA", - "scale": 0.91, - "modelScale": 1, - "layer": 5, - "parallax": 1, - "ignoreScaling": false, - "animationSpeed": 150, - "locked": false, - "disabled": true, - "customName": "your ID card", - "groupID": "#D1", - "components": [ - { - "type": "Item", - "ignoreDistance": true - } - ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Entity", - "name": "sub_D1_stuff", - "x": 320, - "y": 184, - "width": 979, - "height": 412, - "baseWidth": 979, - "baseHeight": 412, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "sub_drawers_d1_items.json", - "color": "#00ff00", - "scale": 1, - "modelScale": 1, - "layer": 5, - "parallax": 1, - "ignoreScaling": true, - "animationSpeed": 150, - "locked": false, - "disabled": true, - "customName": "", - "groupID": "#D1", - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Entity", - "name": "sub_D_main_top", - "x": 135, - "y": -176, - "width": 614.4, - "height": 129.6, - "baseWidth": 1024, - "baseHeight": 216, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "sub_drawers_top.json", - "color": "#00ff00", - "scale": 0.6, - "modelScale": 0.6, - "layer": 6, - "parallax": 1, - "ignoreScaling": true, - "animationSpeed": 150, - "locked": true, - "disabled": false, - "customName": "", - "groupID": "#D", - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - }, - { - "type": "Entity", - "name": "sub_D1_fasade", - "x": 135, - "y": 66, - "width": 614.4, - "height": 105.6, - "baseWidth": 1024, - "baseHeight": 176, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "sub_drawers_d1_facade.json", - "color": "#00ff00", - "scale": 0.6, - "modelScale": 0.6, - "layer": 6, - "parallax": 1, - "ignoreScaling": true, - "animationSpeed": 150, - "locked": false, - "disabled": true, - "customName": "", - "groupID": "#D1", - "components": [ - { - "type": "Subtrigger", - "target": "sub_sw_d1" - } - ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0 - } - ], - "camera": { - "x": 297, - "y": 29, - "zoom": 0.51 - }, - "autoCenter": false, - "cameraSpeed": 1.5, - "camDeadzoneX": 200, - "camDeadzoneY": -21, - "camMinX": 143, - "camMaxY": 45 -} diff --git a/public/scenes/test_room.json b/public/scenes/test_room.json index 991da00..703d8bd 100644 --- a/public/scenes/test_room.json +++ b/public/scenes/test_room.json @@ -138,19 +138,19 @@ ], "layer": 0, "visible": true, - "x": 95.87250515576761, - "y": 444.191244640698, - "width": 146.00904103865875, - "height": 352.40453732170107, + "x": 265.7928920608564, + "y": 449.8636667262623, + "width": 159.4835228879511, + "height": 384.92628055054865, "baseWidth": 162, "baseHeight": 391, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "miles_ds-idle-down.json", "color": "#00ffff", - "scale": 0.9012903767818442, + "scale": 0.9844661906663649, "modelScale": 1.03, - "parallax": 0.7442508967384815, + "parallax": 0.8536873845960511, "ignoreScaling": false, "animationSpeed": 30, "opacity": 1, @@ -188,30 +188,30 @@ "components": [], "layer": 0, "visible": true, - "x": 23.61461296023532, - "y": 444.1034969001102, + "x": 186.57454763425872, + "y": 449.76782116289945, "parallax": 1, "ignoreScaling": false, "vertices": [ { - "x": 23.61461296023532, - "y": 444.1034969001102, - "p": 0.7425580035788193 + "x": 186.57454763425872, + "y": 449.76782116289945, + "p": 0.8518382623563923 }, { - "x": 130.79310145805096, - "y": 444.10826139683275, - "p": 0.7426499237109707 + "x": 300.548110677063, + "y": 449.77224745488013, + "p": 0.851923657596959 }, { - "x": 121.97350541619949, - "y": 449.4618274910352, - "p": 0.8459348101360973 + "x": 300.7379764115932, + "y": 454.7457940959017, + "p": 0.9478769326493168 }, { - "x": 21.516082555040327, - "y": 449.17185306532133, - "p": 0.8403404128504302 + "x": 193.40132937190342, + "y": 454.4764033186863, + "p": 0.9426796500643867 } ], "color": "#000000", @@ -258,7 +258,7 @@ "blur": 0 }, { - "name": "logo_1", + "name": "logo_2", "type": "Entity", "locked": false, "disabled": false, diff --git a/public/scenes/ttt.json b/public/scenes/ttt.json deleted file mode 100644 index e8c841a..0000000 --- a/public/scenes/ttt.json +++ /dev/null @@ -1,145 +0,0 @@ -{ - "id": "ttt", - "name": "New Scene", - "filename": "ttt", - "walkbox": [], - "triggerboxes": [], - "scaling": { - "enabled": true, - "min": 0.5, - "max": 1, - "horizon": 150, - "front": 300 - }, - "entities": [ - { - "type": "Quad", - "name": "Table_1", - "x": -22.401046752929688, - "y": 102.5, - "color": "#888888", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, - "vertices": [ - { - "x": 12.319771405705986, - "y": 3.4308825168171477, - "p": 0.6666666666666669, - "binding": { - "targetName": "Quad_460", - "type": "grid", - "gridU": 0.3333333333333333, - "gridV": 0.16666666666666666 - } - }, - { - "x": 145.0142158501505, - "y": 3.4308825168171495, - "p": 0.6666666666666667, - "binding": { - "targetName": "Quad_460", - "type": "grid", - "gridU": 0.5, - "gridV": 0.16666666666666666 - } - }, - { - "x": 147.8475491834838, - "y": 65.0975491834838, - "p": 0.8, - "binding": { - "targetName": "Quad_460", - "type": "grid", - "gridU": 0.5, - "gridV": 0.5 - } - }, - { - "x": -9.569117483182907, - "y": 65.09754918348382, - "p": 0.7999999999999999, - "binding": { - "targetName": "Quad_460", - "type": "grid", - "gridU": 0.3333333333333333, - "gridV": 0.5 - } - } - ], - "sortMode": "v3", - "isGrid": false, - "gridLinesX": 5, - "gridLinesY": 5, - "lineWidth": 1, - "gridColor": "#ffffff", - "filled": true - }, - { - "type": "Actor", - "name": "miles_ds", - "x": -44.31537914386294, - "y": 27.151810240650562, - "width": 35.04, - "height": 143.07999999999998, - "baseWidth": 96, - "baseHeight": 392, - "colliderWidth": 0, - "colliderHeight": 0, - "spriteName": "miles_ds-idle-right.json", - "color": "#00ffff", - "scale": 0.365, - "modelScale": 0.73, - "layer": 0, - "parallax": 1, - "ignoreScaling": false, - "animationSpeed": 30, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, - "isPlayer": true, - "speed": 0.24, - "direction": "right", - "animSets": { - "idle": { - "id": "idle", - "up": "miles_ds-idle-up.json", - "down": "miles_ds-idle-down.json", - "left": null, - "right": "miles_ds-idle-right.json" - }, - "walk": { - "id": "walk", - "up": "miles_ds-walk-up.json", - "down": "miles_ds-walk-down.json", - "left": null, - "right": "miles_ds-walk-right.json" - } - } - } - ], - "camera": { - "x": 0, - "y": 0, - "zoom": 1 - }, - "autoCenter": false, - "cameraSpeed": 5, - "camDeadzoneX": 50, - "camDeadzoneY": 30 -} diff --git a/public/text/scenes/new_scene.json b/public/text/scenes/new_scene.json index faa8b8b..4d88f2e 100644 --- a/public/text/scenes/new_scene.json +++ b/public/text/scenes/new_scene.json @@ -1,4 +1,4 @@ { - "title": "New Scene", - "description": "You are in New Scene." + "title": "Room A", + "description": "You are in Room A." } From 06888858acfb841bbb0cfa92ef309416eef90859 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Fri, 13 Mar 2026 19:30:07 +0200 Subject: [PATCH 15/75] Fix: hide technical IDs from player-facing messages Ensure runtime and parser-adjacent messages use TA titles instead of technical object IDs. Escalate stage-1 cases that would need to name untitled objects in success or clarification flows, and fall back to generic engine text when naming is not possible. Also prevent locked-item and distance messages from exposing raw IDs. --- public/text/system/engine.json | 3 +- src/core/Game.ts | 104 ++++++++++++++++++++++++++++----- src/core/TextAssetManager.ts | 1 + src/systems/ComponentSystem.ts | 36 ++++++++++-- 4 files changed, 123 insertions(+), 21 deletions(-) diff --git a/public/text/system/engine.json b/public/text/system/engine.json index b7ff50c..81d53ea 100644 --- a/public/text/system/engine.json +++ b/public/text/system/engine.json @@ -2,5 +2,6 @@ "click_you_see": "You see {title}", "too_far_generic": "You are too far away.", "too_far_from_entity": "You are too far away from the {target}.", - "locked_needs": "Locked. Needs {item}" + "locked_needs": "Locked. Needs {item}", + "locked_generic": "Locked." } diff --git a/src/core/Game.ts b/src/core/Game.ts index 5d3a636..15721fd 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -445,7 +445,8 @@ export class Game implements IGame { ): | { status: 'found'; entity: Entity } | { status: 'not_found' } - | { status: 'ambiguous'; message: string; options: string[] } { + | { status: 'ambiguous'; message: string; options: string[] } + | { status: 'escalate'; code: string; message?: string } { const normalizedTarget = String(rawTarget || '') .trim() .toUpperCase(); @@ -466,6 +467,12 @@ export class Game implements IGame { } if (exactMatches.length > 1) { const optionTitles = this.getResolutionOptionTitles(exactMatches); + if (!optionTitles) { + return { + status: 'escalate', + code: 'ambiguous_targets_missing_titles', + }; + } return { status: 'ambiguous', message: this.text(options.clarificationKey, { options: optionTitles.join(', ') }), @@ -484,6 +491,12 @@ export class Game implements IGame { } if (partialMatches.length > 1) { const optionTitles = this.getResolutionOptionTitles(partialMatches); + if (!optionTitles) { + return { + status: 'escalate', + code: 'ambiguous_targets_missing_titles', + }; + } return { status: 'ambiguous', message: this.text(options.clarificationKey, { options: optionTitles.join(', ') }), @@ -506,17 +519,17 @@ export class Game implements IGame { return Array.from(new Set(tokens)); } - private getResolutionOptionTitles(entities: Entity[]): string[] { - return Array.from( - new Set( - entities.map( - (entity) => - this.textAssets.getResolvedObjectField(entity, 'title') || - entity.customName || - entity.name - ) - ) - ); + private getPlayerFacingEntityTitle(entity: Entity): string | null { + const title = this.textAssets.getResolvedObjectField(entity, 'title'); + return title && title.trim() ? title.trim() : null; + } + + private getResolutionOptionTitles(entities: Entity[]): string[] | null { + const titles = entities + .map((entity) => this.getPlayerFacingEntityTitle(entity)) + .filter((title): title is string => !!title); + if (titles.length !== entities.length) return null; + return Array.from(new Set(titles)); } look(target?: string | null): GameActionOutcome { @@ -555,6 +568,13 @@ export class Game implements IGame { includeTakeablesOnly: false, clarificationKey: 'parser.look_which_one', }); + if (resolved.status === 'escalate') { + return { + status: 'escalate', + code: resolved.code, + recoverable: true, + }; + } if (resolved.status === 'not_found') { return { status: 'failed', @@ -640,6 +660,19 @@ export class Game implements IGame { }) : null; + if (resolved.status === 'escalate' || broadResolved?.status === 'escalate') { + return { + status: 'escalate', + code: + resolved.status === 'escalate' + ? resolved.code + : broadResolved?.status === 'escalate' + ? broadResolved.code + : 'take_target_missing_title', + recoverable: true, + }; + } + if (resolved.status === 'not_found') { if (broadResolved?.status === 'ambiguous') { return { @@ -705,11 +738,21 @@ export class Game implements IGame { if (isItem || entity.isTakeable) { scene.removeEntity(entity); this.inventory.push(entity); + const itemTitle = this.getPlayerFacingEntityTitle(entity); + if (!itemTitle) { + return { + status: 'escalate', + code: 'taken_item_missing_title', + data: { entityId: entity.name }, + effects: ['moved_to_inventory'], + recoverable: true, + }; + } return { status: 'ok', code: 'item_taken', message: this.text('parser.take_pickup_success', { - item: entity.customName || entity.name, + item: itemTitle, }), data: { entityId: entity.name }, effects: ['moved_to_inventory'], @@ -726,6 +769,21 @@ export class Game implements IGame { } showInventory(): GameActionOutcome { + const inventoryTitles = this.inventory + .map((entity: any) => this.getPlayerFacingEntityTitle(entity)) + .filter((title): title is string => !!title); + + if (inventoryTitles.length !== this.inventory.length) { + return { + status: 'escalate', + code: 'inventory_item_missing_title', + data: { + count: this.inventory.length, + }, + recoverable: true, + }; + } + return { status: 'ok', code: 'inventory_list', @@ -733,7 +791,7 @@ export class Game implements IGame { this.inventory.length === 0 ? this.text('parser.inventory_empty') : this.text('parser.inventory_items', { - items: this.inventory.map((e: any) => e.customName || e.name).join(', '), + items: inventoryTitles.join(', '), }), data: { count: this.inventory.length, @@ -775,6 +833,13 @@ export class Game implements IGame { includeTakeablesOnly: false, clarificationKey: 'parser.go_to_which_one', }); + if (resolved.status === 'escalate') { + return { + status: 'escalate', + code: resolved.code, + recoverable: true, + }; + } if (resolved.status === 'ambiguous') { return { status: 'needs_clarification', @@ -787,12 +852,21 @@ export class Game implements IGame { if (resolved.status === 'found' && 'x' in resolved.entity && 'y' in resolved.entity) { const entity = resolved.entity; + const entityTitle = this.getPlayerFacingEntityTitle(entity); + if (!entityTitle) { + return { + status: 'escalate', + code: 'destination_missing_title', + data: { targetType: 'entity', entityId: entity.name }, + recoverable: true, + }; + } currentScene.player.moveTo((entity as any).x, (entity as any).y); return { status: 'ok', code: 'player_moving', message: this.text('parser.go_to_success', { - target: this.textAssets.getResolvedObjectField(entity, 'title') || entity.name, + target: entityTitle, }), data: { targetType: 'entity', entityId: entity.name }, effects: ['player_move_started'], diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 7fa5651..5dca4fd 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -31,6 +31,7 @@ const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { too_far_generic: 'You are too far away.', too_far_from_entity: 'You are too far away from the {target}.', locked_needs: 'Locked. Needs {item}', + locked_generic: 'Locked.', }, scripts: { pillar_key_inserted: 'You insert the key into a hidden slot in the pillar.', diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index 46e065d..52bfc7f 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -45,6 +45,11 @@ export interface WalkBoxComponent { import type { IGame } from '../core/IGame'; export class ComponentSystem { + private static getPlayerFacingTitle(game: IGame | undefined, entity: SceneObject): string | null { + const title = game?.textAssets.getResolvedObjectField(entity, 'title'); + return title && title.trim() ? title.trim() : null; + } + static update(entity: any, _dt: number) { if (!entity.components) return; @@ -113,10 +118,14 @@ export class ComponentSystem { const allowedDist = (player.width || 30) * 4; // Tolerance if (dist > allowedDist) { - return ( - game?.text('engine.too_far_from_entity', { target: entity.name }) || - `You are too far away from the ${entity.name}.` - ); + const title = this.getPlayerFacingTitle(game, entity); + if (title) { + return ( + game?.text('engine.too_far_from_entity', { target: title }) || + `You are too far away from the ${title}.` + ); + } + return game?.text('engine.too_far_generic') || 'You are too far away.'; } } @@ -212,7 +221,24 @@ export class ComponentSystem { (i) => i.name === sw.idKey || (i as unknown as { id?: string }).id === sw.idKey ); if (!hasKey) { - game.showMessage(game.text('engine.locked_needs', { item: sw.idKey })); + const keyEntity = + game.inventory.find( + (i) => i.name === sw.idKey || (i as unknown as { id?: string }).id === sw.idKey + ) || + scene.entities.find( + (i) => i.name === sw.idKey || (i as unknown as { id?: string }).id === sw.idKey + ) || + scene.triggerboxes.find( + (i) => i.name === sw.idKey || (i as unknown as { id?: string }).id === sw.idKey + ); + const keyTitle = keyEntity + ? this.getPlayerFacingTitle(game, keyEntity as SceneObject) + : null; + game.showMessage( + keyTitle + ? game.text('engine.locked_needs', { item: keyTitle }) + : game.text('engine.locked_generic') + ); return true; // Handled (Blocked) } } From 46ee4f0d49e23e66f715136bb4634511052e68c5 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 15 Mar 2026 18:07:52 +0200 Subject: [PATCH 16/75] Feature: add NLP cascade and parser-owned target resolution Introduce parser stage 2 with NLP.js-based intent recognition for look/take/go-to/inventory, plus parser debug support for stage2 and console toggles for parser staging. Add #STAGE1-ON and #STAGE1-OFF, extend #PEEK output with NLP diagnostics, and wire async parser flow through the UI layer. Refactor parser architecture so language interpretation stays inside Parser instead of leaking into Game and SceneManager. Replace string-target Game methods with resolved-target APIs (lookScene, lookEntity, examineEntity, takeEntity, goToScene, goToEntity), move scene/entity textual resolution and ambiguity handling into Parser, remove Scene title-based entity lookup, and remove the old SceneManager textual scene-target helper. Add inventory-aware parser target resolution for non-movement actions and introduce a separate EXAMINE action with stage1 aliases EXAMINE/X/CHECK/INSPECT and NLP intent support. EXAMINE reads the TA field details, escalates when details are missing, and is allowed for inventory items, active subscene objects, or nearby objects using the same distance threshold as item pickup. Improve target normalization and matching across cascades, including shared target cleanup, clarification prompts, partial matching, disabled-object exclusion, and player-facing message safety. Update parser/system TA strings, component distance helpers, and supporting test scene/text assets used to validate the new parser behavior end-to-end. --- package-lock.json | 99 +++++ package.json | 7 +- public/scenes/test1.json | 343 +++++++++------ public/text/objects/Chair.json | 4 + public/text/objects/key1.json | 4 + public/text/objects/key1_1.json | 4 + public/text/objects/key_g.json | 4 + public/text/objects/key_s.json | 4 + public/text/scenes/test1.json | 4 + public/text/system/parser.json | 2 + src/components/UIOverlay.tsx | 2 +- src/core/Console.ts | 14 + src/core/Game.ts | 376 +++++----------- src/core/IGame.ts | 10 +- src/core/TextAssetManager.ts | 2 + src/mechanics/NlpCascade.ts | 185 ++++++++ src/mechanics/Parser.ts | 620 +++++++++++++++++++++------ src/mechanics/nlp/normalizeTarget.ts | 59 +++ src/mechanics/nlp/trainingData.ts | 93 ++++ src/mechanics/parserTypes.ts | 105 +++++ src/scene/Scene.ts | 4 +- src/scene/SceneManager.ts | 83 ++-- src/systems/ComponentSystem.ts | 47 +- src/types/nlpjs.d.ts | 21 + 24 files changed, 1485 insertions(+), 611 deletions(-) create mode 100644 public/text/objects/Chair.json create mode 100644 public/text/objects/key1.json create mode 100644 public/text/objects/key1_1.json create mode 100644 public/text/objects/key_g.json create mode 100644 public/text/objects/key_s.json create mode 100644 public/text/scenes/test1.json create mode 100644 src/mechanics/NlpCascade.ts create mode 100644 src/mechanics/nlp/normalizeTarget.ts create mode 100644 src/mechanics/nlp/trainingData.ts create mode 100644 src/mechanics/parserTypes.ts create mode 100644 src/types/nlpjs.d.ts diff --git a/package-lock.json b/package-lock.json index 214dce5..cdea905 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "temp_vite", "version": "0.0.0", "dependencies": { + "@nlpjs/core": "^5.0.0-alpha.5", + "@nlpjs/lang-en-min": "^5.0.0-alpha.5", + "@nlpjs/nlp": "^5.0.0-alpha.5", "react": "^19.2.0", "react-dom": "^19.2.0", "zustand": "^5.0.9" @@ -1017,6 +1020,102 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nlpjs/core": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/core/-/core-5.0.0-alpha.5.tgz", + "integrity": "sha512-6KER6Jsy/KLVNor2isRsYV5XjVtYVaYLLqEmQt69Nmw2tIYyzzhzPxZPmOaU++OWfiUaZWGMgNueJHAmK6sApw==", + "license": "MIT" + }, + "node_modules/@nlpjs/lang-en-min": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/lang-en-min/-/lang-en-min-5.0.0-alpha.5.tgz", + "integrity": "sha512-a3k8VrJ6jOp9XSoz25kJR1N/kMaGjzb/B7dS1S5bqCvGY9sm+Xr4C9PCTZwEP4WgD2QZLCLEauiiKXM59zxwDQ==", + "license": "MIT", + "dependencies": { + "@nlpjs/core": "^5.0.0-alpha.5" + } + }, + "node_modules/@nlpjs/language-min": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/language-min/-/language-min-5.0.0-alpha.5.tgz", + "integrity": "sha512-O4tnPFqiSxK2Ukv0WD+NhviaBSjW8xq2GJFzQo3dKEief0JJQVK0McYzkahgyInhy0J/q4z9KGFzGs5VEVgwfA==", + "license": "MIT" + }, + "node_modules/@nlpjs/ner": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/ner/-/ner-5.0.0-alpha.5.tgz", + "integrity": "sha512-abtdWNXqEmWmRwRFCLnv72g2wMgcmWcEes6kL895GmevS/NseOE8j87ufTwPuk77Fgcga/z794eQaFU7Wn2HTA==", + "license": "MIT", + "dependencies": { + "@nlpjs/core": "^5.0.0-alpha.5", + "@nlpjs/language-min": "^5.0.0-alpha.5", + "@nlpjs/similarity": "^5.0.0-alpha.5" + } + }, + "node_modules/@nlpjs/neural": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/neural/-/neural-5.0.0-alpha.5.tgz", + "integrity": "sha512-QayWLhzfGardmIwJXEK7ojb6O2GCwJl5CQhwy8VI/zZtCvZ/l7ABKI0/tU0qhbPXWPg3v0PR3k5yLSBcX4XSPg==", + "license": "MIT" + }, + "node_modules/@nlpjs/nlg": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/nlg/-/nlg-5.0.0-alpha.5.tgz", + "integrity": "sha512-+gaNb1dcqzocR3CNdnvk+Tt5eAnWXCrnWmW99i6KB8FaKSMi8pzPmR1fZwCGVqhqlSRWAGLSRpEMpZ2qQuKjDQ==", + "license": "MIT", + "dependencies": { + "@nlpjs/core": "^5.0.0-alpha.5" + } + }, + "node_modules/@nlpjs/nlp": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/nlp/-/nlp-5.0.0-alpha.5.tgz", + "integrity": "sha512-K/MfazhTOWNjdn9Xy2PydYtziPHN5ZHFBfOKa5acWt26fdGiORBDL7ee2UqfM+Y1poZ9qaqbDHRi0O/R6YwpkA==", + "license": "MIT", + "dependencies": { + "@nlpjs/core": "^5.0.0-alpha.5", + "@nlpjs/ner": "^5.0.0-alpha.5", + "@nlpjs/nlg": "^5.0.0-alpha.5", + "@nlpjs/nlu": "^5.0.0-alpha.5", + "@nlpjs/sentiment": "^5.0.0-alpha.5", + "@nlpjs/slot": "^5.0.0-alpha.5" + } + }, + "node_modules/@nlpjs/nlu": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/nlu/-/nlu-5.0.0-alpha.5.tgz", + "integrity": "sha512-3DwDgGlpusuFXa3+8YFzm8BAzFT49PzRolUJDDOoMMhrF5s+J21XmtWPssFZjNfHjuqjssQRx4k+sJ9/QGfzXQ==", + "license": "MIT", + "dependencies": { + "@nlpjs/core": "^5.0.0-alpha.5", + "@nlpjs/language-min": "^5.0.0-alpha.5", + "@nlpjs/neural": "^5.0.0-alpha.5", + "@nlpjs/similarity": "^5.0.0-alpha.5" + } + }, + "node_modules/@nlpjs/sentiment": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/sentiment/-/sentiment-5.0.0-alpha.5.tgz", + "integrity": "sha512-yNk3lnZ7g1MFCnrm9TcZnFL1sK4IsBkBXzyw7gygG8lTlNZgPsumODo449fqWc1SHN2sz1K/dmSslBb6Iq2IcA==", + "license": "MIT", + "dependencies": { + "@nlpjs/core": "^5.0.0-alpha.5", + "@nlpjs/language-min": "^5.0.0-alpha.5", + "@nlpjs/neural": "^5.0.0-alpha.5" + } + }, + "node_modules/@nlpjs/similarity": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/similarity/-/similarity-5.0.0-alpha.5.tgz", + "integrity": "sha512-xgEjMXnwieZTXV/cRHnsFL72s6EvFxohOU2jWhXMgtaiV6bpglnJJTzvtFl8ZeLCzqfZqY3pfGmsvtWaR3vujw==", + "license": "MIT" + }, + "node_modules/@nlpjs/slot": { + "version": "5.0.0-alpha.5", + "resolved": "https://registry.npmjs.org/@nlpjs/slot/-/slot-5.0.0-alpha.5.tgz", + "integrity": "sha512-weia63mK3NXpUlkzVYx9fmv+SiAtVNrX8KC3wOqZtJl+Krg/Feo+xdmKPDT/VDtjz+v+je/4Xj85oAfPbHXepA==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 1635138..892776d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "preview": "vite preview" }, "dependencies": { + "@nlpjs/core": "^5.0.0-alpha.5", + "@nlpjs/lang-en-min": "^5.0.0-alpha.5", + "@nlpjs/nlp": "^5.0.0-alpha.5", "react": "^19.2.0", "react-dom": "^19.2.0", "zustand": "^5.0.9" @@ -26,13 +29,13 @@ "@vitejs/plugin-react": "^5.1.1", "baseline-browser-mapping": "^2.9.19", "eslint": "^9.39.1", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "husky": "^9.0.11", "lint-staged": "^15.2.10", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.2.1", "prettier": "^3.3.3", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", diff --git a/public/scenes/test1.json b/public/scenes/test1.json index fd67ccc..1211f4a 100644 --- a/public/scenes/test1.json +++ b/public/scenes/test1.json @@ -1,6 +1,8 @@ { "id": "test1", "name": "New Scene", + "description": "You are in New Scene.", + "textRedirects": {}, "filename": "test1", "walkbox": [], "triggerboxes": [], @@ -13,28 +15,26 @@ }, "entities": [ { - "type": "Quad", "name": "Table", - "x": 59.59895324707031, - "y": 38.5, - "color": "#3b3b3b", - "layer": -1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "WalkBox", "mode": "Subtract" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "multiply", - "blur": 4, + "layer": -1, + "visible": true, + "x": 59.59895324707031, + "y": 38.5, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 12.319771405705986, @@ -81,45 +81,51 @@ } } ], + "color": "#3b3b3b", "sortMode": "v3", + "opacity": 1, + "blendMode": "multiply", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 4 }, { - "type": "Actor", "name": "Hero", - "x": 342.2897696485943, - "y": 6.8788407954938435, - "width": 81.3795553528816, - "height": 196.41608730232534, - "baseWidth": 162, - "baseHeight": 391, + "type": "Actor", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": -202.22446023938141, + "y": 56.1156452815223, + "width": 54.53940368071831, + "height": 222.70256502959978, + "baseWidth": 96, + "baseHeight": 392, "colliderWidth": 67, "colliderHeight": 9, - "spriteName": "miles_ds-idle-down.json", + "spriteName": "miles_ds-idle-right.json", "color": "#00ffff", - "scale": 0.5023429342770469, + "scale": 0.5681187883408158, "modelScale": 0.73, - "layer": 0, - "parallax": 0.6772222218538513, + "parallax": 0.7836801774993183, "ignoreScaling": false, "animationSpeed": 30, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0, "isPlayer": true, "speed": 0.24, - "direction": "down", + "direction": "right", "animSets": { "idle": { "id": "idle", @@ -138,27 +144,25 @@ } }, { - "type": "Quad", "name": "Floor", - "x": -102.34295904141581, - "y": 154.6256011960822, - "color": "#888888", - "layer": -1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "3d-parallax" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": -1, + "visible": true, + "x": -102.34295904141581, + "y": 154.6256011960822, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -217.4024508165162, @@ -181,32 +185,34 @@ "p": 1 } ], + "color": "#888888", "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", "isGrid": true, "gridLinesX": 7, "gridLinesY": 7, "lineWidth": 1, "gridColor": "#ffffff", - "filled": false + "filled": false, + "blur": 0 }, { - "type": "Quad", "name": "Table_1", - "x": 76.7688832240514, - "y": -8.859439815848802, - "color": "#888888", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 0, + "visible": true, + "x": 76.7688832240514, + "y": -8.859439815848802, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 19.489701382687066, @@ -229,32 +235,34 @@ "p": 0.7999999999999999 } ], + "color": "#888888", "sortMode": "v3", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_390", - "x": -301.0427737310781, - "y": -19.020837617886528, - "color": "#888888", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 0, + "visible": true, + "x": -301.0427737310781, + "y": -19.020837617886528, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -329.6782468829987, @@ -283,119 +291,127 @@ "p": 0.8 } ], + "color": "#888888", "sortMode": "v1", + "opacity": 1, + "blendMode": "source-over", "isGrid": true, "gridLinesX": 0, "gridLinesY": 0, "lineWidth": 2, "gridColor": "#ffffff", - "filled": false + "filled": false, + "blur": 0 }, { + "name": "Chair", "type": "Entity", - "name": "Static_356", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, "x": 228.3733428469584, "y": 44.85723027846018, - "width": 74.5754043558174, - "height": 126.82284333564156, + "width": 73.86837489927758, + "height": 125.62046988859183, "baseWidth": 167, "baseHeight": 284, "colliderWidth": 59, "colliderHeight": 28, "spriteName": "chair", "color": "#AAAAAA", - "scale": 0.44655930751986467, + "scale": 0.442325598199267, "modelScale": 0.6, - "layer": 0, "parallax": 0.7, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Entity", "name": "Static_261", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, "x": -436, "y": 58, - "width": 212.99109421060265, - "height": 322.2891557134119, + "width": 211.7236974198895, + "height": 320.37138425378015, "baseWidth": 269.4226327944573, "baseHeight": 407.67898383371823, "colliderWidth": 0, "colliderHeight": 0, "spriteName": null, "color": "#000000", - "scale": 0.790546406593442, + "scale": 0.7858422851261113, "modelScale": 1, - "layer": 0, "parallax": 0.8, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Entity", "name": "Static_332", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, "x": -175.66857200445347, "y": -2.59236790543932, - "width": 105.59413541296372, - "height": 120.1588437457863, + "width": 104.2060667709911, + "height": 118.57931736009331, "baseWidth": 145, "baseHeight": 165, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "office-plant", "color": "#AAAAAA", - "scale": 0.7282354166411291, + "scale": 0.718662529455111, "modelScale": 1.1, - "layer": 0, "parallax": 0.63, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Quad", "name": "Quad_72", - "x": 518.7507530972048, - "y": -123.06448817274526, - "color": "#3b494e", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 0, + "visible": true, + "x": 518.7507530972048, + "y": -123.06448817274526, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 436, @@ -430,27 +446,27 @@ } } ], + "color": "#3b494e", "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_1", - "x": 347.11252701368375, - "y": -181.43675419702421, - "color": "#606b8a", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -462,10 +478,12 @@ "cullingType": "render" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 0, + "visible": true, + "x": 347.11252701368375, + "y": -181.43675419702421, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 413, @@ -504,27 +522,27 @@ } } ], + "color": "#606b8a", "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_2", - "x": 304.53304051582234, - "y": -222.5633211224302, - "color": "#606b8a", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -536,10 +554,12 @@ "cullingType": "render" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 0, + "visible": true, + "x": 304.53304051582234, + "y": -222.5633211224302, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 413, @@ -577,13 +597,52 @@ } } ], + "color": "#606b8a", "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 + }, + { + "name": "Chair", + "type": "Actor", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": 618.6579311226798, + "y": -607.9714361625997, + "width": 15, + "height": 24, + "baseWidth": 25, + "baseHeight": 40, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#36d87fff", + "scale": 0.6, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0, + "isPlayer": false, + "speed": 0.1, + "direction": "down", + "animSets": {} } ], "camera": { diff --git a/public/text/objects/Chair.json b/public/text/objects/Chair.json new file mode 100644 index 0000000..00bfe18 --- /dev/null +++ b/public/text/objects/Chair.json @@ -0,0 +1,4 @@ +{ + "title": "Chair", + "description": "Just an old ordinary office chair." +} diff --git a/public/text/objects/key1.json b/public/text/objects/key1.json new file mode 100644 index 0000000..990ffce --- /dev/null +++ b/public/text/objects/key1.json @@ -0,0 +1,4 @@ +{ + "title": "golden key", + "description": "You see GODEN key" +} diff --git a/public/text/objects/key1_1.json b/public/text/objects/key1_1.json new file mode 100644 index 0000000..2d37a83 --- /dev/null +++ b/public/text/objects/key1_1.json @@ -0,0 +1,4 @@ +{ + "title": "silver key", + "description": "You see SILVER key" +} diff --git a/public/text/objects/key_g.json b/public/text/objects/key_g.json new file mode 100644 index 0000000..a15f1ab --- /dev/null +++ b/public/text/objects/key_g.json @@ -0,0 +1,4 @@ +{ + "title": "golden key", + "description": "You see GOLDEN key." +} diff --git a/public/text/objects/key_s.json b/public/text/objects/key_s.json new file mode 100644 index 0000000..c000852 --- /dev/null +++ b/public/text/objects/key_s.json @@ -0,0 +1,4 @@ +{ + "title": "silver key", + "description": "You see nothing special." +} diff --git a/public/text/scenes/test1.json b/public/text/scenes/test1.json new file mode 100644 index 0000000..faa8b8b --- /dev/null +++ b/public/text/scenes/test1.json @@ -0,0 +1,4 @@ +{ + "title": "New Scene", + "description": "You are in New Scene." +} diff --git a/public/text/system/parser.json b/public/text/system/parser.json index a619644..a107ea9 100644 --- a/public/text/system/parser.json +++ b/public/text/system/parser.json @@ -3,6 +3,8 @@ "look_default_object": "You see nothing special about the {target}.", "look_not_found": "You don't see any {target} here.", "look_which_one": "Which one do you mean: {options}?", + "examine_prompt": "Examine what?", + "examine_which_one": "Which one do you want to examine: {options}?", "take_prompt": "Take what?", "take_which_one": "Which item do you mean: {options}?", "take_pickup_success": "You picked up the {item}.", diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index 8b07105..994df76 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -124,7 +124,7 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { game.console.addHistory(val); // 3. Send to gameplay parser - game.parser.parse(val); + void game.parser.parse(val); } e.currentTarget.value = ''; diff --git a/src/core/Console.ts b/src/core/Console.ts index b8e8092..d7865b1 100644 --- a/src/core/Console.ts +++ b/src/core/Console.ts @@ -19,6 +19,7 @@ export class Console { history: string[] = []; isOpen: boolean = false; parserPeekEnabled: boolean = false; + parserStage1Enabled: boolean = true; // Configuration readonly MAX_BUFFER_LINES = 2000; // Approx 150KB of text depending on length @@ -119,6 +120,19 @@ export class Console { this.parserPeekEnabled = false; this.log('Parser peek disabled.', 'info'); }); + + this.registerCommand('#STAGE1-OFF', () => { + this.parserStage1Enabled = false; + this.log( + 'Parser stage1 disabled. Commands will go directly to stage2 when possible.', + 'info' + ); + }); + + this.registerCommand('#STAGE1-ON', () => { + this.parserStage1Enabled = true; + this.log('Parser stage1 enabled.', 'info'); + }); } private runScript(id: string, args: string[]) { diff --git a/src/core/Game.ts b/src/core/Game.ts index 15721fd..a946255 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -435,104 +435,18 @@ export class Game implements IGame { return this.textAssets.getServiceText(key, params); } - private resolveSceneEntityTarget( - scene: Scene, - rawTarget: string, - options: { - includeTakeablesOnly: boolean; - clarificationKey: string; - } - ): - | { status: 'found'; entity: Entity } - | { status: 'not_found' } - | { status: 'ambiguous'; message: string; options: string[] } - | { status: 'escalate'; code: string; message?: string } { - const normalizedTarget = String(rawTarget || '') - .trim() - .toUpperCase(); - if (!normalizedTarget) return { status: 'not_found' }; - - const allCandidates = (scene.entities || []).filter((entity: Entity) => !entity.disabled); - const partialCandidates = allCandidates.filter((entity: Entity) => { - if (!options.includeTakeablesOnly) return true; - const isItem = entity.components && entity.components.find((c: any) => c.type === 'Item'); - return !!isItem || !!entity.isTakeable; - }); - - const exactMatches = allCandidates.filter((entity: Entity) => - this.getEntityLookupTokens(entity).includes(normalizedTarget) - ); - if (exactMatches.length === 1) { - return { status: 'found', entity: exactMatches[0] }; - } - if (exactMatches.length > 1) { - const optionTitles = this.getResolutionOptionTitles(exactMatches); - if (!optionTitles) { - return { - status: 'escalate', - code: 'ambiguous_targets_missing_titles', - }; - } - return { - status: 'ambiguous', - message: this.text(options.clarificationKey, { options: optionTitles.join(', ') }), - options: optionTitles, - }; - } - - const partialMatches = partialCandidates.filter((entity: Entity) => { - const title = this.textAssets.getResolvedObjectField(entity, 'title'); - if (!title) return false; - return title.toUpperCase().includes(normalizedTarget); - }); - - if (partialMatches.length === 1) { - return { status: 'found', entity: partialMatches[0] }; - } - if (partialMatches.length > 1) { - const optionTitles = this.getResolutionOptionTitles(partialMatches); - if (!optionTitles) { - return { - status: 'escalate', - code: 'ambiguous_targets_missing_titles', - }; - } - return { - status: 'ambiguous', - message: this.text(options.clarificationKey, { options: optionTitles.join(', ') }), - options: optionTitles, - }; - } - - return { status: 'not_found' }; - } - - private getEntityLookupTokens(entity: Entity): string[] { - const tokens = [ - entity.name, - entity.customName, - this.textAssets.getResolvedObjectField(entity, 'title'), - ] - .filter((value): value is string => !!value) - .map((value) => value.toUpperCase()); - - return Array.from(new Set(tokens)); - } - private getPlayerFacingEntityTitle(entity: Entity): string | null { const title = this.textAssets.getResolvedObjectField(entity, 'title'); return title && title.trim() ? title.trim() : null; } - private getResolutionOptionTitles(entities: Entity[]): string[] | null { - const titles = entities - .map((entity) => this.getPlayerFacingEntityTitle(entity)) - .filter((title): title is string => !!title); - if (titles.length !== entities.length) return null; - return Array.from(new Set(titles)); + private isEntityInInventory(entity: Entity): boolean { + return this.inventory.includes(entity); } - look(target?: string | null): GameActionOutcome { + private canExamineEntity(entity: Entity): GameActionOutcome | null { + if (this.isEntityInInventory(entity)) return null; + const scene = this.sceneManager.currentScene; if (!scene) { return { @@ -543,58 +457,48 @@ export class Game implements IGame { }; } - const normalizedTarget = String(target || '').trim(); - const normalizedUpper = normalizedTarget.toUpperCase(); - - if ( - !normalizedTarget || - normalizedUpper === 'AROUND' || - normalizedUpper === 'HERE' || - normalizedUpper === 'SCENE' - ) { - const sceneDescription = - this.textAssets.getResolvedSceneField(scene, 'description') || - scene.description || - this.text('parser.look_default_scene', { scene: scene.name }); - return { - status: 'ok', - code: 'scene_description', - message: sceneDescription, - data: { targetType: 'scene', sceneId: scene.id }, - }; + if (scene.activeSubscene && scene.subsceneEntities.has(entity as any)) { + return null; } - const resolved = this.resolveSceneEntityTarget(scene, normalizedTarget, { - includeTakeablesOnly: false, - clarificationKey: 'parser.look_which_one', - }); - if (resolved.status === 'escalate') { - return { - status: 'escalate', - code: resolved.code, - recoverable: true, - }; - } - if (resolved.status === 'not_found') { + const distanceError = ComponentSystem.getInteractionDistanceError(entity as any, scene.player); + if (distanceError) { return { status: 'failed', - code: 'entity_not_found', - message: this.text('parser.look_not_found', { target: normalizedTarget }), - data: { target: normalizedTarget }, + code: 'too_far_to_examine', + message: distanceError, + data: { entityId: entity.name }, recoverable: true, }; } - if (resolved.status === 'ambiguous') { + + return null; + } + + lookScene(scene?: Scene | null): GameActionOutcome { + const targetScene = scene || this.sceneManager.currentScene; + if (!targetScene) { return { - status: 'needs_clarification', - code: 'ambiguous_look_target', - message: resolved.message, - data: { target: normalizedTarget, options: resolved.options }, - recoverable: true, + status: 'failed', + code: 'no_current_scene', + message: this.text('parser.parse_unknown'), + recoverable: false, }; } - const entity = resolved.entity; + const sceneDescription = + this.textAssets.getResolvedSceneField(targetScene, 'description') || + targetScene.description || + this.text('parser.look_default_scene', { scene: targetScene.name }); + return { + status: 'ok', + code: 'scene_description', + message: sceneDescription, + data: { targetType: 'scene', sceneId: targetScene.id }, + }; + } + + lookEntity(entity: Entity): GameActionOutcome { const interactionId = entity.interactions && (entity.interactions.look || entity.interactions.LOOK); if (interactionId) { @@ -621,95 +525,61 @@ export class Game implements IGame { return { status: 'escalate', code: 'missing_description', - message: this.text('parser.look_default_object', { target: normalizedTarget }), data: { targetType: 'entity', entityId: entity.name }, recoverable: true, }; } - take(target?: string | null): GameActionOutcome { - const scene = this.sceneManager.currentScene; - if (!scene) { - return { - status: 'failed', - code: 'no_current_scene', - message: this.text('parser.parse_unknown'), - recoverable: false, - }; - } + examineEntity(entity: Entity): GameActionOutcome { + const accessError = this.canExamineEntity(entity); + if (accessError) return accessError; - const normalizedTarget = String(target || '').trim(); - if (!normalizedTarget) { + const interactionId = + entity.interactions && + (entity.interactions.examine || + entity.interactions.EXAMINE || + entity.interactions.inspect || + entity.interactions.INSPECT || + entity.interactions.check || + entity.interactions.CHECK); + if (interactionId) { + ScriptRegistry.execute(interactionId, { game: this, entity }); return { - status: 'needs_clarification', - code: 'missing_take_target', - message: this.text('parser.take_prompt'), - recoverable: true, + status: 'ok', + code: 'delegated_script', + data: { targetType: 'entity', entityId: entity.name, scriptId: interactionId }, + effects: ['script_executed'], }; } - const resolved = this.resolveSceneEntityTarget(scene, normalizedTarget, { - includeTakeablesOnly: true, - clarificationKey: 'parser.take_which_one', - }); - const broadResolved = - resolved.status === 'not_found' - ? this.resolveSceneEntityTarget(scene, normalizedTarget, { - includeTakeablesOnly: false, - clarificationKey: 'parser.take_which_one', - }) - : null; - - if (resolved.status === 'escalate' || broadResolved?.status === 'escalate') { + const details = this.textAssets.getResolvedObjectField(entity, 'details'); + if (details && details.trim()) { return { - status: 'escalate', - code: - resolved.status === 'escalate' - ? resolved.code - : broadResolved?.status === 'escalate' - ? broadResolved.code - : 'take_target_missing_title', - recoverable: true, + status: 'ok', + code: 'entity_details', + message: details, + data: { targetType: 'entity', entityId: entity.name }, }; } - if (resolved.status === 'not_found') { - if (broadResolved?.status === 'ambiguous') { - return { - status: 'needs_clarification', - code: 'ambiguous_take_target', - message: broadResolved.message, - data: { target: normalizedTarget, options: broadResolved.options }, - recoverable: true, - }; - } - if (broadResolved?.status === 'found') { - return { - status: 'failed', - code: 'not_takeable', - message: this.text('parser.take_cannot'), - data: { entityId: broadResolved.entity.name }, - recoverable: true, - }; - } + return { + status: 'escalate', + code: 'missing_details', + data: { targetType: 'entity', entityId: entity.name }, + recoverable: true, + }; + } + + takeEntity(entity: Entity): GameActionOutcome { + const scene = this.sceneManager.currentScene; + if (!scene) { return { status: 'failed', - code: 'entity_not_found', - message: this.text('parser.look_not_found', { target: normalizedTarget }), - data: { target: normalizedTarget }, - recoverable: true, - }; - } - if (resolved.status === 'ambiguous') { - return { - status: 'needs_clarification', - code: 'ambiguous_take_target', - message: resolved.message, - data: { target: normalizedTarget, options: resolved.options }, - recoverable: true, + code: 'no_current_scene', + message: this.text('parser.parse_unknown'), + recoverable: false, }; } - const entity = resolved.entity; const interactionId = entity.interactions && (entity.interactions.pickup || entity.interactions.PICKUP); @@ -799,86 +669,58 @@ export class Game implements IGame { }; } - goTo(target?: string | null): GameActionOutcome { - const normalizedTarget = String(target || '').trim(); - if (!normalizedTarget) { + goToScene(sceneId: string): GameActionOutcome { + const currentScene = this.sceneManager.currentScene; + const activeScene = this.sceneManager.scenes.get(sceneId); + if (!activeScene && !this.sceneManager.sceneRegistry.get(sceneId)) { return { - status: 'needs_clarification', - code: 'missing_destination', - message: this.text('parser.go_to_prompt'), + status: 'failed', + code: 'destination_not_found', recoverable: true, }; } - const currentScene = this.sceneManager.currentScene; - const sceneMatch = this.sceneManager.findSceneDescriptorByTarget(normalizedTarget); - - if (sceneMatch) { - this.sceneManager.switchTo(sceneMatch.id); - const activeScene = this.sceneManager.currentScene; - return { - status: 'ok', - code: 'scene_switched', - message: - (activeScene && this.textAssets.getResolvedSceneField(activeScene, 'description')) || - activeScene?.description || - this.text('parser.go_to_success', { target: sceneMatch.name }), - data: { targetType: 'scene', sceneId: sceneMatch.id }, - effects: currentScene?.id !== sceneMatch.id ? ['scene_changed'] : [], - }; - } + this.sceneManager.switchTo(sceneId); + const switchedScene = this.sceneManager.currentScene; + return { + status: 'ok', + code: 'scene_switched', + message: + (switchedScene && this.textAssets.getResolvedSceneField(switchedScene, 'description')) || + switchedScene?.description || + undefined, + data: { targetType: 'scene', sceneId }, + effects: currentScene?.id !== sceneId ? ['scene_changed'] : [], + }; + } - if (currentScene?.player) { - const resolved = this.resolveSceneEntityTarget(currentScene, normalizedTarget, { - includeTakeablesOnly: false, - clarificationKey: 'parser.go_to_which_one', - }); - if (resolved.status === 'escalate') { + goToEntity(entity: Entity): GameActionOutcome { + const currentScene = this.sceneManager.currentScene; + if (currentScene?.player && 'x' in entity && 'y' in entity) { + const entityTitle = this.getPlayerFacingEntityTitle(entity); + if (!entityTitle) { return { status: 'escalate', - code: resolved.code, - recoverable: true, - }; - } - if (resolved.status === 'ambiguous') { - return { - status: 'needs_clarification', - code: 'ambiguous_destination', - message: resolved.message, - data: { target: normalizedTarget, options: resolved.options }, - recoverable: true, - }; - } - - if (resolved.status === 'found' && 'x' in resolved.entity && 'y' in resolved.entity) { - const entity = resolved.entity; - const entityTitle = this.getPlayerFacingEntityTitle(entity); - if (!entityTitle) { - return { - status: 'escalate', - code: 'destination_missing_title', - data: { targetType: 'entity', entityId: entity.name }, - recoverable: true, - }; - } - currentScene.player.moveTo((entity as any).x, (entity as any).y); - return { - status: 'ok', - code: 'player_moving', - message: this.text('parser.go_to_success', { - target: entityTitle, - }), + code: 'destination_missing_title', data: { targetType: 'entity', entityId: entity.name }, - effects: ['player_move_started'], + recoverable: true, }; } + currentScene.player.moveTo((entity as any).x, (entity as any).y); + return { + status: 'ok', + code: 'player_moving', + message: this.text('parser.go_to_success', { + target: entityTitle, + }), + data: { targetType: 'entity', entityId: entity.name }, + effects: ['player_move_started'], + }; } return { status: 'failed', code: 'destination_not_found', - message: this.text('parser.go_to_not_found', { target: normalizedTarget }), - data: { target: normalizedTarget }, recoverable: true, }; } diff --git a/src/core/IGame.ts b/src/core/IGame.ts index 100fb79..7ce5488 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -5,6 +5,7 @@ import { SceneEditor } from '../tools/SceneEditor'; import { Entity } from '../entities/Entity'; import { TextAssetManager } from './TextAssetManager'; import type { GameActionOutcome } from './GameActionTypes'; +import type { Scene } from '../scene/Scene'; export interface IGame { assets: AssetLoader; @@ -17,10 +18,13 @@ export interface IGame { showMessage(text: string): void; log(text: string): void; text(key: string, params?: Record<string, string | number>): string; - look(target?: string | null): GameActionOutcome; - take(target?: string | null): GameActionOutcome; + lookScene(scene?: Scene | null): GameActionOutcome; + lookEntity(entity: Entity): GameActionOutcome; + examineEntity(entity: Entity): GameActionOutcome; + takeEntity(entity: Entity): GameActionOutcome; showInventory(): GameActionOutcome; - goTo(target?: string | null): GameActionOutcome; + goToScene(sceneId: string): GameActionOutcome; + goToEntity(entity: Entity): GameActionOutcome; showNotification?(text: string): void; // Optional onSceneChange?(sceneName: string): void; playSound(name: string): void; diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 5dca4fd..ed0d38f 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -9,6 +9,8 @@ const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { look_default_object: 'You see nothing special about the {target}.', look_not_found: "You don't see any {target} here.", look_which_one: 'Which one do you mean: {options}?', + examine_prompt: 'Examine what?', + examine_which_one: 'Which one do you want to examine: {options}?', take_prompt: 'Take what?', take_which_one: 'Which item do you mean: {options}?', take_pickup_success: 'You picked up the {item}.', diff --git a/src/mechanics/NlpCascade.ts b/src/mechanics/NlpCascade.ts new file mode 100644 index 0000000..90c219e --- /dev/null +++ b/src/mechanics/NlpCascade.ts @@ -0,0 +1,185 @@ +import { NLP_TRAINING_DATA } from './nlp/trainingData'; +import { normalizeTargetForIntent } from './nlp/normalizeTarget'; +import type { ParserActionEnvelope, ParserContext, ParserToolAction } from './parserTypes'; + +const NLP_CONFIDENCE_THRESHOLD = 0.58; + +type SupportedIntent = 'look' | 'examine' | 'take' | 'goTo' | 'showInventory'; + +type NlpProcessResult = { + intent?: string; + score?: number; +}; + +export type NlpCascadeDebugInfo = { + input: string; + normalizedInput: string; + rawIntent: string; + score: number; + matched: boolean; + reason?: + | 'not_initialized' + | 'none_intent' + | 'low_confidence' + | 'unsupported_intent' + | 'no_actions'; + target?: string | null; +}; + +export class NlpCascade { + private manager: any = null; + private initPromise: Promise<void> | null = null; + private ready = false; + private lastDebugInfo: NlpCascadeDebugInfo | null = null; + + getLastDebugInfo(): NlpCascadeDebugInfo | null { + return this.lastDebugInfo; + } + + clearLastDebugInfo(): void { + this.lastDebugInfo = null; + } + + initialize(): Promise<void> { + if (this.ready) return Promise.resolve(); + if (this.initPromise) return this.initPromise; + + this.initPromise = (async () => { + const [coreModule, { Nlp }, { LangEn }] = await Promise.all([ + import('@nlpjs/core'), + import('@nlpjs/nlp'), + import('@nlpjs/lang-en-min'), + ]); + const { ArrToObj, Container, Normalizer, Stemmer, Stopwords, Tokenizer } = coreModule as any; + + const container = new Container(); + container.use(ArrToObj); + container.use(Normalizer); + container.use(Tokenizer); + container.use(Stopwords); + container.use(Stemmer); + container.use(LangEn); + + this.manager = new Nlp( + { + autoSave: false, + autoLoad: false, + forceNER: false, + languages: ['en'], + nlu: { useNoneFeature: true }, + }, + container + ); + + for (const [intent, utterances] of Object.entries(NLP_TRAINING_DATA)) { + for (const utterance of utterances) { + this.manager.addDocument('en', utterance, intent); + } + } + await this.manager.train(); + this.ready = true; + })(); + + return this.initPromise; + } + + async parse(input: string, _context: ParserContext): Promise<ParserActionEnvelope | null> { + await this.initialize(); + const normalizedInput = input.replace(/[?.!,]+$/g, '').trim(); + if (!this.manager) { + this.lastDebugInfo = { + input, + normalizedInput, + rawIntent: '', + score: 0, + matched: false, + reason: 'not_initialized', + }; + return null; + } + const result = (await this.manager.process('en', normalizedInput)) as NlpProcessResult; + const rawIntent = (result.intent || '').trim(); + const score = Number(result.score || 0); + + if (!rawIntent || rawIntent === 'None') { + this.lastDebugInfo = { + input, + normalizedInput, + rawIntent, + score, + matched: false, + reason: 'none_intent', + }; + return null; + } + + if (score < NLP_CONFIDENCE_THRESHOLD) { + this.lastDebugInfo = { + input, + normalizedInput, + rawIntent, + score, + matched: false, + reason: 'low_confidence', + }; + return null; + } + + const intent = rawIntent as SupportedIntent; + + const target = normalizeTargetForIntent(input, intent); + const actions = this.buildActions(intent, target); + if (!actions) { + this.lastDebugInfo = { + input, + normalizedInput, + rawIntent, + score, + matched: false, + reason: 'no_actions', + target, + }; + return null; + } + + this.lastDebugInfo = { + input, + normalizedInput, + rawIntent, + score, + matched: true, + target, + }; + + return { + stage: 'nlp-v2', + actions, + debug: { + rawInput: input, + normalizedInput: normalizedInput.toUpperCase(), + verb: intent.toUpperCase(), + noun: target || '', + intent, + score, + source: 'nlpjs', + }, + }; + } + + private buildActions(intent: SupportedIntent, target: string | null): ParserToolAction[] | null { + switch (intent) { + case 'look': + return target ? [{ type: 'lookTarget', target }] : [{ type: 'lookScene' }]; + case 'examine': + return [{ type: 'examineTarget', target }]; + case 'take': + return [{ type: 'takeTarget', target }]; + case 'goTo': + return [{ type: 'goToTarget', target }]; + case 'showInventory': + return [{ type: 'showInventory' }]; + default: + return null; + } + } +} diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 1d3c2e2..5782107 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -1,89 +1,24 @@ import type { GameActionOutcome } from '../core/GameActionTypes'; - -type ParserEntityContext = { - id: string; - type: string; - title: string | null; - description: string | null; - interactions: string[]; -}; - -type ParserInventoryItemContext = { - id: string; - title: string | null; -}; - -type ParserPendingState = { - intent: 'look' | 'take' | 'goTo'; - question: string; - originalInput: string; -}; - -type ParserContext = { - rawInput: string; - normalizedInput: string; - scene: { - id: string; - name: string; - title: string | null; - description: string | null; - } | null; - entities: ParserEntityContext[]; - inventory: ParserInventoryItemContext[]; - pending: ParserPendingState | null; -}; - -type ParserToolAction = - | { - type: 'callGameMethod'; - method: 'look' | 'take' | 'showInventory' | 'goTo'; - args: Array<string | null>; - } - | { - type: 'handoff'; - reason: string; - verb: string; - noun: string; - rawInput: string; - }; - -type ParserActionEnvelope = { - stage: 'regex-v1' | 'pending-resolution'; - actions: ParserToolAction[]; - debug: { - rawInput: string; - normalizedInput: string; - verb: string; - noun: string; - pendingIntent?: string; - }; -}; - -type ParserResult = - | { - type: 'outcomes'; - handled: boolean; - outcomes: GameActionOutcome[]; - actionsExecuted: string[]; - } - | { - type: 'handoff'; - handled: false; - outcomes: GameActionOutcome[]; - actionsExecuted: string[]; - reason: string; - debug: Record<string, unknown>; - }; - -type ParserResponse = { - playerMessage?: string; - debugMessages?: string[]; - nextPendingState?: ParserPendingState | null; -}; +import { NlpCascade } from './NlpCascade'; +import { normalizeTargetForIntent } from './nlp/normalizeTarget'; +import type { Entity } from '../entities/Entity'; +import type { SceneDescriptor } from '../scene/SceneManager'; +import type { + ParserActionEnvelope, + ParserContext, + ParserEntityContext, + ParserInventoryItemContext, + ParserPendingState, + ParserResponse, + ParserResult, + ParserToolAction, +} from './parserTypes'; const STAGE1_COMMAND_WORDS = new Set([ 'LOOK', 'EXAMINE', + 'INSPECT', + 'CHECK', 'X', 'TAKE', 'GET', @@ -100,42 +35,63 @@ export class Parser { game: any; inputField: HTMLInputElement | null; pendingState: ParserPendingState | null; + nlpCascade: NlpCascade; constructor(game: any) { this.game = game; this.inputField = null; this.pendingState = null; + this.nlpCascade = new NlpCascade(); } - parse(input: string): void { + async parse(input: string): Promise<void> { const trimmed = input.trim(); if (!trimmed) return; + try { + this.nlpCascade.clearLastDebugInfo(); + const actionEnvelope = this.resolvePendingAction(trimmed); + const context = this.buildContext(trimmed); + const contextJson = JSON.stringify(context); + let actionJson = + actionEnvelope || + (this.game.console?.parserStage1Enabled === false + ? this.buildStage1BypassAction(trimmed) + : this.runStage1(trimmed)); + + if (!actionEnvelope && this.isHandoffAction(actionJson)) { + const stage2Envelope = await this.nlpCascade.parse(trimmed, context); + if (stage2Envelope) { + actionJson = JSON.stringify(stage2Envelope); + } + } - const actionEnvelope = this.resolvePendingAction(trimmed); - const contextJson = this.buildContextJson(trimmed); - const actionJson = actionEnvelope || this.runStage1(trimmed); - const resultJson = this.executeActionJson(actionJson); - const response = this.buildResponse(resultJson, actionJson, contextJson); + const resultJson = this.executeActionJson(actionJson); + const response = this.buildResponse(resultJson, actionJson, contextJson); - if (response.debugMessages?.length) { - for (const message of response.debugMessages) { - this.game.console?.log(message, 'info'); + if (response.debugMessages?.length) { + for (const message of response.debugMessages) { + this.game.console?.log(message, 'info'); + } } - } - this.pendingState = - response.nextPendingState === undefined ? this.pendingState : response.nextPendingState; + this.pendingState = + response.nextPendingState === undefined ? this.pendingState : response.nextPendingState; - if (response.playerMessage) { - this.game.log(response.playerMessage); + if (response.playerMessage) { + this.game.log(response.playerMessage); + } + } catch (error) { + this.pendingState = null; + this.game.console?.log(`[Parser error] ${String(error)}`, 'error'); + this.game.log(this.game.text('parser.parse_unknown')); } } - private buildContextJson(rawInput: string): string { + private buildContext(rawInput: string): ParserContext { const scene = this.game.sceneManager.currentScene; const normalizedInput = rawInput.trim().toUpperCase(); - const context: ParserContext = { + return { rawInput, normalizedInput, scene: scene @@ -153,6 +109,7 @@ export class Parser { type: entity.type, title: this.game.textAssets.getResolvedObjectField(entity, 'title'), description: this.game.textAssets.getResolvedObjectField(entity, 'description'), + details: this.game.textAssets.getResolvedObjectField(entity, 'details'), interactions: Object.keys(entity.interactions || {}), })) .filter((entity: ParserEntityContext) => !!entity.title?.trim()) @@ -161,6 +118,8 @@ export class Parser { .map((entity: any) => ({ id: entity.name, title: this.game.textAssets.getResolvedObjectField(entity, 'title'), + description: this.game.textAssets.getResolvedObjectField(entity, 'description'), + details: this.game.textAssets.getResolvedObjectField(entity, 'details'), })) .filter((entity: ParserInventoryItemContext) => !!entity.title?.trim()), pending: this.pendingState @@ -171,8 +130,6 @@ export class Parser { } : null, }; - - return JSON.stringify(context); } private resolvePendingAction(input: string): string | null { @@ -183,9 +140,15 @@ export class Parser { } const action: ParserToolAction = { - type: 'callGameMethod', - method: this.pendingState.intent, - args: [input.trim()], + type: + this.pendingState.intent === 'look' + ? 'lookTarget' + : this.pendingState.intent === 'examine' + ? 'examineTarget' + : this.pendingState.intent === 'take' + ? 'takeTarget' + : 'goToTarget', + target: input.trim(), }; const envelope: ParserActionEnvelope = { @@ -213,41 +176,46 @@ export class Parser { switch (verb) { case 'LOOK': + actions = [ + !normalizedNoun || + normalizedNoun === 'AROUND' || + normalizedNoun === 'HERE' || + normalizedNoun === 'SCENE' + ? { type: 'lookScene' as const } + : { + type: 'lookTarget' as const, + target: normalizeTargetForIntent(input, 'look') || noun, + }, + ]; + break; case 'EXAMINE': + case 'INSPECT': + case 'CHECK': case 'X': actions = [ { - type: 'callGameMethod', - method: 'look', - args: [ - !normalizedNoun || - normalizedNoun === 'AROUND' || - normalizedNoun === 'HERE' || - normalizedNoun === 'SCENE' - ? null - : noun, - ], + type: 'examineTarget', + target: normalizeTargetForIntent(input, 'examine') || noun || null, }, ]; break; case 'TAKE': case 'GET': case 'PICKUP': - actions = [{ type: 'callGameMethod', method: 'take', args: [noun || null] }]; + actions = [ + { type: 'takeTarget', target: normalizeTargetForIntent(input, 'take') || noun || null }, + ]; break; case 'INV': case 'INVENTORY': case 'I': - actions = [{ type: 'callGameMethod', method: 'showInventory', args: [] }]; + actions = [{ type: 'showInventory' }]; break; case 'GO': case 'WALK': case 'MOVE': { - let target = noun; - if (target.toUpperCase().startsWith('TO ')) { - target = target.slice(3).trim(); - } - actions = [{ type: 'callGameMethod', method: 'goTo', args: [target || null] }]; + const target = normalizeTargetForIntent(input, 'goTo') || noun; + actions = [{ type: 'goToTarget', target: target || null }]; break; } default: @@ -277,6 +245,42 @@ export class Parser { return JSON.stringify(envelope); } + private buildStage1BypassAction(input: string): string { + const words = input.trim().split(/\s+/); + const verb = (words[0] || '').toUpperCase(); + const noun = words.slice(1).join(' ').trim(); + + const envelope: ParserActionEnvelope = { + stage: 'regex-v1', + actions: [ + { + type: 'handoff', + reason: 'stage1_disabled', + verb, + noun, + rawInput: input, + }, + ], + debug: { + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + verb, + noun, + }, + }; + + return JSON.stringify(envelope); + } + + private isHandoffAction(actionJson: string): boolean { + try { + const envelope = JSON.parse(actionJson) as ParserActionEnvelope; + return envelope.actions.length === 1 && envelope.actions[0]?.type === 'handoff'; + } catch { + return false; + } + } + private executeActionJson(actionJson: string): string { const envelope = JSON.parse(actionJson) as ParserActionEnvelope; const executedActions: string[] = []; @@ -311,8 +315,8 @@ export class Parser { return JSON.stringify(result); } - executedActions.push(action.method); - const outcome = this.callGameMethod(action.method, action.args); + const outcome = this.executeParserAction(action); + executedActions.push(this.getExecutedActionName(action)); outcomes.push(outcome); if (outcome.status !== 'ok') { @@ -329,40 +333,372 @@ export class Parser { return JSON.stringify(result); } - private callGameMethod( - method: 'look' | 'take' | 'showInventory' | 'goTo', - args: Array<string | null> - ): GameActionOutcome { - switch (method) { - case 'look': - return this.game.look(args[0] || null); - case 'take': - return this.game.take(args[0] || null); + private executeParserAction(action: ParserToolAction): GameActionOutcome { + switch (action.type) { + case 'lookScene': + return this.game.lookScene(); + case 'lookTarget': + return this.resolveLookTarget(action.target); + case 'examineTarget': + return this.resolveExamineTarget(action.target); + case 'takeTarget': + return this.resolveTakeTarget(action.target); case 'showInventory': return this.game.showInventory(); - case 'goTo': - return this.game.goTo(args[0] || null); + case 'goToTarget': + return this.resolveGoToTarget(action.target); + case 'handoff': + return { + status: 'escalate', + code: action.reason, + message: this.game.text('parser.parse_unknown'), + recoverable: true, + }; default: return { status: 'escalate', - code: 'unknown_game_method', + code: 'unknown_parser_action', message: this.game.text('parser.parse_unknown'), recoverable: false, }; } } + private getExecutedActionName(action: ParserToolAction): string { + switch (action.type) { + case 'lookScene': + return 'lookScene'; + case 'lookTarget': + return 'look'; + case 'examineTarget': + return 'examine'; + case 'takeTarget': + return 'take'; + case 'showInventory': + return 'showInventory'; + case 'goToTarget': + return 'goTo'; + case 'handoff': + return 'handoff'; + default: + return 'unknown'; + } + } + + private getPlayerFacingEntityTitle(entity: Entity): string | null { + const title = this.game.textAssets.getResolvedObjectField(entity, 'title'); + return title && title.trim() ? title.trim() : null; + } + + private getEntityLookupTokens(entity: Entity): string[] { + const title = this.getPlayerFacingEntityTitle(entity); + return title ? [title.toUpperCase()] : []; + } + + private getResolutionOptionTitles(entities: Entity[]): string[] | null { + const titles = entities + .map((entity) => this.getPlayerFacingEntityTitle(entity)) + .filter((title): title is string => !!title); + if (titles.length !== entities.length) return null; + return Array.from(new Set(titles)); + } + + private getSceneEntitiesForResolution(options: { includeTakeablesOnly: boolean }): Entity[] { + const scene = this.game.sceneManager.currentScene; + if (!scene) return []; + + return (scene.entities || []).filter((entity: Entity) => { + if (entity.disabled || !this.getPlayerFacingEntityTitle(entity)) return false; + if (!options.includeTakeablesOnly) return true; + const isItem = entity.components && entity.components.find((c: any) => c.type === 'Item'); + return !!isItem || !!entity.isTakeable; + }); + } + + private getInventoryEntitiesForResolution(): Entity[] { + return (this.game.inventory || []).filter( + (entity: Entity) => !!this.getPlayerFacingEntityTitle(entity) + ); + } + + private resolveSceneEntityTarget( + rawTarget: string, + options: { + includeTakeablesOnly: boolean; + includeInventory: boolean; + clarificationKey: string; + } + ): + | { status: 'found'; entity: Entity } + | { status: 'not_found' } + | { status: 'ambiguous'; message: string; options: string[] } + | { status: 'escalate'; code: string } { + const scene = this.game.sceneManager.currentScene; + if (!scene) return { status: 'not_found' }; + + const normalizedTarget = String(rawTarget || '') + .trim() + .toUpperCase(); + if (!normalizedTarget) return { status: 'not_found' }; + + const sceneCandidates = this.getSceneEntitiesForResolution({ + includeTakeablesOnly: options.includeTakeablesOnly, + }); + const inventoryCandidates = options.includeInventory + ? this.getInventoryEntitiesForResolution() + : []; + const exactCandidates = Array.from(new Set([...sceneCandidates, ...inventoryCandidates])); + const partialCandidates = exactCandidates; + + const exactMatches = exactCandidates.filter((entity: Entity) => + this.getEntityLookupTokens(entity).includes(normalizedTarget) + ); + if (exactMatches.length === 1) return { status: 'found', entity: exactMatches[0] }; + if (exactMatches.length > 1) { + const optionTitles = this.getResolutionOptionTitles(exactMatches); + if (!optionTitles) return { status: 'escalate', code: 'ambiguous_targets_missing_titles' }; + return { + status: 'ambiguous', + message: this.game.text(options.clarificationKey, { options: optionTitles.join(', ') }), + options: optionTitles, + }; + } + + const partialMatches = partialCandidates.filter((entity: Entity) => { + const title = this.getPlayerFacingEntityTitle(entity); + return !!title && title.toUpperCase().includes(normalizedTarget); + }); + if (partialMatches.length === 1) return { status: 'found', entity: partialMatches[0] }; + if (partialMatches.length > 1) { + const optionTitles = this.getResolutionOptionTitles(partialMatches); + if (!optionTitles) return { status: 'escalate', code: 'ambiguous_targets_missing_titles' }; + return { + status: 'ambiguous', + message: this.game.text(options.clarificationKey, { options: optionTitles.join(', ') }), + options: optionTitles, + }; + } + + return { status: 'not_found' }; + } + + private resolveSceneTarget(rawTarget: string): SceneDescriptor | null { + const normalized = String(rawTarget || '') + .trim() + .toUpperCase(); + if (!normalized) return null; + for (const descriptor of this.game.sceneManager.sceneRegistry.values()) { + if ( + descriptor.id.toUpperCase() === normalized || + descriptor.name.toUpperCase() === normalized || + (!!descriptor.title && descriptor.title.toUpperCase() === normalized) + ) { + return descriptor; + } + } + return null; + } + + private resolveLookTarget(rawTarget: string): GameActionOutcome { + const resolved = this.resolveSceneEntityTarget(rawTarget, { + includeTakeablesOnly: false, + includeInventory: true, + clarificationKey: 'parser.look_which_one', + }); + if (resolved.status === 'escalate') { + return { status: 'escalate', code: resolved.code, recoverable: true }; + } + if (resolved.status === 'not_found') { + return { + status: 'failed', + code: 'entity_not_found', + message: this.game.text('parser.look_not_found', { target: rawTarget }), + data: { target: rawTarget }, + recoverable: true, + }; + } + if (resolved.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_look_target', + message: resolved.message, + data: { target: rawTarget, options: resolved.options }, + recoverable: true, + }; + } + return this.game.lookEntity(resolved.entity); + } + + private resolveExamineTarget(rawTarget: string | null): GameActionOutcome { + if (!rawTarget) { + return { + status: 'needs_clarification', + code: 'missing_examine_target', + message: this.game.text('parser.examine_prompt'), + recoverable: true, + }; + } + + const resolved = this.resolveSceneEntityTarget(rawTarget, { + includeTakeablesOnly: false, + includeInventory: true, + clarificationKey: 'parser.examine_which_one', + }); + if (resolved.status === 'escalate') { + return { status: 'escalate', code: resolved.code, recoverable: true }; + } + if (resolved.status === 'not_found') { + return { + status: 'failed', + code: 'entity_not_found', + message: this.game.text('parser.look_not_found', { target: rawTarget }), + data: { target: rawTarget }, + recoverable: true, + }; + } + if (resolved.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_examine_target', + message: resolved.message, + data: { target: rawTarget, options: resolved.options }, + recoverable: true, + }; + } + return this.game.examineEntity(resolved.entity); + } + + private resolveTakeTarget(rawTarget: string | null): GameActionOutcome { + if (!rawTarget) { + return { + status: 'needs_clarification', + code: 'missing_take_target', + message: this.game.text('parser.take_prompt'), + recoverable: true, + }; + } + const resolved = this.resolveSceneEntityTarget(rawTarget, { + includeTakeablesOnly: true, + includeInventory: false, + clarificationKey: 'parser.take_which_one', + }); + const broadResolved = + resolved.status === 'not_found' + ? this.resolveSceneEntityTarget(rawTarget, { + includeTakeablesOnly: false, + includeInventory: false, + clarificationKey: 'parser.take_which_one', + }) + : null; + + if (resolved.status === 'escalate' || broadResolved?.status === 'escalate') { + return { + status: 'escalate', + code: + resolved.status === 'escalate' + ? resolved.code + : broadResolved?.status === 'escalate' + ? broadResolved.code + : 'take_target_missing_title', + recoverable: true, + }; + } + if (resolved.status === 'not_found') { + if (broadResolved?.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_take_target', + message: broadResolved.message, + data: { target: rawTarget, options: broadResolved.options }, + recoverable: true, + }; + } + if (broadResolved?.status === 'found') { + return { + status: 'failed', + code: 'not_takeable', + message: this.game.text('parser.take_cannot'), + data: { entityId: broadResolved.entity.name }, + recoverable: true, + }; + } + return { + status: 'failed', + code: 'entity_not_found', + message: this.game.text('parser.look_not_found', { target: rawTarget }), + data: { target: rawTarget }, + recoverable: true, + }; + } + if (resolved.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_take_target', + message: resolved.message, + data: { target: rawTarget, options: resolved.options }, + recoverable: true, + }; + } + return this.game.takeEntity(resolved.entity); + } + + private resolveGoToTarget(rawTarget: string | null): GameActionOutcome { + if (!rawTarget) { + return { + status: 'needs_clarification', + code: 'missing_destination', + message: this.game.text('parser.go_to_prompt'), + recoverable: true, + }; + } + + const sceneMatch = this.resolveSceneTarget(rawTarget); + if (sceneMatch) { + return this.game.goToScene(sceneMatch.id); + } + + const resolved = this.resolveSceneEntityTarget(rawTarget, { + includeTakeablesOnly: false, + includeInventory: false, + clarificationKey: 'parser.go_to_which_one', + }); + if (resolved.status === 'escalate') { + return { status: 'escalate', code: resolved.code, recoverable: true }; + } + if (resolved.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_destination', + message: resolved.message, + data: { target: rawTarget, options: resolved.options }, + recoverable: true, + }; + } + if (resolved.status === 'found') { + return this.game.goToEntity(resolved.entity); + } + return { + status: 'failed', + code: 'destination_not_found', + message: this.game.text('parser.go_to_not_found', { target: rawTarget }), + data: { target: rawTarget }, + recoverable: true, + }; + } + private buildResponse( resultJson: string, actionJson: string, contextJson: string ): ParserResponse { const result = JSON.parse(resultJson) as ParserResult; + const nlpDebug = this.nlpCascade.getLastDebugInfo(); const peekMessages = this.game.console?.parserPeekEnabled ? [ `[Parser peek] context=${contextJson}`, `[Parser peek] actions=${actionJson}`, `[Parser peek] result=${resultJson}`, + ...(nlpDebug ? [`[Parser peek] nlp=${JSON.stringify(nlpDebug)}`] : []), ] : undefined; @@ -434,18 +770,24 @@ export class Parser { } } - private extractPendingIntent(actionJson: string): 'look' | 'take' | 'goTo' { + private extractPendingIntent(actionJson: string): 'look' | 'examine' | 'take' | 'goTo' { try { const envelope = JSON.parse(actionJson) as ParserActionEnvelope; const firstAction = envelope.actions[0]; if ( firstAction && - firstAction.type === 'callGameMethod' && - (firstAction.method === 'look' || - firstAction.method === 'take' || - firstAction.method === 'goTo') + (firstAction.type === 'lookTarget' || + firstAction.type === 'examineTarget' || + firstAction.type === 'takeTarget' || + firstAction.type === 'goToTarget') ) { - return firstAction.method; + return firstAction.type === 'lookTarget' + ? 'look' + : firstAction.type === 'examineTarget' + ? 'examine' + : firstAction.type === 'takeTarget' + ? 'take' + : 'goTo'; } } catch { // Fall through to default. diff --git a/src/mechanics/nlp/normalizeTarget.ts b/src/mechanics/nlp/normalizeTarget.ts new file mode 100644 index 0000000..a67bc63 --- /dev/null +++ b/src/mechanics/nlp/normalizeTarget.ts @@ -0,0 +1,59 @@ +const LEADING_POLITE_PATTERNS = [ + /^(please)\s+/i, + /^(could you)\s+/i, + /^(can you)\s+/i, + /^(would you)\s+/i, + /^(i want to)\s+/i, + /^(i would like to)\s+/i, + /^(i'd like to)\s+/i, +]; + +function stripLeadingPhrases(input: string): string { + let value = input.trim(); + for (const pattern of LEADING_POLITE_PATTERNS) { + value = value.replace(pattern, ''); + } + return value.trim(); +} + +function stripLeadingArticle(input: string): string { + return input.replace(/^(the|a|an|my)\s+/i, '').trim(); +} + +export function normalizeTargetForIntent(input: string, intent: string): string | null { + let value = stripLeadingPhrases(input) + .replace(/[?.!,]+$/g, '') + .trim(); + if (!value) return null; + + switch (intent) { + case 'look': + case 'examine': + value = value + .replace(/^(look|examine|inspect|check|x)(\s+at)?\s+/i, '') + .replace(/^(tell me about)\s+/i, '') + .replace(/^(what is(?:\s+that)?)\s+/i, '') + .replace(/^(describe)\s+/i, '') + .trim(); + break; + case 'take': + value = value + .replace(/^(take|get|grab)\s+/i, '') + .replace(/^(pick up)\s+/i, '') + .trim(); + break; + case 'goTo': + value = value + .replace(/^(go|walk|move|head|travel)(\s+(over\s+)?to)?\s+/i, '') + .replace(/^(go|walk|move|head|travel)\s+/i, '') + .trim(); + break; + case 'showInventory': + return null; + default: + break; + } + + value = stripLeadingArticle(value); + return value || null; +} diff --git a/src/mechanics/nlp/trainingData.ts b/src/mechanics/nlp/trainingData.ts new file mode 100644 index 0000000..89e301c --- /dev/null +++ b/src/mechanics/nlp/trainingData.ts @@ -0,0 +1,93 @@ +export const NLP_TRAINING_DATA: Record<string, string[]> = { + look: [ + 'look', + 'look chair', + 'look logo', + 'look lamp', + 'look key', + 'look door', + 'examine chair', + 'inspect logo', + 'look at the lamp', + 'look at the chair', + 'look at the logo', + 'examine the lamp', + 'inspect the desk', + 'check the card', + 'tell me about the door', + 'what is that lamp', + 'what is the chair', + 'what is the logo', + 'describe the office door', + 'look over the note', + ], + examine: [ + 'examine', + 'examine chair', + 'examine logo', + 'inspect chair', + 'inspect the logo', + 'check the card', + 'check chair', + 'inspect the note', + 'examine the lamp', + 'inspect the desk', + 'check the key', + 'look closely at the logo', + 'take a closer look at the chair', + ], + take: [ + 'take', + 'take key', + 'take card', + 'take note', + 'get key', + 'pickup key', + 'take the key', + 'take the chair', + 'pick up the key', + 'pick up the logo', + 'grab the card', + 'take the id card', + 'pick up linda card', + 'grab the note', + 'i want to take the key', + 'please pick up the card', + ], + goTo: [ + 'go', + 'go office', + 'go logo', + 'walk office', + 'walk logo', + 'move office', + 'move logo', + 'go to the office', + 'go to office', + 'walk to the office', + 'walk to the logo', + 'move to the lamp', + 'move to logo', + 'head to the door', + 'go over to the desk', + 'go over to the office', + 'go over to the logo', + 'travel to the office', + 'walk over to the card reader', + 'move over to the console', + ], + showInventory: [ + 'inventory', + 'inv', + 'items', + 'my items', + 'show inventory', + 'what do i have', + 'what am i carrying', + 'check my inventory', + 'show me my inventory', + 'list my items', + 'what items do i have', + 'open inventory', + ], +}; diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts new file mode 100644 index 0000000..b050c17 --- /dev/null +++ b/src/mechanics/parserTypes.ts @@ -0,0 +1,105 @@ +import type { GameActionOutcome } from '../core/GameActionTypes'; + +export type ParserEntityContext = { + id: string; + type: string; + title: string | null; + description: string | null; + details: string | null; + interactions: string[]; +}; + +export type ParserInventoryItemContext = { + id: string; + title: string | null; + description: string | null; + details: string | null; +}; + +export type ParserPendingState = { + intent: 'look' | 'examine' | 'take' | 'goTo'; + question: string; + originalInput: string; +}; + +export type ParserContext = { + rawInput: string; + normalizedInput: string; + scene: { + id: string; + name: string; + title: string | null; + description: string | null; + } | null; + entities: ParserEntityContext[]; + inventory: ParserInventoryItemContext[]; + pending: ParserPendingState | null; +}; + +export type ParserToolAction = + | { + type: 'lookScene'; + } + | { + type: 'lookTarget'; + target: string; + } + | { + type: 'examineTarget'; + target: string | null; + } + | { + type: 'takeTarget'; + target: string | null; + } + | { + type: 'showInventory'; + } + | { + type: 'goToTarget'; + target: string | null; + } + | { + type: 'handoff'; + reason: string; + verb: string; + noun: string; + rawInput: string; + }; + +export type ParserActionEnvelope = { + stage: 'regex-v1' | 'pending-resolution' | 'nlp-v2'; + actions: ParserToolAction[]; + debug: { + rawInput: string; + normalizedInput: string; + verb: string; + noun: string; + pendingIntent?: string; + intent?: string; + score?: number; + source?: 'nlpjs'; + }; +}; + +export type ParserResult = + | { + type: 'outcomes'; + handled: boolean; + outcomes: GameActionOutcome[]; + actionsExecuted: string[]; + } + | { + type: 'handoff'; + handled: false; + outcomes: GameActionOutcome[]; + actionsExecuted: string[]; + reason: string; + debug: Record<string, unknown>; + }; + +export type ParserResponse = { + playerMessage?: string; + debugMessages?: string[]; + nextPendingState?: ParserPendingState | null; +}; diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 0d698b8..e7591c0 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -158,11 +158,9 @@ export class Scene { const normalized = name.toUpperCase(); return this.entities.find((e) => { if (e.disabled) return false; - const resolvedTitle = this.game.textAssets.getResolvedObjectField(e, 'title'); return ( e.name.toUpperCase() === normalized || - (e.customName && e.customName.toUpperCase() === normalized) || - (resolvedTitle && resolvedTitle.toUpperCase() === normalized) + (e.customName && e.customName.toUpperCase() === normalized) ); }); } diff --git a/src/scene/SceneManager.ts b/src/scene/SceneManager.ts index d70842c..172e908 100644 --- a/src/scene/SceneManager.ts +++ b/src/scene/SceneManager.ts @@ -250,7 +250,7 @@ export class SceneManager { weight += 24; break; case 'Quad': - weight += 18 + (((entity as any).vertices?.length || 0) * 3); + weight += 18 + ((entity as any).vertices?.length || 0) * 3; break; default: weight += 12; @@ -276,25 +276,6 @@ export class SceneManager { return weight; } - findSceneDescriptorByTarget(target: string): SceneDescriptor | null { - const normalized = String(target || '') - .trim() - .toUpperCase(); - if (!normalized) return null; - - for (const descriptor of this.sceneRegistry.values()) { - if ( - descriptor.id.toUpperCase() === normalized || - descriptor.name.toUpperCase() === normalized || - (!!descriptor.title && descriptor.title.toUpperCase() === normalized) - ) { - return descriptor; - } - } - - return null; - } - async refreshSceneRegistry(): Promise<void> { try { const files = await this.listSceneFiles('public/scenes'); @@ -350,14 +331,23 @@ export class SceneManager { const entry = this.sceneCacheMeta.get(scene.id); const stats = this.getSceneCacheStats(); const imageStats = this.game.assets.getImageCacheStats(); - const profile = this.buildMemoryProfile(scene, entry, stats.estimatedMemory, heapBytes, null, imageStats.estimatedBytes); + const profile = this.buildMemoryProfile( + scene, + entry, + stats.estimatedMemory, + heapBytes, + null, + imageStats.estimatedBytes + ); console.table([this.formatMemoryProfileForConsole(profile)]); return profile; } async profileScenes(sceneIds: string[]): Promise<SceneMemoryProfile[]> { - const requested = [...new Set((sceneIds || []).map((id) => String(id || '').trim()).filter(Boolean))]; + const requested = [ + ...new Set((sceneIds || []).map((id) => String(id || '').trim()).filter(Boolean)), + ]; if (requested.length === 0) { console.warn('[SceneManager] profileScenes(): no scene ids provided'); return []; @@ -489,7 +479,8 @@ export class SceneManager { this.game.assets.markSceneSpriteRefs(scene.id, spriteKeys); const textureBytes = existing?.textureBytes || 0; - const textureWeightUnits = existing?.textureWeightUnits || this.textureBytesToUnits(textureBytes); + const textureWeightUnits = + existing?.textureWeightUnits || this.textureBytesToUnits(textureBytes); this.sceneCacheMeta.set(scene.id, { scene, @@ -525,7 +516,10 @@ export class SceneManager { const entry = this.sceneCacheMeta.get(this.currentScene.id); if (!entry) return; entry.graphWeightUnits = this.estimateSceneGraphWeight(this.currentScene); - entry.totalWeightUnits = this.computeTotalWeightUnits(entry.graphWeightUnits, entry.textureBytes); + entry.totalWeightUnits = this.computeTotalWeightUnits( + entry.graphWeightUnits, + entry.textureBytes + ); } private async refreshSceneFootprint(sceneId: string): Promise<void> { @@ -540,7 +534,10 @@ export class SceneManager { entry.graphWeightUnits = this.estimateSceneGraphWeight(scene); entry.textureBytes = textureEstimate.bytes; entry.textureWeightUnits = this.textureBytesToUnits(textureEstimate.bytes); - entry.totalWeightUnits = this.computeTotalWeightUnits(entry.graphWeightUnits, entry.textureBytes); + entry.totalWeightUnits = this.computeTotalWeightUnits( + entry.graphWeightUnits, + entry.textureBytes + ); const descriptor = this.sceneRegistry.get(sceneId); if (descriptor) { @@ -645,12 +642,15 @@ export class SceneManager { private detectDeviceMemoryProfile(): DeviceMemoryProfile { const navigatorLike = - typeof navigator !== 'undefined' ? (navigator as Navigator & { deviceMemory?: number }) : null; + typeof navigator !== 'undefined' + ? (navigator as Navigator & { deviceMemory?: number }) + : null; const deviceMemoryRaw = navigatorLike && typeof navigatorLike.deviceMemory === 'number' ? navigatorLike.deviceMemory : null; - const deviceMemoryGb = deviceMemoryRaw && Number.isFinite(deviceMemoryRaw) ? deviceMemoryRaw : null; + const deviceMemoryGb = + deviceMemoryRaw && Number.isFinite(deviceMemoryRaw) ? deviceMemoryRaw : null; if (deviceMemoryGb === null) { return { @@ -715,7 +715,9 @@ export class SceneManager { spriteNames.add(entity.spriteName); } - const animSets = (entity as any).animSets as Record<string, Record<string, string | null>> | undefined; + const animSets = (entity as any).animSets as + | Record<string, Record<string, string | null>> + | undefined; if (!animSets) continue; for (const set of Object.values(animSets)) { @@ -732,10 +734,7 @@ export class SceneManager { } private syncAssetCacheState(): void { - this.game.assets.syncSceneCacheState( - this.currentScene?.id || null, - [...this.scenes.keys()] - ); + this.game.assets.syncSceneCacheState(this.currentScene?.id || null, [...this.scenes.keys()]); } private computeTotalWeightUnits(graphWeightUnits: number, textureBytes: number): number { @@ -757,9 +756,13 @@ export class SceneManager { deltaBytes: number | null, imageCacheBytes: number ): SceneMemoryProfile { - const graphWeightUnits = Math.round(entry?.graphWeightUnits ?? this.estimateSceneGraphWeight(scene)); + const graphWeightUnits = Math.round( + entry?.graphWeightUnits ?? this.estimateSceneGraphWeight(scene) + ); const textureBytes = entry?.textureBytes ?? 0; - const totalWeightUnits = Math.round(entry?.totalWeightUnits ?? this.computeTotalWeightUnits(graphWeightUnits, textureBytes)); + const totalWeightUnits = Math.round( + entry?.totalWeightUnits ?? this.computeTotalWeightUnits(graphWeightUnits, textureBytes) + ); const bytesPerUnit = heapBytes !== null && totalWeightUnits > 0 ? heapBytes / totalWeightUnits : null; @@ -768,7 +771,9 @@ export class SceneManager { sceneName: scene.name, weightUnits: totalWeightUnits, graphWeightUnits, - textureWeightUnits: Math.round(entry?.textureWeightUnits ?? this.textureBytesToUnits(textureBytes)), + textureWeightUnits: Math.round( + entry?.textureWeightUnits ?? this.textureBytesToUnits(textureBytes) + ), loadedScenes: this.scenes.size, estimatedCacheUnits, jsHeapUsedBytes: heapBytes, @@ -801,7 +806,9 @@ export class SceneManager { return Math.round((bytes / (1024 * 1024)) * 100) / 100; } - private formatMemoryProfileForConsole(profile: SceneMemoryProfile): Record<string, string | number | null> { + private formatMemoryProfileForConsole( + profile: SceneMemoryProfile + ): Record<string, string | number | null> { return { sceneId: profile.sceneId, sceneName: profile.sceneName, @@ -816,7 +823,9 @@ export class SceneManager { imageCacheMb: profile.imageCacheMb, textureCount: profile.textureCount, kbPerUnit: - profile.bytesPerUnit !== null ? Math.round((profile.bytesPerUnit / 1024) * 100) / 100 : null, + profile.bytesPerUnit !== null + ? Math.round((profile.bytesPerUnit / 1024) * 100) / 100 + : null, }; } } diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index 52bfc7f..1aab592 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -99,6 +99,32 @@ export class ComponentSystem { // Called when trying to TAKE an item // Returns string (error message) or null (success) + static getInteractionDistanceError( + entity: SceneObject, + player: Actor | null, + options?: { ignoreDistance?: boolean } + ): string | null { + const game = (entity as any).game as IGame | undefined; + if (!player || options?.ignoreDistance) return null; + + const e = entity as unknown as { x: number; y: number }; + const dist = Math.hypot(player.x - e.x, player.y - e.y); + const allowedDist = (player.width || 30) * 4; + + if (dist > allowedDist) { + const title = this.getPlayerFacingTitle(game, entity); + if (title) { + return ( + game?.text('engine.too_far_from_entity', { target: title }) || + `You are too far away from the ${title}.` + ); + } + return game?.text('engine.too_far_generic') || 'You are too far away.'; + } + + return null; + } + static canTakeItem(entity: SceneObject, player: Actor | null): string | null { const game = (entity as any).game as IGame | undefined; if (!entity.components) return game?.text('parser.take_cannot') || 'You cannot take that.'; @@ -111,23 +137,10 @@ export class ComponentSystem { if (!itemComp) return null; // Not an item component, let caller handle legacy or fail - // Check Proximity - if (!itemComp.ignoreDistance && player) { - const e = entity as unknown as { x: number; y: number }; - const dist = Math.hypot(player.x - e.x, player.y - e.y); - const allowedDist = (player.width || 30) * 4; // Tolerance - - if (dist > allowedDist) { - const title = this.getPlayerFacingTitle(game, entity); - if (title) { - return ( - game?.text('engine.too_far_from_entity', { target: title }) || - `You are too far away from the ${title}.` - ); - } - return game?.text('engine.too_far_generic') || 'You are too far away.'; - } - } + const distanceError = this.getInteractionDistanceError(entity, player, { + ignoreDistance: !!itemComp.ignoreDistance, + }); + if (distanceError) return distanceError; return null; // OK } diff --git a/src/types/nlpjs.d.ts b/src/types/nlpjs.d.ts new file mode 100644 index 0000000..07ed073 --- /dev/null +++ b/src/types/nlpjs.d.ts @@ -0,0 +1,21 @@ +declare module '@nlpjs/core' { + export class Container { + constructor(hasPreffix?: boolean); + use(item: any, name?: string, isSingleton?: boolean, onlyIfNotExists?: boolean): string; + } +} + +declare module '@nlpjs/nlp' { + export class Nlp { + constructor(settings?: any, container?: any); + addDocument(locale: string, utterance: string, intent: string): void; + train(): Promise<void>; + process(locale: string, utterance: string): Promise<any>; + } +} + +declare module '@nlpjs/lang-en-min' { + export class LangEn { + register(container: any): void; + } +} From 9da55757e59eac761bb4cd599282d13c23ed5ee9 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 16 Mar 2026 02:24:09 +0200 Subject: [PATCH 17/75] Docs: add parser architecture document Add Parser.md describing the Scanline parser as mediator, orchestrator, and Game Master for Blue Signal. Document the separation between Player Input and Parser Context, the role of Context Builder and future Scope Builder, the sequential cascade model (Stage 1.1 regex, Stage 1.2 NLP, future Stage 2 LLM), and the central role of Parser Core. Include Mermaid diagrams for the high-level parser flow, Stage 1 flow, Core flow, clarification loop, and future stage-2 plan execution. Document why Game/runtime must stay language-agnostic, how resolved targets are passed into Game API, and how Core can escalate both before and after API calls. Also capture the first draft of the constrained parser DSL for future LLM planning, plus parser language-asset principles and the roadmap toward richer scope, relations, and world understanding. --- Parser.md | 916 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 916 insertions(+) create mode 100644 Parser.md diff --git a/Parser.md b/Parser.md new file mode 100644 index 0000000..3115690 --- /dev/null +++ b/Parser.md @@ -0,0 +1,916 @@ +# Parser + +## Summary + +`Parser` в `Blue Signal` — это отдельный оркестратор между игроком и движком `Scanline`. + +Он не является простым обработчиком команд. Его роль ближе к **Game Master**: +- принять ввод игрока; +- увидеть текущую картину мира через `context`; +- выбрать подходящий каскад распознавания; +- разрешить цели команды внутри собственной модели мира; +- вызвать допустимые игровые API; +- проанализировать outcomes; +- либо ответить игроку, +- либо задать уточнение, +- либо передать кейс более сильному каскаду, +- либо сделать следующую итерацию исполнения. + +Главный принцип: +- **вся языковая интерпретация живёт внутри parser-а**; +- `Game` и runtime не понимают язык игрока и не резолвят текстовые цели; +- `Game` только исполняет операции над уже понятными сущностями и возвращает structured outcomes. + +--- + +## Design Goals + +Parser должен: +- быть единственной точкой интерпретации пользовательского текста; +- иметь собственную "картину мира", пригодную для текстового анализа; +- использовать движок как набор инструментов, а не как место принятия языковых решений; +- поддерживать несколько каскадов понимания ввода; +- уметь вести короткий диалог с игроком внутри одной незавершённой команды; +- быть локализуемым без переписывания логики; +- со временем уметь переходить от простого command parser-а к полноценному orchestrator/GM. + +--- + +## High-Level Architecture + +Ключевой момент: +- `Player Input` и `Parser Context` — это **две отдельные сущности**; +- `Context Builder` работает только от состояния игры; +- ввод игрока проходит каскады **последовательно**, а не параллельно. + +```mermaid +flowchart TD + GS[Game state] + CB[Context builder] + CTX[Parser context] + U[Input] + L1[Stage1 Regex] + L2[Stage1 NLP] + S2[Stage2 LLM] + CORE[Parser Core] + API[Game API] + Q[Question] + R[Response] + + GS --> CB + CB --> CTX + + U --> L1 + CTX --> L1 + + L1 --> CORE + L1 --> L2 + + CTX --> L2 + L2 --> CORE + L2 --> S2 + + CTX --> S2 + S2 --> CORE + + CORE --> S2 + CORE --> Q + Q --> U + + CORE --> API + API --> CORE + + CORE --> R +``` + +Что важно в этой схеме: +- ввод игрока всегда сначала идёт в `Stage 1.1`; +- `Stage 1.2` включается только по handoff от `Stage 1.1`; +- следующий каскад включается только после провала всего первого каскада; +- `Core` получает два разных типа входа: + - распознанные данные от каскадов; + - outcomes от `Game API`. + +--- + +## Layers + +### 1. Raw Game State + +Это реальное состояние runtime: +- текущая сцена; +- объекты сцены; +- инвентарь игрока; +- активная subscene; +- registry сцен; +- состояния объектов и компонентов; +- player actor. + +Это не parser-слой. Это слой движка. + +### 2. Context Builder + +`Context Builder` не использует ввод игрока. + +Он получает только состояние игры и строит `Parser Context`: упрощённый снимок мира, пригодный для parser-а. + +Текущий context включает: +- `rawInput` и `normalizedInput` как metadata текущего цикла parser-а; +- текущую сцену (`id`, `name`, `title`, `description`); +- список текстово значимых объектов сцены; +- инвентарь игрока; +- `pending state`, если parser уже ждёт уточнение. + +Важно: +- `Context Builder` не интерпретирует пользовательский ввод; +- он не выбирает intent; +- он не определяет target; +- он лишь даёт parser-у картину мира. + +Пример context: + +```json +{ + "rawInput": "look logo", + "normalizedInput": "LOOK LOGO", + "scene": { + "id": "test_room", + "name": "New Scene", + "title": "New Scene", + "description": "You are in New Scene." + }, + "entities": [ + { + "id": "logo_1", + "type": "Entity", + "title": "logo", + "description": "You see Scanline Engine logo.", + "details": null, + "interactions": [] + } + ], + "inventory": [], + "pending": null +} +``` + +### 3. Scope Builder + +`Scope` — это не отдельный каскад, а структурированная часть context. + +То есть: +- `context` = всё, что parser знает о мире; +- `scope` = какая часть этого мира доступна для конкретного класса действий. + +Примеры: +- `LOOK` использует видимые объекты сцены и инвентарь; +- `TAKE` использует только берущиеся объекты сцены; +- `EXAMINE` использует инвентарь, объекты активной subscene и объекты в пределах допустимой дистанции; +- `GO TO` использует сценовые цели и достижимые сценовые объекты. + +Планируемая модель scope: + +```ts +type ParserScope = { + visible: Entity[]; + held: Entity[]; + takable: Entity[]; + reachable: Entity[]; + examinable: Entity[]; + subscene: Entity[]; + sceneTargets: SceneDescriptor[]; +}; +``` + +Ключевой принцип: +- scope должен быть общим для всех каскадов; +- каскады различаются тем, **как они понимают ввод**; +- они не должны различаться тем, **как они понимают мир**. + +--- + +## Cascades + +## Stage 1 + +Stage 1 на самом деле состоит из двух внутренних уровней. + +### Stage 1.1 — Regex Parser + +Это быстрый, детерминированный, дешёвый слой. + +Он: +- пытается распознать canonical-команду; +- выделяет базовый `intent`; +- строит preliminary action candidate; +- нормализует или очищает `target phrase`. + +Подходит для: +- `LOOK` +- `LOOK LOGO` +- `EXAMINE BOOMBOX` +- `TAKE KEY` +- `INV` +- `GO TO OFFICE` + +### Stage 1.2 — NLP Layer + +Этот слой включается только если `Stage 1.1` не справился. + +Он: +- определяет `intent` по более свободному вводу; +- оценивает confidence; +- очищает `target phrase`; +- строит тот же action candidate, что и `Stage 1.1`. + +Он полезен для: +- `look at the lamp` +- `pick up the key` +- `what do i have?` +- `go over to the office` + +Важно: +- `Stage 1.2` не занимается world reasoning; +- не должен сам принимать игровые решения; +- не должен сам резолвить сложные semantic target-и; +- не генерирует player-facing ответы. + +### Детальная схема Stage 1 + +```mermaid +flowchart TD + U[Player Input] + CTX[Parser Context] + + U --> R1[Stage 1.1 Regex Parser] + CTX --> R1 + + R1 --> R1A{Intent recognized?} + R1A -->|yes| R1B[Build preliminary action candidate] + R1B --> R1C[Extract or normalize target phrase] + R1C --> CORE[Parser Core] + + R1A -->|no| N1[Stage 1.2 NLP Layer] + CTX --> N1 + + N1 --> N1A[Classify intent] + N1A --> N1B{Confidence high enough?} + N1B -->|yes| N1C[Extract or normalize target phrase] + N1C --> N1D[Build preliminary action candidate] + N1D --> CORE + + N1B -->|no| H[Handoff to next cascade] +``` + +Что важно: +- `intent` определяется внутри уровня каскада; +- `target phrase` выделяется и очищается там же; +- в `Core` приходит уже не сырой ввод, а первичная интерпретация команды. + +## Stage 2 — LLM / Future + +Следующий каскад — старший, LLM-based. + +Его роль намного шире: +- понимать сложные смысловые соответствия; +- строить многошаговые планы; +- генерировать player-facing тексты, когда lower layers не справились; +- задавать сложные уточнения; +- работать как настоящий Game Master. + +Например: +- `look logotype` -> понять, что речь о `logo`; +- `go to office` -> построить цепочку действий; +- `examine the thing under the desk` -> понять relation и target. + +В отличие от первых двух уровней, stage2 не обязан возвращать только `intent`. + +--- + +## Parser Core + +`Parser Core` — центральный оркестратор всей системы. + +Он получает: +- action candidate от активного каскада; +- outcomes от `Game API` по отдельному каналу. + +Именно `Core` принимает решения: +- достаточно ли данных для обработки команды; +- нужно ли звать следующий каскад; +- нужно ли задать clarification; +- какой API-блок вызвать; +- нужно ли сделать следующую итерацию; +- какой итоговый ответ показать игроку. + +### Детальная схема Core + +```mermaid +flowchart TD + IN[Action candidate] + OUT[API outcomes] + CORE[Parser Core] + RES[Resolve and validate] + DEC[Decision] + PLAN[Build API block] + API[Game API] + POST[Analyze outcomes] + CLAR[Clarification] + ASK[Missing argument] + ESC[Escalate] + LOOP[Next API step] + RESP[Final response] + Q[Question] + M[Message] + + IN --> CORE + OUT --> CORE + + CORE --> RES + RES --> DEC + + DEC --> PLAN + DEC --> CLAR + DEC --> ASK + DEC --> ESC + + PLAN --> API + API --> OUT + + CORE --> POST + POST --> RESP + POST --> CLAR + POST --> ESC + POST --> LOOP + + LOOP --> API + CLAR --> Q + ASK --> Q + RESP --> M +``` + +Самое важное утверждение: +- `Core` может эскалировать **до API**, если уже видит, что intent/target/данных недостаточно; +- `Core` может эскалировать **после API**, если полученных outcomes недостаточно для завершения сценария. + +Именно это делает parser не просто parser-ом, а оркестратором. + +--- + +## Action Flow + +### Step 1. Input arrives + +Игрок вводит текст. + +### Step 2. Pending clarification is checked + +Parser сначала проверяет: +- не является ли ввод продолжением уже незавершённой команды; +- или это новая команда. + +### Step 3. Context is built + +`Context Builder` строит `Parser Context` из состояния игры. + +### Step 4. Stage 1 runs sequentially + +- сначала `Stage 1.1`; +- если не справился, `Stage 1.2`; +- если весь первый каскад не справился, handoff на следующий каскад. + +### Step 5. Core resolves, validates, and decides + +`Core` получает action candidate, применяет context/scope, и решает: +- можно ли продолжать; +- нужен ли API call block; +- нужен ли clarification; +- нужна ли эскалация выше. + +### Step 6. API block executes + +Если `Core` решил исполнять, он формирует блок API вызовов. + +### Step 7. Outcomes return to Core + +`Game API` возвращает structured outcomes. + +### Step 8. Core either completes or iterates + +`Core` может: +- завершить ответ; +- задать уточнение; +- передать кейс следующему каскаду; +- построить следующий API block и продолжить цикл. + +--- + +## Game API Contract + +`Game` — это tool layer для parser-а. + +Он не занимается языком игрока. + +Текущий semantic API: +- `lookScene(scene?)` +- `lookEntity(entity)` +- `examineEntity(entity)` +- `takeEntity(entity)` +- `showInventory()` +- `goToScene(sceneId)` +- `goToEntity(entity)` + +Принцип: +- parser передаёт в `Game` уже resolved цели; +- `Game` не подбирает объекты по тексту; +- `Game` не делает disambiguation; +- `Game` не разбирает user input. + +### Что делает Game + +`Game` отвечает за: +- реальные операции в мире; +- валидацию игровых ограничений; +- structured outcomes. + +Например: +- `takeEntity(entity)` проверяет дистанцию и возможность взять предмет; +- `examineEntity(entity)` проверяет доступность examine; +- `goToEntity(entity)` запускает movement; +- `lookEntity(entity)` возвращает краткое описание. + +То есть: +- parser отвечает за язык и выбор цели; +- `Game` отвечает за допустимость и исполнение операции. + +--- + +## Current Actions + +Текущие action types parser-а: +- `lookScene` +- `lookTarget` +- `examineTarget` +- `takeTarget` +- `showInventory` +- `goToTarget` +- `handoff` + +Нижние каскады сейчас обычно выдают именно такие действия. + +--- + +## Target Resolution + +### Current Resolution Model + +Сейчас target resolution уже принадлежит parser-у. + +Parser: +- ищет цели в собственной модели мира; +- использует только player-facing `title`, а не технические `id`; +- исключает `disabled` объекты сцены; +- поддерживает partial matching; +- поддерживает clarification при неоднозначности. + +### Inventory-aware resolution + +Инвентарь является частью доступного текстового мира для non-movement действий. + +Сейчас: +- `LOOK` может находить предметы в инвентаре; +- `EXAMINE` может находить предметы в инвентаре; +- `TAKE` и `GO TO` inventory не используют. + +### EXAMINE + +`EXAMINE` — отдельное действие, отличное от `LOOK`. + +- `LOOK` использует обычное краткое описание (`description`); +- `EXAMINE` использует расширенное описание (`details`). + +Если `details` отсутствует: +- lower layer не обязан это придумывать; +- `Game.examineEntity()` может вернуть `escalate`; +- старший каскад решит, что делать дальше. + +### Access rules for EXAMINE + +Игрок может examine объект, если он: +- лежит в инвентаре; +- находится в активной subscene; +- находится достаточно близко, по той же дистанции, что и `TAKE`. + +Это правило относится к игровому миру, а не к языку, поэтому применяется на стороне `Game.examineEntity()`. + +--- + +## Pending Clarification + +Parser может задавать вопросы, если ввода недостаточно. + +Примеры: +- `TAKE` -> `Take what?` +- `EXAMINE` -> `Examine what?` +- `GO TO` -> `Where do you want to go?` +- ambiguity -> `Which one do you mean ...?` + +Parser хранит `pendingState`: +- intent +- question +- originalInput + +Следующий ввод: +- либо трактуется как продолжение текущей команды; +- либо отменяет pending flow, если выглядит как новая команда. + +```mermaid +sequenceDiagram + participant P as Player + participant R as Parser + participant G as Game + + P->>R: TAKE + R->>G: takeTarget(null) + G-->>R: needs_clarification + R-->>P: Take what? + + P->>R: key + R->>R: resolve as continuation of TAKE + R->>G: takeEntity(key) + G-->>R: ok / failed + R-->>P: final response +``` + +--- + +## Stage 2 Output Model + +Первые два уровня parser-а по сути формируют пакет данных для одного и того же `Core`. + +То есть: +- `Stage 1.1` и `Stage 1.2` — это не два разных parser-а; +- это два разных способа превратить ввод игрока в данные для `Core`. + +Нижние уровни обычно возвращают: +- `intent` +- `target phrase` +- preliminary action candidate + +Но старший каскад должен уметь возвращать более богатые инструкции. + +--- + +## Stage 2 DSL (First Draft) + +Будущий старший каскад (LLM) должен уметь возвращать не только `intent`, но и richer instructions. + +Однако он не должен: +- напрямую вызывать `Game API`; +- исполнять произвольный код; +- писать свободный JS; +- обходить `Parser Core`. + +Поэтому нужен **ограниченный parser DSL**. + +### Общий смысл DSL + +LLM возвращает не код, а допустимый план шагов. + +`Core`: +- валидирует этот план; +- исполняет шаги по одному; +- собирает outcomes; +- при необходимости повторно зовёт старший каскад. + +### Богатые выходы старшего каскада + +Старший каскад должен уметь возвращать не только `intent`, но и: +- `plan` +- `clarification` +- `final_response` +- `handoff_up` + +То есть `Core` должен уметь принимать richer cascade outputs. + +### Первый вариант envelope + +```ts +type CascadeEnvelope = + | { + stage: 'llm-v3'; + output: { + kind: 'plan'; + actions: ParserPlannedAction[]; + }; + } + | { + stage: 'llm-v3'; + output: { + kind: 'clarification'; + question: string; + missing: string; + }; + } + | { + stage: 'llm-v3'; + output: { + kind: 'final_response'; + message: string; + }; + } + | { + stage: 'llm-v3'; + output: { + kind: 'handoff_up'; + reason: string; + }; + }; +``` + +### Первый вариант `ParserPlannedAction` + +Для первого DSL достаточно ограниченного набора шагов: + +```ts +type ParserPlannedAction = + | { type: 'resolveEntity'; source: 'visible' | 'held' | 'takable' | 'examinable' | 'reachable'; query: string; saveAs: string } + | { type: 'resolveScene'; query: string; saveAs: string } + | { type: 'checkInventoryContains'; query: string; saveAs?: string } + | { type: 'checkResolved'; ref: string } + | { type: 'checkState'; scope: 'scene' | 'entity'; ref?: string; key: string; expected?: string | number | boolean } + | { type: 'lookScene' } + | { type: 'lookEntity'; ref: string } + | { type: 'examineEntity'; ref: string } + | { type: 'takeEntity'; ref: string } + | { type: 'showInventory' } + | { type: 'goToScene'; ref: string } + | { type: 'goToEntity'; ref: string } + | { type: 'removeInventoryItem'; ref: string } + | { type: 'addInventoryItem'; ref: string } + | { type: 'askPlayer'; question: string; saveAs?: string } + | { type: 'showMessage'; text: string }; +``` + +### Почему DSL должен быть ограниченным + +Это важно для безопасности и устойчивости архитектуры. + +LLM не должна: +- писать произвольный код; +- обращаться к внутренностям runtime напрямую; +- вносить неконтролируемые side effects. + +Поэтому DSL должен быть: +- декларативным; +- ограниченным; +- валидируемым `Core`-ом; +- исполняемым только через разрешённые игровые API. + +### Важный принцип DSL + +Первый вариант DSL лучше делать **линейным**, без встроенных `if/else` и циклов. + +То есть: +- старший каскад предлагает список шагов; +- `Core` исполняет их по одному; +- при неожиданном outcome `Core` останавливает план и снова зовёт старший каскад. + +Это проще и надёжнее, чем сразу делать полноценный mini-language. + +### Пример планового потока + +```mermaid +sequenceDiagram + participant P as Player + participant C as Parser Core + participant L as Stage 2 LLM + participant G as Game API + + P->>C: go to office + C->>L: unresolved complex command + context + L-->>C: plan(actions[]) + C->>G: execute action 1 + G-->>C: outcome 1 + C->>G: execute action 2 + G-->>C: outcome 2 + C->>L: outcomes summary / interrupted plan + L-->>C: clarification or new plan or final response + C-->>P: message or question +``` + +--- + +## Parser Debugging + +Для отладки используются служебные команды консоли: +- `#PEEK-ON` +- `#PEEK-OFF` +- `#STAGE1-ON` +- `#STAGE1-OFF` + +### PEEK + +При `#PEEK-ON` parser выводит: +- `context=...` +- `actions=...` +- `result=...` +- `nlp=...` при участии NLP-слоя + +### Stage toggles + +Можно отключить `Stage 1.1` и отправлять обработку сразу на следующий уровень первого каскада, чтобы изолированно тестировать NLP. + +--- + +## Language Assets + +Parser должен быть локализуемым без переписывания логики. + +### Что должно жить в text assets + +Всё language-specific: +- player-facing parser strings; +- clarification prompts; +- NLP training phrases; +- stage1 lexicon и normalisation vocabulary: + - verbs; + - aliases; + - articles; + - polite prefixes; + - prepositional phrases. + +### Что остаётся в коде + +- internal intent ids (`look`, `take`, `examine`, `goTo`); +- parser action ids (`lookTarget`, `takeTarget`, etc); +- `Game API` contracts; +- dev/system console commands вроде `#RUN`, `#PEEK`. + +### Предпочтительный формат language assets + +Language assets лучше хранить как **структурированные словари**, а не как сырые regex-строки. + +Пример: + +```json +{ + "verbs": { + "look": ["look"], + "examine": ["examine", "inspect", "check", "x"], + "take": ["take", "get", "grab", "pick up"], + "goTo": ["go", "walk", "move", "head", "travel"] + }, + "articles": ["the", "a", "an", "my"], + "politePrefixes": ["please", "could you", "can you", "would you", "i want to"], + "lookPrepositions": ["at"], + "goToPhrases": ["to", "over to"] +} +``` + +--- + +## Why Stage 1.2 Still Matters + +NLP-слой полезен, но не является фундаментом parser-а. + +Его роль: +- сделать ввод менее хрупким; +- поддержать более естественные формулировки; +- выдавать тот же internal package, что и regex layer. + +Фундамент parser-а — это: +- `Context Builder`; +- `Scope`; +- `Relations`; +- `Parser Core`. + +То есть: +- `Stage 1.1` = strict command parser; +- `Stage 1.2` = language comfort layer; +- `Stage 2` = semantic reasoning / Game Master layer. + +--- + +## Future: Relations and World Understanding + +Следующий важный шаг — richer world model. + +Например: +- `key under table` +- `note in drawer` +- `coin behind the picture` + +Тогда parser сможет различать: +- `look table` +- `look under table` +- `examine drawer` +- `look in drawer` + +Пример будущей relation model: + +```ts +type ParserRelation = { + type: 'on' | 'under' | 'in' | 'behind' | 'near'; + sourceId: string; + targetId: string; +}; +``` + +Именно richer context/scope/relations дадут parser-у настоящую "картину мира". + +--- + +## Technical Organization + +Текущие роли по коду: + +- `src/mechanics/Parser.ts` + - главный orchestrator parser-а + - context building + - stage orchestration + - target resolution + - pending clarification + - response building + +- `src/mechanics/NlpCascade.ts` + - Stage 1.2 (`NLP.js`) + - intent recognition + target cleanup + +- `src/core/Game.ts` + - semantic runtime tools + - world operations on resolved scene/entity targets + - access checks and structured outcomes + +- `src/core/TextAssetManager.ts` + - service text assets + - scene/object text resolution + +### Separation of concerns + +```mermaid +flowchart TD + A[Parser] -->|resolved target| B[Game API] + B -->|outcome| A + + A --> C[Language interpretation] + A --> D[Clarification] + A --> E[Target resolution] + A --> F[Plan orchestration] + + B --> G[World rules] + B --> H[Movement] + B --> I[Inventory mutation] + B --> J[Script execution] +``` + +Главное правило: +- parser понимает язык и управляет сценарием обработки; +- `Game` исполняет допустимые действия в игровом мире. + +--- + +## Current State vs Target State + +### Уже реализовано + +- parser-mediator v1; +- первый каскад с двумя уровнями (`regex` + `NLP.js`); +- shared action package model; +- parser-owned target resolution; +- inventory-aware `LOOK` / `EXAMINE`; +- отдельный `EXAMINE` + `details`; +- pending clarification; +- parser debug via `#PEEK`; +- stage toggles via console; +- Game API с resolved targets; +- базовая groundwork for future stage-2 DSL. + +### Дальше + +- explicit `Scope Builder` как отдельный subsystem; +- parser relations (`on`, `under`, `in`, `behind`, ...); +- parser language assets for lexicon/training; +- richer stage-2 (LLM) handoff; +- полноценный DSL execution loop; +- более сложные semantic actions (`use`, `open`, `talkTo`, ...); +- richer dialog/session state. + +--- + +## Core Principles Recap + +1. Parser — единственный слой, интерпретирующий язык игрока. +2. `Game` и runtime не должны парсить текст и резолвить текстовые цели. +3. `Context Builder` строит context только из состояния игры. +4. `Player Input` и `Parser Context` — отдельные входы parser-а. +5. Stage processing последовательный, а не параллельный. +6. Первый каскад имеет два внутренних уровня: `regex`, затем `NLP`. +7. Все каскады подают данные в один и тот же `Parser Core`. +8. `Core` может эскалировать как до API, так и после API. +9. `Core` — центр clarification, orchestration, iteration и final response. +10. Старший каскад должен уметь возвращать не только `intent`, но и richer instructions через constrained DSL. +11. Player-facing messages никогда не должны показывать технические `id`. +12. Всё language-specific должно жить в text assets. + +Эта архитектура делает parser фундаментом для постепенного перехода от классического IF-style command parser-а к полноценному Game Master и orchestrator. From 17ebc57b8dc2ad4e4246c807723ebd7d0cf87891 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 16 Mar 2026 03:25:41 +0200 Subject: [PATCH 18/75] Feature: move parser language data into assets Move stage1 lexicon and stage2 NLP training data out of code and into dedicated parser text assets so the parser language layer can evolve independently of parser logic. Add parser lexicon/training loading to TextAssetManager, switch stage1 and stage2 to consume the same language pack, and introduce parserLanguage helpers for asset-driven command matching and target normalization. Add console-side gameplay preprocessing so single-letter shorthands are expanded before gameplay parsing: I -> INVENTORY, X -> EXAMINE..., L -> LOOK.... Keep these aliases out of the parser lexicon itself. Add #STAGE2-ON and #STAGE2-OFF to control the NLP cascade independently from stage1. Update parser flow so stage2 handoff is skipped when disabled. Also update Parser.md to document the current language-asset layout and how stage1 and stage2 consume it. --- Parser.md | 33 +++- public/text/system/parser-lexicon.json | 50 ++++++ public/text/system/parser-training.json | 87 ++++++++++ src/components/UIOverlay.tsx | 4 +- src/core/Console.ts | 30 ++++ src/core/Game.ts | 1 + src/core/TextAssetManager.ts | 209 +++++++++++++++++++++++- src/mechanics/NlpCascade.ts | 58 ++++++- src/mechanics/Parser.ts | 84 ++++------ src/mechanics/nlp/normalizeTarget.ts | 59 ------- src/mechanics/nlp/trainingData.ts | 93 ----------- src/mechanics/parserLanguage.ts | 102 ++++++++++++ 12 files changed, 599 insertions(+), 211 deletions(-) create mode 100644 public/text/system/parser-lexicon.json create mode 100644 public/text/system/parser-training.json delete mode 100644 src/mechanics/nlp/normalizeTarget.ts delete mode 100644 src/mechanics/nlp/trainingData.ts create mode 100644 src/mechanics/parserLanguage.ts diff --git a/Parser.md b/Parser.md index 3115690..511fc72 100644 --- a/Parser.md +++ b/Parser.md @@ -740,6 +740,23 @@ Parser должен быть локализуемым без переписыв - polite prefixes; - prepositional phrases. +Текущая раскладка: +- `public/text/system/parser.json` — player-facing parser strings; +- `public/text/system/parser-lexicon.json` — stage1 lexicon и normalization vocabulary; +- `public/text/system/parser-training.json` — training phrases для NLP-слоя. + +Текущее применение: +- `Stage 1.1` использует `parser-lexicon.json` для: + - command aliases; + - command-word detection; + - target normalization; + - scene-look special words (`around`, `here`, `scene`); +- `Stage 1.2` использует: + - `parser-training.json` как training corpus для `NLP.js`; + - `parser-lexicon.json` для той же target normalization, что и у `Stage 1.1`. + +То есть stage1 и stage2 уже питаются от одного и того же language pack, а не от независимых словарей в коде. + ### Что остаётся в коде - internal intent ids (`look`, `take`, `examine`, `goTo`); @@ -755,16 +772,22 @@ Language assets лучше хранить как **структурирован ```json { - "verbs": { + "stage1Aliases": { "look": ["look"], "examine": ["examine", "inspect", "check", "x"], - "take": ["take", "get", "grab", "pick up"], - "goTo": ["go", "walk", "move", "head", "travel"] + "take": ["take", "get", "pickup", "pick up"], + "goTo": ["go", "walk", "move"], + "showInventory": ["inventory", "inv", "i"] + }, + "normalizationPrefixes": { + "look": ["look at", "tell me about", "what is", "describe"], + "examine": ["take a closer look at", "look closely at", "examine", "inspect", "check", "x"], + "take": ["pick up", "take", "get", "grab"], + "goTo": ["go over to", "go to", "walk to", "move to", "go", "walk", "move"] }, "articles": ["the", "a", "an", "my"], "politePrefixes": ["please", "could you", "can you", "would you", "i want to"], - "lookPrepositions": ["at"], - "goToPhrases": ["to", "over to"] + "lookSceneWords": ["around", "here", "scene"] } ``` diff --git a/public/text/system/parser-lexicon.json b/public/text/system/parser-lexicon.json new file mode 100644 index 0000000..5a1aaab --- /dev/null +++ b/public/text/system/parser-lexicon.json @@ -0,0 +1,50 @@ +{ + "stage1Aliases": { + "look": ["look"], + "examine": ["examine", "inspect", "check"], + "take": ["take", "get", "pickup", "pick up"], + "goTo": ["go", "walk", "move"], + "showInventory": ["inventory", "inv"] + }, + "normalizationPrefixes": { + "look": ["look at", "look", "tell me about", "what is that", "what is", "describe"], + "examine": [ + "take a closer look at", + "look closely at", + "examine at", + "examine", + "inspect at", + "inspect", + "check at", + "check" + ], + "take": ["pick up", "take", "get", "grab"], + "goTo": [ + "go over to", + "walk over to", + "move over to", + "go to", + "walk to", + "move to", + "go", + "walk", + "move", + "head to", + "travel to", + "head", + "travel" + ], + "showInventory": [] + }, + "politePrefixes": [ + "please", + "could you", + "can you", + "would you", + "i want to", + "i would like to", + "i'd like to" + ], + "articles": ["the", "a", "an", "my"], + "lookSceneWords": ["around", "here", "scene"] +} diff --git a/public/text/system/parser-training.json b/public/text/system/parser-training.json new file mode 100644 index 0000000..f504a0c --- /dev/null +++ b/public/text/system/parser-training.json @@ -0,0 +1,87 @@ +{ + "look": [ + "look", + "look chair", + "look logo", + "look lamp", + "look key", + "look door", + "look at the lamp", + "look at the chair", + "look at the logo", + "tell me about the door", + "what is that lamp", + "what is the chair", + "what is the logo", + "describe the office door", + "look over the note" + ], + "examine": [ + "examine", + "examine chair", + "examine logo", + "inspect chair", + "inspect the logo", + "check the card", + "check chair", + "inspect the note", + "examine the lamp", + "inspect the desk", + "check the key", + "look closely at the logo", + "take a closer look at the chair" + ], + "take": [ + "take", + "take key", + "take card", + "take note", + "get key", + "pickup key", + "pick up key", + "take the key", + "pick up the key", + "grab the card", + "take the id card", + "pick up linda card", + "grab the note", + "i want to take the key", + "please pick up the card" + ], + "goTo": [ + "go", + "go office", + "go logo", + "walk office", + "walk logo", + "move office", + "move logo", + "go to the office", + "go to office", + "walk to the office", + "walk to the logo", + "move to the lamp", + "move to logo", + "head to the door", + "go over to the desk", + "go over to the office", + "go over to the logo", + "travel to the office", + "walk over to the card reader", + "move over to the console" + ], + "showInventory": [ + "inventory", + "inv", + "items", + "my items", + "show inventory", + "what do i have", + "what am i carrying", + "check my inventory", + "show me my inventory", + "list my items", + "what items do i have", + "open inventory" + ] +} diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index 994df76..f2d2aaf 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -117,6 +117,8 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { if (firstWord.startsWith('#')) { game.console.processCommand(val); } else { + const preprocessed = game.console.preprocessGameplayInput(val); + // 1. Log Command to Buffer game.console.log(val, 'command'); @@ -124,7 +126,7 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { game.console.addHistory(val); // 3. Send to gameplay parser - void game.parser.parse(val); + void game.parser.parse(preprocessed); } e.currentTarget.value = ''; diff --git a/src/core/Console.ts b/src/core/Console.ts index d7865b1..37873b5 100644 --- a/src/core/Console.ts +++ b/src/core/Console.ts @@ -20,6 +20,7 @@ export class Console { isOpen: boolean = false; parserPeekEnabled: boolean = false; parserStage1Enabled: boolean = true; + parserStage2Enabled: boolean = true; // Configuration readonly MAX_BUFFER_LINES = 2000; // Approx 150KB of text depending on length @@ -55,6 +56,25 @@ export class Console { return this.commands.has(name.toUpperCase()); } + preprocessGameplayInput(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + + if (/^i$/i.test(trimmed)) { + return 'INVENTORY'; + } + + if (/^x(?:\s|$)/i.test(trimmed)) { + return trimmed.replace(/^x/i, 'EXAMINE'); + } + + if (/^l(?:\s|$)/i.test(trimmed)) { + return trimmed.replace(/^l/i, 'LOOK'); + } + + return trimmed; + } + processCommand(input: string): void { const trimmed = input.trim(); if (!trimmed) return; @@ -133,6 +153,16 @@ export class Console { this.parserStage1Enabled = true; this.log('Parser stage1 enabled.', 'info'); }); + + this.registerCommand('#STAGE2-OFF', () => { + this.parserStage2Enabled = false; + this.log('Parser stage2 disabled. NLP handoff is bypassed.', 'info'); + }); + + this.registerCommand('#STAGE2-ON', () => { + this.parserStage2Enabled = true; + this.log('Parser stage2 enabled.', 'info'); + }); } private runScript(id: string, args: string[]) { diff --git a/src/core/Game.ts b/src/core/Game.ts index a946255..0720bbf 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -157,6 +157,7 @@ export class Game implements IGame { this.audio = new AudioManager(); this.textAssets = new TextAssetManager(); void this.textAssets.preloadServiceAssets(); + void this.textAssets.preloadParserLanguageAssets(); this.sceneManager = new SceneManager(this); if (typeof window !== 'undefined') { const debugWindow = window as Window & { diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index ed0d38f..da2d428 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -1,5 +1,6 @@ import type { Scene } from '../scene/Scene'; import type { SceneObject } from '../entities/SceneObject'; +import type { ParserLexiconAsset, ParserTrainingAsset } from '../mechanics/parserLanguage'; type TextAssetData = Record<string, string>; @@ -43,10 +44,156 @@ const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { }, }; +const DEFAULT_PARSER_LEXICON: ParserLexiconAsset = { + stage1Aliases: { + look: ['look'], + examine: ['examine', 'inspect', 'check', 'x'], + take: ['take', 'get', 'pickup', 'pick up'], + goTo: ['go', 'walk', 'move'], + showInventory: ['inventory', 'inv', 'i'], + }, + normalizationPrefixes: { + look: ['look at', 'look', 'tell me about', 'what is that', 'what is', 'describe'], + examine: [ + 'take a closer look at', + 'look closely at', + 'examine at', + 'examine', + 'inspect at', + 'inspect', + 'check at', + 'check', + 'x at', + 'x', + ], + take: ['pick up', 'take', 'get', 'grab'], + goTo: [ + 'go over to', + 'walk over to', + 'move over to', + 'go to', + 'walk to', + 'move to', + 'go', + 'walk', + 'move', + 'head to', + 'travel to', + 'head', + 'travel', + ], + showInventory: [], + }, + politePrefixes: [ + 'please', + 'could you', + 'can you', + 'would you', + 'i want to', + 'i would like to', + "i'd like to", + ], + articles: ['the', 'a', 'an', 'my'], + lookSceneWords: ['around', 'here', 'scene'], +}; + +const DEFAULT_PARSER_TRAINING: ParserTrainingAsset = { + look: [ + 'look', + 'look chair', + 'look logo', + 'look lamp', + 'look key', + 'look door', + 'look at the lamp', + 'look at the chair', + 'look at the logo', + 'tell me about the door', + 'what is that lamp', + 'what is the chair', + 'what is the logo', + 'describe the office door', + 'look over the note', + ], + examine: [ + 'examine', + 'x boombox', + 'examine chair', + 'examine logo', + 'inspect chair', + 'inspect the logo', + 'check the card', + 'check chair', + 'inspect the note', + 'examine the lamp', + 'inspect the desk', + 'check the key', + 'look closely at the logo', + 'take a closer look at the chair', + ], + take: [ + 'take', + 'take key', + 'take card', + 'take note', + 'get key', + 'pickup key', + 'pick up key', + 'take the key', + 'pick up the key', + 'grab the card', + 'take the id card', + 'pick up linda card', + 'grab the note', + 'i want to take the key', + 'please pick up the card', + ], + goTo: [ + 'go', + 'go office', + 'go logo', + 'walk office', + 'walk logo', + 'move office', + 'move logo', + 'go to the office', + 'go to office', + 'walk to the office', + 'walk to the logo', + 'move to the lamp', + 'move to logo', + 'head to the door', + 'go over to the desk', + 'go over to the office', + 'go over to the logo', + 'travel to the office', + 'walk over to the card reader', + 'move over to the console', + ], + showInventory: [ + 'inventory', + 'inv', + 'items', + 'my items', + 'show inventory', + 'what do i have', + 'what am i carrying', + 'check my inventory', + 'show me my inventory', + 'list my items', + 'what items do i have', + 'open inventory', + ], +}; + export class TextAssetManager { private sceneCache = new Map<string, TextAssetData | null>(); private objectCache = new Map<string, TextAssetData | null>(); private serviceCache = new Map<string, TextAssetData>(); + private parserLexiconCache: ParserLexiconAsset = structuredClone(DEFAULT_PARSER_LEXICON); + private parserTrainingCache: ParserTrainingAsset = structuredClone(DEFAULT_PARSER_TRAINING); + private parserLexiconLoaded = false; + private parserTrainingLoaded = false; private normalizeId(id: string): string { return String(id || '') @@ -82,6 +229,14 @@ export class TextAssetManager { return { ...(DEFAULT_SERVICE_ASSETS[domain] || {}) }; } + getParserLexicon(): ParserLexiconAsset { + return this.parserLexiconCache; + } + + getParserTraining(): ParserTrainingAsset { + return this.parserTrainingCache; + } + buildDefaultSceneAsset(scene: Scene): TextAssetData { return { title: scene.name || scene.id || 'Untitled Scene', @@ -175,10 +330,58 @@ export class TextAssetManager { await Promise.all(targetDomains.map((domain) => this.readServiceAsset(domain, true))); } + async preloadParserLanguageAssets(): Promise<void> { + await Promise.all([this.readParserLexiconAsset(true), this.readParserTrainingAsset(true)]); + } + clearCaches(): void { this.sceneCache.clear(); this.objectCache.clear(); this.serviceCache.clear(); + this.parserLexiconCache = structuredClone(DEFAULT_PARSER_LEXICON); + this.parserTrainingCache = structuredClone(DEFAULT_PARSER_TRAINING); + this.parserLexiconLoaded = false; + this.parserTrainingLoaded = false; + } + + async readParserLexiconAsset(forceReload: boolean = false): Promise<ParserLexiconAsset> { + if (!forceReload && this.parserLexiconLoaded) { + return this.parserLexiconCache; + } + + const loaded = (await this.fetchUnknownJson( + '/text/system/parser-lexicon.json' + )) as Partial<ParserLexiconAsset> | null; + this.parserLexiconCache = { + ...structuredClone(DEFAULT_PARSER_LEXICON), + ...(loaded || {}), + stage1Aliases: { + ...DEFAULT_PARSER_LEXICON.stage1Aliases, + ...(loaded?.stage1Aliases || {}), + }, + normalizationPrefixes: { + ...DEFAULT_PARSER_LEXICON.normalizationPrefixes, + ...(loaded?.normalizationPrefixes || {}), + }, + }; + this.parserLexiconLoaded = true; + return this.parserLexiconCache; + } + + async readParserTrainingAsset(forceReload: boolean = false): Promise<ParserTrainingAsset> { + if (!forceReload && this.parserTrainingLoaded) { + return this.parserTrainingCache; + } + + const loaded = (await this.fetchUnknownJson( + '/text/system/parser-training.json' + )) as Partial<ParserTrainingAsset> | null; + this.parserTrainingCache = { + ...structuredClone(DEFAULT_PARSER_TRAINING), + ...(loaded || {}), + }; + this.parserTrainingLoaded = true; + return this.parserTrainingCache; } async readServiceAsset(domain: string, forceReload: boolean = false): Promise<TextAssetData> { @@ -264,6 +467,10 @@ export class TextAssetManager { } private async fetchJson(url: string): Promise<TextAssetData | null> { + return (await this.fetchUnknownJson(url)) as TextAssetData | null; + } + + private async fetchUnknownJson(url: string): Promise<unknown | null> { try { const response = await fetch(`${url}?t=${Date.now()}`); if (!response.ok) { @@ -274,7 +481,7 @@ export class TextAssetManager { if (!contentType.includes('application/json')) { return null; } - return (await response.json()) as TextAssetData; + return await response.json(); } catch (error) { console.error('[TextAssetManager] Failed to fetch text asset:', error); return null; diff --git a/src/mechanics/NlpCascade.ts b/src/mechanics/NlpCascade.ts index 90c219e..e8075c6 100644 --- a/src/mechanics/NlpCascade.ts +++ b/src/mechanics/NlpCascade.ts @@ -1,6 +1,6 @@ -import { NLP_TRAINING_DATA } from './nlp/trainingData'; -import { normalizeTargetForIntent } from './nlp/normalizeTarget'; +import { normalizeTargetForIntent } from './parserLanguage'; import type { ParserActionEnvelope, ParserContext, ParserToolAction } from './parserTypes'; +import type { TextAssetManager } from '../core/TextAssetManager'; const NLP_CONFIDENCE_THRESHOLD = 0.58; @@ -27,11 +27,16 @@ export type NlpCascadeDebugInfo = { }; export class NlpCascade { + private getTextAssets: () => TextAssetManager | undefined; private manager: any = null; private initPromise: Promise<void> | null = null; private ready = false; private lastDebugInfo: NlpCascadeDebugInfo | null = null; + constructor(getTextAssets: () => TextAssetManager | undefined) { + this.getTextAssets = getTextAssets; + } + getLastDebugInfo(): NlpCascadeDebugInfo | null { return this.lastDebugInfo; } @@ -45,6 +50,11 @@ export class NlpCascade { if (this.initPromise) return this.initPromise; this.initPromise = (async () => { + const textAssets = this.getTextAssets(); + if (!textAssets) { + throw new Error('Parser language assets are not available'); + } + const trainingData = await textAssets.readParserTrainingAsset(); const [coreModule, { Nlp }, { LangEn }] = await Promise.all([ import('@nlpjs/core'), import('@nlpjs/nlp'), @@ -71,7 +81,7 @@ export class NlpCascade { container ); - for (const [intent, utterances] of Object.entries(NLP_TRAINING_DATA)) { + for (const [intent, utterances] of Object.entries(trainingData)) { for (const utterance of utterances) { this.manager.addDocument('en', utterance, intent); } @@ -126,8 +136,32 @@ export class NlpCascade { } const intent = rawIntent as SupportedIntent; + if (!this.shouldAcceptIntent(intent, normalizedInput)) { + this.lastDebugInfo = { + input, + normalizedInput, + rawIntent, + score, + matched: false, + reason: 'unsupported_intent', + }; + return null; + } - const target = normalizeTargetForIntent(input, intent); + const textAssets = this.getTextAssets(); + if (!textAssets) { + this.lastDebugInfo = { + input, + normalizedInput, + rawIntent, + score, + matched: false, + reason: 'not_initialized', + }; + return null; + } + + const target = normalizeTargetForIntent(input, intent, textAssets.getParserLexicon()); const actions = this.buildActions(intent, target); if (!actions) { this.lastDebugInfo = { @@ -182,4 +216,20 @@ export class NlpCascade { return null; } } + + private shouldAcceptIntent(intent: SupportedIntent, normalizedInput: string): boolean { + if (intent !== 'showInventory') { + return true; + } + + const lowered = normalizedInput.toLowerCase(); + return ( + /\binventory\b/.test(lowered) || + /\binv\b/.test(lowered) || + /\bitems?\b/.test(lowered) || + /\bcarrying\b/.test(lowered) || + /\bcarry\b/.test(lowered) || + /\bhave\b/.test(lowered) + ); + } } diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 5782107..94bca77 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -1,6 +1,11 @@ import type { GameActionOutcome } from '../core/GameActionTypes'; import { NlpCascade } from './NlpCascade'; -import { normalizeTargetForIntent } from './nlp/normalizeTarget'; +import { + getStage1CommandWords, + isLookSceneWord, + matchStage1Intent, + normalizeTargetForIntent, +} from './parserLanguage'; import type { Entity } from '../entities/Entity'; import type { SceneDescriptor } from '../scene/SceneManager'; import type { @@ -14,23 +19,6 @@ import type { ParserToolAction, } from './parserTypes'; -const STAGE1_COMMAND_WORDS = new Set([ - 'LOOK', - 'EXAMINE', - 'INSPECT', - 'CHECK', - 'X', - 'TAKE', - 'GET', - 'PICKUP', - 'INV', - 'INVENTORY', - 'I', - 'GO', - 'WALK', - 'MOVE', -]); - export class Parser { game: any; inputField: HTMLInputElement | null; @@ -41,7 +29,7 @@ export class Parser { this.game = game; this.inputField = null; this.pendingState = null; - this.nlpCascade = new NlpCascade(); + this.nlpCascade = new NlpCascade(() => this.game.textAssets); } async parse(input: string): Promise<void> { @@ -58,7 +46,11 @@ export class Parser { ? this.buildStage1BypassAction(trimmed) : this.runStage1(trimmed)); - if (!actionEnvelope && this.isHandoffAction(actionJson)) { + if ( + !actionEnvelope && + this.game.console?.parserStage2Enabled !== false && + this.isHandoffAction(actionJson) + ) { const stage2Envelope = await this.nlpCascade.parse(trimmed, context); if (stage2Envelope) { actionJson = JSON.stringify(stage2Envelope); @@ -167,54 +159,50 @@ export class Parser { } private runStage1(input: string): string { + const lexicon = this.game.textAssets.getParserLexicon(); + const match = matchStage1Intent(input, lexicon); const words = input.trim().split(/\s+/); const verb = (words[0] || '').toUpperCase(); const noun = words.slice(1).join(' ').trim(); - const normalizedNoun = noun.toUpperCase(); let actions: ParserToolAction[]; - switch (verb) { - case 'LOOK': + switch (match?.intent) { + case 'look': { + const target = normalizeTargetForIntent(input, 'look', lexicon) || match.remainder || noun; actions = [ - !normalizedNoun || - normalizedNoun === 'AROUND' || - normalizedNoun === 'HERE' || - normalizedNoun === 'SCENE' + !target || isLookSceneWord(target, lexicon) ? { type: 'lookScene' as const } - : { - type: 'lookTarget' as const, - target: normalizeTargetForIntent(input, 'look') || noun, - }, + : { type: 'lookTarget' as const, target }, ]; break; - case 'EXAMINE': - case 'INSPECT': - case 'CHECK': - case 'X': + } + case 'examine': actions = [ { type: 'examineTarget', - target: normalizeTargetForIntent(input, 'examine') || noun || null, + target: + normalizeTargetForIntent(input, 'examine', lexicon) || + match?.remainder || + noun || + null, }, ]; break; - case 'TAKE': - case 'GET': - case 'PICKUP': + case 'take': actions = [ - { type: 'takeTarget', target: normalizeTargetForIntent(input, 'take') || noun || null }, + { + type: 'takeTarget', + target: + normalizeTargetForIntent(input, 'take', lexicon) || match?.remainder || noun || null, + }, ]; break; - case 'INV': - case 'INVENTORY': - case 'I': + case 'showInventory': actions = [{ type: 'showInventory' }]; break; - case 'GO': - case 'WALK': - case 'MOVE': { - const target = normalizeTargetForIntent(input, 'goTo') || noun; + case 'goTo': { + const target = normalizeTargetForIntent(input, 'goTo', lexicon) || match?.remainder || noun; actions = [{ type: 'goToTarget', target: target || null }]; break; } @@ -800,6 +788,6 @@ export class Parser { if (!trimmed) return false; if (trimmed.startsWith('#') || trimmed.startsWith('-')) return true; const firstWord = trimmed.split(/\s+/)[0]?.toUpperCase() || ''; - return STAGE1_COMMAND_WORDS.has(firstWord); + return getStage1CommandWords(this.game.textAssets.getParserLexicon()).has(firstWord); } } diff --git a/src/mechanics/nlp/normalizeTarget.ts b/src/mechanics/nlp/normalizeTarget.ts deleted file mode 100644 index a67bc63..0000000 --- a/src/mechanics/nlp/normalizeTarget.ts +++ /dev/null @@ -1,59 +0,0 @@ -const LEADING_POLITE_PATTERNS = [ - /^(please)\s+/i, - /^(could you)\s+/i, - /^(can you)\s+/i, - /^(would you)\s+/i, - /^(i want to)\s+/i, - /^(i would like to)\s+/i, - /^(i'd like to)\s+/i, -]; - -function stripLeadingPhrases(input: string): string { - let value = input.trim(); - for (const pattern of LEADING_POLITE_PATTERNS) { - value = value.replace(pattern, ''); - } - return value.trim(); -} - -function stripLeadingArticle(input: string): string { - return input.replace(/^(the|a|an|my)\s+/i, '').trim(); -} - -export function normalizeTargetForIntent(input: string, intent: string): string | null { - let value = stripLeadingPhrases(input) - .replace(/[?.!,]+$/g, '') - .trim(); - if (!value) return null; - - switch (intent) { - case 'look': - case 'examine': - value = value - .replace(/^(look|examine|inspect|check|x)(\s+at)?\s+/i, '') - .replace(/^(tell me about)\s+/i, '') - .replace(/^(what is(?:\s+that)?)\s+/i, '') - .replace(/^(describe)\s+/i, '') - .trim(); - break; - case 'take': - value = value - .replace(/^(take|get|grab)\s+/i, '') - .replace(/^(pick up)\s+/i, '') - .trim(); - break; - case 'goTo': - value = value - .replace(/^(go|walk|move|head|travel)(\s+(over\s+)?to)?\s+/i, '') - .replace(/^(go|walk|move|head|travel)\s+/i, '') - .trim(); - break; - case 'showInventory': - return null; - default: - break; - } - - value = stripLeadingArticle(value); - return value || null; -} diff --git a/src/mechanics/nlp/trainingData.ts b/src/mechanics/nlp/trainingData.ts deleted file mode 100644 index 89e301c..0000000 --- a/src/mechanics/nlp/trainingData.ts +++ /dev/null @@ -1,93 +0,0 @@ -export const NLP_TRAINING_DATA: Record<string, string[]> = { - look: [ - 'look', - 'look chair', - 'look logo', - 'look lamp', - 'look key', - 'look door', - 'examine chair', - 'inspect logo', - 'look at the lamp', - 'look at the chair', - 'look at the logo', - 'examine the lamp', - 'inspect the desk', - 'check the card', - 'tell me about the door', - 'what is that lamp', - 'what is the chair', - 'what is the logo', - 'describe the office door', - 'look over the note', - ], - examine: [ - 'examine', - 'examine chair', - 'examine logo', - 'inspect chair', - 'inspect the logo', - 'check the card', - 'check chair', - 'inspect the note', - 'examine the lamp', - 'inspect the desk', - 'check the key', - 'look closely at the logo', - 'take a closer look at the chair', - ], - take: [ - 'take', - 'take key', - 'take card', - 'take note', - 'get key', - 'pickup key', - 'take the key', - 'take the chair', - 'pick up the key', - 'pick up the logo', - 'grab the card', - 'take the id card', - 'pick up linda card', - 'grab the note', - 'i want to take the key', - 'please pick up the card', - ], - goTo: [ - 'go', - 'go office', - 'go logo', - 'walk office', - 'walk logo', - 'move office', - 'move logo', - 'go to the office', - 'go to office', - 'walk to the office', - 'walk to the logo', - 'move to the lamp', - 'move to logo', - 'head to the door', - 'go over to the desk', - 'go over to the office', - 'go over to the logo', - 'travel to the office', - 'walk over to the card reader', - 'move over to the console', - ], - showInventory: [ - 'inventory', - 'inv', - 'items', - 'my items', - 'show inventory', - 'what do i have', - 'what am i carrying', - 'check my inventory', - 'show me my inventory', - 'list my items', - 'what items do i have', - 'open inventory', - ], -}; diff --git a/src/mechanics/parserLanguage.ts b/src/mechanics/parserLanguage.ts new file mode 100644 index 0000000..405cf0c --- /dev/null +++ b/src/mechanics/parserLanguage.ts @@ -0,0 +1,102 @@ +export type ParserIntentId = 'look' | 'examine' | 'take' | 'goTo' | 'showInventory'; + +export type ParserLexiconAsset = { + stage1Aliases: Record<ParserIntentId, string[]>; + normalizationPrefixes: Record<ParserIntentId, string[]>; + politePrefixes: string[]; + articles: string[]; + lookSceneWords: string[]; +}; + +export type ParserTrainingAsset = Record<ParserIntentId, string[]>; + +type Stage1Match = { + intent: ParserIntentId; + matchedAlias: string; + remainder: string; +}; + +function sortByLengthDesc(values: string[]): string[] { + return [...values].sort((a, b) => b.length - a.length); +} + +function startsWithPhrase(input: string, phrase: string): boolean { + if (input === phrase) return true; + return input.startsWith(`${phrase} `); +} + +function stripLeadingPhrase(input: string, phrase: string): string { + if (input === phrase) return ''; + return input.slice(phrase.length).trimStart(); +} + +function stripFromList(input: string, phrases: string[]): string { + let value = input.trim(); + for (const phrase of sortByLengthDesc(phrases.map((item) => item.trim()).filter(Boolean))) { + if (startsWithPhrase(value.toLowerCase(), phrase.toLowerCase())) { + value = stripLeadingPhrase(value, value.slice(0, phrase.length)); + break; + } + } + return value.trim(); +} + +export function matchStage1Intent(input: string, lexicon: ParserLexiconAsset): Stage1Match | null { + const trimmed = input.trim(); + if (!trimmed) return null; + const lowered = trimmed.toLowerCase(); + + const intents: ParserIntentId[] = ['look', 'examine', 'take', 'showInventory', 'goTo']; + for (const intent of intents) { + const aliases = sortByLengthDesc(lexicon.stage1Aliases[intent] || []); + for (const alias of aliases) { + const normalizedAlias = alias.trim().toLowerCase(); + if (!normalizedAlias) continue; + if (startsWithPhrase(lowered, normalizedAlias)) { + const remainder = trimmed.slice(alias.length).trim(); + return { + intent, + matchedAlias: alias, + remainder, + }; + } + } + } + + return null; +} + +export function getStage1CommandWords(lexicon: ParserLexiconAsset): Set<string> { + const words = new Set<string>(); + for (const aliases of Object.values(lexicon.stage1Aliases)) { + for (const alias of aliases) { + const firstWord = alias.trim().split(/\s+/)[0]; + if (firstWord) words.add(firstWord.toUpperCase()); + } + } + return words; +} + +export function isLookSceneWord(target: string, lexicon: ParserLexiconAsset): boolean { + const normalized = String(target || '') + .trim() + .toLowerCase(); + return ( + !!normalized && (lexicon.lookSceneWords || []).some((word) => word.toLowerCase() === normalized) + ); +} + +export function normalizeTargetForIntent( + input: string, + intent: ParserIntentId, + lexicon: ParserLexiconAsset +): string | null { + let value = input.replace(/[?.!,]+$/g, '').trim(); + if (!value) return null; + + value = stripFromList(value, lexicon.politePrefixes || []); + value = stripFromList(value, lexicon.normalizationPrefixes[intent] || []); + value = stripFromList(value, lexicon.articles || []); + + return value.trim() || null; +} From e75e96e780086d1c460cabadc6c72132a53f98b5 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 16 Mar 2026 19:56:22 +0200 Subject: [PATCH 19/75] Docs: refine parser architecture contract Update Parser.md to reflect the agreed parser contract more precisely: Game API is a shared gameplay API used by parser, UI, scripts, and game logic; Stage 1.1 and Stage 1.2 both follow the same intent -> target -> envelope flow; the parser DSL is described as a unified protocol for all cascades rather than a special Stage 2 format; object TA now includes optional synonyms as parser-owned text knowledge; and parser debugging docs now include Stage 2 toggles. --- Parser.md | 139 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 99 insertions(+), 40 deletions(-) diff --git a/Parser.md b/Parser.md index 511fc72..7075a62 100644 --- a/Parser.md +++ b/Parser.md @@ -19,7 +19,8 @@ Главный принцип: - **вся языковая интерпретация живёт внутри parser-а**; - `Game` и runtime не понимают язык игрока и не резолвят текстовые цели; -- `Game` только исполняет операции над уже понятными сущностями и возвращает structured outcomes. +- `Game` только исполняет операции над уже понятными сущностями и возвращает structured outcomes; +- parser — не единственный клиент `Game API`: тем же shared API пользуются UI, scripts и игровая логика. --- @@ -202,8 +203,8 @@ Stage 1 на самом деле состоит из двух внутренни Он: - пытается распознать canonical-команду; - выделяет базовый `intent`; -- строит preliminary action candidate; -- нормализует или очищает `target phrase`. +- нормализует или очищает `target phrase`; +- собирает унифицированный envelope для `Core`. Подходит для: - `LOOK` @@ -221,7 +222,7 @@ Stage 1 на самом деле состоит из двух внутренни - определяет `intent` по более свободному вводу; - оценивает confidence; - очищает `target phrase`; -- строит тот же action candidate, что и `Stage 1.1`. +- собирает тот же унифицированный envelope для `Core`, что и `Stage 1.1`. Он полезен для: - `look at the lamp` @@ -246,8 +247,8 @@ flowchart TD CTX --> R1 R1 --> R1A{Intent recognized?} - R1A -->|yes| R1B[Build preliminary action candidate] - R1B --> R1C[Extract or normalize target phrase] + R1A -->|yes| R1B[Extract or normalize target phrase] + R1B --> R1C[Build cascade envelope] R1C --> CORE[Parser Core] R1A -->|no| N1[Stage 1.2 NLP Layer] @@ -256,7 +257,7 @@ flowchart TD N1 --> N1A[Classify intent] N1A --> N1B{Confidence high enough?} N1B -->|yes| N1C[Extract or normalize target phrase] - N1C --> N1D[Build preliminary action candidate] + N1C --> N1D[Build cascade envelope] N1D --> CORE N1B -->|no| H[Handoff to next cascade] @@ -265,6 +266,7 @@ flowchart TD Что важно: - `intent` определяется внутри уровня каскада; - `target phrase` выделяется и очищается там же; +- затем уровень собирает единый envelope/protocol для `Core`; - в `Core` приходит уже не сырой ввод, а первичная интерпретация команды. ## Stage 2 — LLM / Future @@ -407,9 +409,9 @@ Parser сначала проверяет: ## Game API Contract -`Game` — это tool layer для parser-а. +`Game API` — это shared gameplay API движка. -Он не занимается языком игрока. +Он не принадлежит одному только parser-у и не занимается языком игрока. Текущий semantic API: - `lookScene(scene?)` @@ -421,11 +423,17 @@ Parser сначала проверяет: - `goToEntity(entity)` Принцип: +- parser — один из клиентов `Game API`, а не его единственный владелец; +- тем же API могут пользоваться UI, scripts и игровая логика; - parser передаёт в `Game` уже resolved цели; - `Game` не подбирает объекты по тексту; - `Game` не делает disambiguation; - `Game` не разбирает user input. +Следствие: +- на `Scanline` можно сделать не только parser-driven игру; +- при расширении полномочий UI на этом же API можно построить чистый point-and-click quest. + ### Что делает Game `Game` отвечает за: @@ -469,10 +477,37 @@ Parser сначала проверяет: Parser: - ищет цели в собственной модели мира; - использует только player-facing `title`, а не технические `id`; +- может использовать опциональные `synonyms`, если они заданы в TA объекта; - исключает `disabled` объекты сцены; - поддерживает partial matching; - поддерживает clarification при неоднозначности. +### Object TA fields relevant to target resolution + +Для object TA важны не только: +- `title` +- `description` +- `details` + +Но и новое опциональное поле: +- `synonyms` + +Пример: + +```json +{ + "title": "logo", + "description": "You see Scanline Engine logo.", + "details": "Extended description here.", + "synonyms": ["logotype", "emblem", "scanline symbol"] +} +``` + +Поле `synonyms`: +- является parser-owned text knowledge; +- помогает точнее определять target без обращения к LLM; +- должно входить в шаблон нового object TA, даже если список пустой. + ### Inventory-aware resolution Инвентарь является частью доступного текстового мира для non-movement действий. @@ -544,7 +579,7 @@ sequenceDiagram --- -## Stage 2 Output Model +## Unified Cascade Output Model Первые два уровня parser-а по сути формируют пакет данных для одного и того же `Core`. @@ -552,16 +587,19 @@ sequenceDiagram - `Stage 1.1` и `Stage 1.2` — это не два разных parser-а; - это два разных способа превратить ввод игрока в данные для `Core`. -Нижние уровни обычно возвращают: -- `intent` -- `target phrase` -- preliminary action candidate +Главный архитектурный вывод: +- protocol взаимодействия с `Core` должен быть единым для всех каскадов; +- нижние каскады могут использовать только простой subset этого protocol; +- старший каскад может использовать более богатые формы того же protocol. -Но старший каскад должен уметь возвращать более богатые инструкции. +Это важно, потому что: +- позволяет отлаживать `Core` и execution loop без реальной LLM; +- позволяет мокать сложные LLM-сценарии через `Stage 1`; +- позволяет стабилизировать orchestration до подключения непредсказуемой модели. --- -## Stage 2 DSL (First Draft) +## Unified Parser DSL (First Draft) Будущий старший каскад (LLM) должен уметь возвращать не только `intent`, но и richer instructions. @@ -573,6 +611,11 @@ sequenceDiagram Поэтому нужен **ограниченный parser DSL**. +Важно: +- этот DSL не должен быть "особым форматом только для Stage 2"; +- это должен быть общий protocol общения cascade layers с `Core`; +- `Stage 1.1` и `Stage 1.2` просто используют его более простой subset. + ### Общий смысл DSL LLM возвращает не код, а допустимый план шагов. @@ -583,9 +626,9 @@ LLM возвращает не код, а допустимый план шаго - собирает outcomes; - при необходимости повторно зовёт старший каскад. -### Богатые выходы старшего каскада +### Богатые выходы каскада -Старший каскад должен уметь возвращать не только `intent`, но и: +Каскадный уровень должен уметь возвращать не только `intent`, но и: - `plan` - `clarification` - `final_response` @@ -598,14 +641,22 @@ LLM возвращает не код, а допустимый план шаго ```ts type CascadeEnvelope = | { - stage: 'llm-v3'; + stage: 'regex-v1' | 'nlp-v2' | 'llm-v3'; + output: { + kind: 'intent'; + intent: string; + target?: string | null; + }; + } + | { + stage: 'regex-v1' | 'nlp-v2' | 'llm-v3'; output: { kind: 'plan'; actions: ParserPlannedAction[]; }; } | { - stage: 'llm-v3'; + stage: 'regex-v1' | 'nlp-v2' | 'llm-v3'; output: { kind: 'clarification'; question: string; @@ -613,14 +664,14 @@ type CascadeEnvelope = }; } | { - stage: 'llm-v3'; + stage: 'regex-v1' | 'nlp-v2' | 'llm-v3'; output: { kind: 'final_response'; message: string; }; } | { - stage: 'llm-v3'; + stage: 'regex-v1' | 'nlp-v2' | 'llm-v3'; output: { kind: 'handoff_up'; reason: string; @@ -656,7 +707,7 @@ type ParserPlannedAction = Это важно для безопасности и устойчивости архитектуры. -LLM не должна: +Ни один каскад не должен: - писать произвольный код; - обращаться к внутренностям runtime напрямую; - вносить неконтролируемые side effects. @@ -672,9 +723,9 @@ LLM не должна: Первый вариант DSL лучше делать **линейным**, без встроенных `if/else` и циклов. То есть: -- старший каскад предлагает список шагов; +- каскад предлагает список шагов; - `Core` исполняет их по одному; -- при неожиданном outcome `Core` останавливает план и снова зовёт старший каскад. +- при неожиданном outcome `Core` останавливает план и снова зовёт следующий подходящий уровень. Это проще и надёжнее, чем сразу делать полноценный mini-language. @@ -708,6 +759,8 @@ sequenceDiagram - `#PEEK-OFF` - `#STAGE1-ON` - `#STAGE1-OFF` +- `#STAGE2-ON` +- `#STAGE2-OFF` ### PEEK @@ -719,7 +772,10 @@ sequenceDiagram ### Stage toggles -Можно отключить `Stage 1.1` и отправлять обработку сразу на следующий уровень первого каскада, чтобы изолированно тестировать NLP. +Можно изолированно тестировать разные уровни: +- `#STAGE1-ON` / `#STAGE1-OFF` управляют `Stage 1.1`; +- `#STAGE2-ON` / `#STAGE2-OFF` управляют `Stage 1.2`; +- это полезно для отладки `Core` и DSL без реальной LLM. --- @@ -774,14 +830,14 @@ Language assets лучше хранить как **структурирован { "stage1Aliases": { "look": ["look"], - "examine": ["examine", "inspect", "check", "x"], + "examine": ["examine", "inspect", "check"], "take": ["take", "get", "pickup", "pick up"], "goTo": ["go", "walk", "move"], - "showInventory": ["inventory", "inv", "i"] + "showInventory": ["inventory", "inv"] }, "normalizationPrefixes": { "look": ["look at", "tell me about", "what is", "describe"], - "examine": ["take a closer look at", "look closely at", "examine", "inspect", "check", "x"], + "examine": ["take a closer look at", "look closely at", "examine", "inspect", "check"], "take": ["pick up", "take", "get", "grab"], "goTo": ["go over to", "go to", "walk to", "move to", "go", "walk", "move"] }, @@ -924,16 +980,19 @@ flowchart TD ## Core Principles Recap 1. Parser — единственный слой, интерпретирующий язык игрока. -2. `Game` и runtime не должны парсить текст и резолвить текстовые цели. -3. `Context Builder` строит context только из состояния игры. -4. `Player Input` и `Parser Context` — отдельные входы parser-а. -5. Stage processing последовательный, а не параллельный. -6. Первый каскад имеет два внутренних уровня: `regex`, затем `NLP`. -7. Все каскады подают данные в один и тот же `Parser Core`. -8. `Core` может эскалировать как до API, так и после API. -9. `Core` — центр clarification, orchestration, iteration и final response. -10. Старший каскад должен уметь возвращать не только `intent`, но и richer instructions через constrained DSL. -11. Player-facing messages никогда не должны показывать технические `id`. -12. Всё language-specific должно жить в text assets. +2. `Game API` — общий gameplay API для parser-а, UI, scripts и игровой логики. +3. `Game` и runtime не должны парсить текст и резолвить текстовые цели. +4. `Context Builder` строит context только из состояния игры. +5. `Player Input` и `Parser Context` — отдельные входы parser-а. +6. Stage processing последовательный, а не параллельный. +7. Первый каскад имеет два внутренних уровня: `regex`, затем `NLP`. +8. Оба уровня Stage 1 работают по одной логике: `intent -> target -> envelope`. +9. Все каскады подают данные в один и тот же `Parser Core`. +10. `Core` может эскалировать как до API, так и после API. +11. `Core` — центр clarification, orchestration, iteration и final response. +12. DSL/protocol общения с `Core` должен быть единым для всех каскадов, даже если нижние уровни используют только простой subset. +13. Object TA может содержать опциональное поле `synonyms` для повышения точности target resolution. +14. Player-facing messages никогда не должны показывать технические `id`. +15. Всё language-specific должно жить в text assets. Эта архитектура делает parser фундаментом для постепенного перехода от классического IF-style command parser-а к полноценному Game Master и orchestrator. From 976d2dbba71a266168b6e0d2de1c5dd483294362 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 16 Mar 2026 21:04:17 +0200 Subject: [PATCH 20/75] Refactor: add parser world model and unified cascade envelope Introduce ParserWorldModelBuilder to produce parser context and scope together, replace ad-hoc resolution with scope-driven target lookup, and add parser-owned synonym support via object text assets. Unify stage1 regex and stage1 NLP outputs under a shared ParserCascadeEnvelope so both cascades speak the same Core-facing protocol. Also extend TextAssetManager with list-field support and include synonyms in the default object TA template. Update tasks.md to reflect the completed scope/world-model work and the start of Step 3. --- src/core/TextAssetManager.ts | 26 +- src/mechanics/NlpCascade.ts | 9 +- src/mechanics/Parser.ts | 432 +++++++++++------------ src/mechanics/ParserWorldModelBuilder.ts | 120 +++++++ src/mechanics/parserTypes.ts | 41 ++- tasks.md | 65 ++++ 6 files changed, 448 insertions(+), 245 deletions(-) create mode 100644 src/mechanics/ParserWorldModelBuilder.ts create mode 100644 tasks.md diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index da2d428..261e856 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -2,7 +2,8 @@ import type { Scene } from '../scene/Scene'; import type { SceneObject } from '../entities/SceneObject'; import type { ParserLexiconAsset, ParserTrainingAsset } from '../mechanics/parserLanguage'; -type TextAssetData = Record<string, string>; +type TextAssetValue = string | string[]; +type TextAssetData = Record<string, TextAssetValue>; const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { parser: { @@ -47,10 +48,10 @@ const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { const DEFAULT_PARSER_LEXICON: ParserLexiconAsset = { stage1Aliases: { look: ['look'], - examine: ['examine', 'inspect', 'check', 'x'], + examine: ['examine', 'inspect', 'check'], take: ['take', 'get', 'pickup', 'pick up'], goTo: ['go', 'walk', 'move'], - showInventory: ['inventory', 'inv', 'i'], + showInventory: ['inventory', 'inv'], }, normalizationPrefixes: { look: ['look at', 'look', 'tell me about', 'what is that', 'what is', 'describe'], @@ -63,8 +64,6 @@ const DEFAULT_PARSER_LEXICON: ParserLexiconAsset = { 'inspect', 'check at', 'check', - 'x at', - 'x', ], take: ['pick up', 'take', 'get', 'grab'], goTo: [ @@ -117,7 +116,6 @@ const DEFAULT_PARSER_TRAINING: ParserTrainingAsset = { ], examine: [ 'examine', - 'x boombox', 'examine chair', 'examine logo', 'inspect chair', @@ -251,6 +249,7 @@ export class TextAssetManager { return { title: fallbackTitle, description: fallbackDescription, + synonyms: [], }; } @@ -414,6 +413,12 @@ export class TextAssetManager { return this.resolveField(asset, obj?.textRedirects || null, field, fallback); } + getResolvedObjectListField(obj: SceneObject, field: string): string[] { + const objectId = this.normalizeId(obj?.name || ''); + const asset = objectId ? this.objectCache.get(objectId) : null; + return this.resolveListField(asset, field); + } + getServiceText(key: string, params?: Record<string, string | number>, fallback?: string): string { const rawKey = String(key || '').trim(); if (!rawKey) return fallback || ''; @@ -466,6 +471,15 @@ export class TextAssetManager { return fallback; } + private resolveListField(asset: TextAssetData | null | undefined, field: string): string[] { + const raw = asset?.[field]; + if (!Array.isArray(raw)) return []; + return raw + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); + } + private async fetchJson(url: string): Promise<TextAssetData | null> { return (await this.fetchUnknownJson(url)) as TextAssetData | null; } diff --git a/src/mechanics/NlpCascade.ts b/src/mechanics/NlpCascade.ts index e8075c6..7fbe2e2 100644 --- a/src/mechanics/NlpCascade.ts +++ b/src/mechanics/NlpCascade.ts @@ -1,5 +1,5 @@ import { normalizeTargetForIntent } from './parserLanguage'; -import type { ParserActionEnvelope, ParserContext, ParserToolAction } from './parserTypes'; +import type { ParserCascadeEnvelope, ParserContext, ParserToolAction } from './parserTypes'; import type { TextAssetManager } from '../core/TextAssetManager'; const NLP_CONFIDENCE_THRESHOLD = 0.58; @@ -93,7 +93,7 @@ export class NlpCascade { return this.initPromise; } - async parse(input: string, _context: ParserContext): Promise<ParserActionEnvelope | null> { + async parse(input: string, _context: ParserContext): Promise<ParserCascadeEnvelope | null> { await this.initialize(); const normalizedInput = input.replace(/[?.!,]+$/g, '').trim(); if (!this.manager) { @@ -187,7 +187,10 @@ export class NlpCascade { return { stage: 'nlp-v2', - actions, + output: { + kind: 'plan', + actions, + }, debug: { rawInput: input, normalizedInput: normalizedInput.toUpperCase(), diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 94bca77..a3ca159 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -6,16 +6,15 @@ import { matchStage1Intent, normalizeTargetForIntent, } from './parserLanguage'; +import { ParserWorldModelBuilder } from './ParserWorldModelBuilder'; import type { Entity } from '../entities/Entity'; import type { SceneDescriptor } from '../scene/SceneManager'; import type { - ParserActionEnvelope, - ParserContext, - ParserEntityContext, - ParserInventoryItemContext, + ParserCascadeEnvelope, ParserPendingState, ParserResponse, ParserResult, + ParserScope, ParserToolAction, } from './parserTypes'; @@ -24,12 +23,16 @@ export class Parser { inputField: HTMLInputElement | null; pendingState: ParserPendingState | null; nlpCascade: NlpCascade; + worldModelBuilder: ParserWorldModelBuilder; + activeScope: ParserScope | null; constructor(game: any) { this.game = game; this.inputField = null; this.pendingState = null; this.nlpCascade = new NlpCascade(() => this.game.textAssets); + this.worldModelBuilder = new ParserWorldModelBuilder(this.game); + this.activeScope = null; } async parse(input: string): Promise<void> { @@ -38,9 +41,11 @@ export class Parser { try { this.nlpCascade.clearLastDebugInfo(); const actionEnvelope = this.resolvePendingAction(trimmed); - const context = this.buildContext(trimmed); + const worldModel = this.worldModelBuilder.build(trimmed, this.pendingState); + const context = worldModel.context; + this.activeScope = worldModel.scope; const contextJson = JSON.stringify(context); - let actionJson = + let envelope = actionEnvelope || (this.game.console?.parserStage1Enabled === false ? this.buildStage1BypassAction(trimmed) @@ -49,16 +54,17 @@ export class Parser { if ( !actionEnvelope && this.game.console?.parserStage2Enabled !== false && - this.isHandoffAction(actionJson) + this.isHandoffEnvelope(envelope) ) { const stage2Envelope = await this.nlpCascade.parse(trimmed, context); if (stage2Envelope) { - actionJson = JSON.stringify(stage2Envelope); + envelope = stage2Envelope; } } - const resultJson = this.executeActionJson(actionJson); - const response = this.buildResponse(resultJson, actionJson, contextJson); + const envelopeJson = JSON.stringify(envelope); + const resultJson = this.executeEnvelope(envelope); + const response = this.buildResponse(resultJson, envelopeJson, contextJson); if (response.debugMessages?.length) { for (const message of response.debugMessages) { @@ -74,57 +80,13 @@ export class Parser { } } catch (error) { this.pendingState = null; + this.activeScope = null; this.game.console?.log(`[Parser error] ${String(error)}`, 'error'); this.game.log(this.game.text('parser.parse_unknown')); } } - private buildContext(rawInput: string): ParserContext { - const scene = this.game.sceneManager.currentScene; - const normalizedInput = rawInput.trim().toUpperCase(); - - return { - rawInput, - normalizedInput, - scene: scene - ? { - id: scene.id, - name: scene.name, - title: this.game.textAssets.getResolvedSceneField(scene, 'title'), - description: this.game.textAssets.getResolvedSceneField(scene, 'description'), - } - : null, - entities: scene - ? (scene.entities || []) - .map((entity: any) => ({ - id: entity.name, - type: entity.type, - title: this.game.textAssets.getResolvedObjectField(entity, 'title'), - description: this.game.textAssets.getResolvedObjectField(entity, 'description'), - details: this.game.textAssets.getResolvedObjectField(entity, 'details'), - interactions: Object.keys(entity.interactions || {}), - })) - .filter((entity: ParserEntityContext) => !!entity.title?.trim()) - : [], - inventory: (this.game.inventory || []) - .map((entity: any) => ({ - id: entity.name, - title: this.game.textAssets.getResolvedObjectField(entity, 'title'), - description: this.game.textAssets.getResolvedObjectField(entity, 'description'), - details: this.game.textAssets.getResolvedObjectField(entity, 'details'), - })) - .filter((entity: ParserInventoryItemContext) => !!entity.title?.trim()), - pending: this.pendingState - ? { - intent: this.pendingState.intent, - question: this.pendingState.question, - originalInput: this.pendingState.originalInput, - } - : null, - }; - } - - private resolvePendingAction(input: string): string | null { + private resolvePendingAction(input: string): ParserCascadeEnvelope | null { if (!this.pendingState) return null; if (this.looksLikeFreshCommand(input)) { this.pendingState = null; @@ -143,9 +105,12 @@ export class Parser { target: input.trim(), }; - const envelope: ParserActionEnvelope = { + const envelope: ParserCascadeEnvelope = { stage: 'pending-resolution', - actions: [action], + output: { + kind: 'plan', + actions: [action], + }, debug: { rawInput: input, normalizedInput: input.trim().toUpperCase(), @@ -154,101 +119,147 @@ export class Parser { pendingIntent: this.pendingState.intent, }, }; - - return JSON.stringify(envelope); + return envelope; } - private runStage1(input: string): string { + private runStage1(input: string): ParserCascadeEnvelope { const lexicon = this.game.textAssets.getParserLexicon(); const match = matchStage1Intent(input, lexicon); const words = input.trim().split(/\s+/); const verb = (words[0] || '').toUpperCase(); const noun = words.slice(1).join(' ').trim(); - let actions: ParserToolAction[]; - switch (match?.intent) { case 'look': { const target = normalizeTargetForIntent(input, 'look', lexicon) || match.remainder || noun; - actions = [ - !target || isLookSceneWord(target, lexicon) - ? { type: 'lookScene' as const } - : { type: 'lookTarget' as const, target }, - ]; - break; + return { + stage: 'regex-v1', + output: { + kind: 'plan', + actions: [ + !target || isLookSceneWord(target, lexicon) + ? { type: 'lookScene' as const } + : { type: 'lookTarget' as const, target }, + ], + }, + debug: { + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + verb, + noun, + }, + }; } case 'examine': - actions = [ - { - type: 'examineTarget', - target: - normalizeTargetForIntent(input, 'examine', lexicon) || - match?.remainder || - noun || - null, + return { + stage: 'regex-v1', + output: { + kind: 'plan', + actions: [ + { + type: 'examineTarget', + target: + normalizeTargetForIntent(input, 'examine', lexicon) || + match?.remainder || + noun || + null, + }, + ], }, - ]; - break; + debug: { + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + verb, + noun, + }, + }; case 'take': - actions = [ - { - type: 'takeTarget', - target: - normalizeTargetForIntent(input, 'take', lexicon) || match?.remainder || noun || null, + return { + stage: 'regex-v1', + output: { + kind: 'plan', + actions: [ + { + type: 'takeTarget', + target: + normalizeTargetForIntent(input, 'take', lexicon) || + match?.remainder || + noun || + null, + }, + ], }, - ]; - break; + debug: { + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + verb, + noun, + }, + }; case 'showInventory': - actions = [{ type: 'showInventory' }]; - break; + return { + stage: 'regex-v1', + output: { + kind: 'plan', + actions: [{ type: 'showInventory' }], + }, + debug: { + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + verb, + noun, + }, + }; case 'goTo': { const target = normalizeTargetForIntent(input, 'goTo', lexicon) || match?.remainder || noun; - actions = [{ type: 'goToTarget', target: target || null }]; - break; + return { + stage: 'regex-v1', + output: { + kind: 'plan', + actions: [{ type: 'goToTarget', target: target || null }], + }, + debug: { + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + verb, + noun, + }, + }; } default: - actions = [ - { - type: 'handoff', + return { + stage: 'regex-v1', + output: { + kind: 'handoff_up', reason: 'unsupported_by_stage1', verb, noun, rawInput: input, }, - ]; - break; + debug: { + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + verb, + noun, + }, + }; } - - const envelope: ParserActionEnvelope = { - stage: 'regex-v1', - actions, - debug: { - rawInput: input, - normalizedInput: input.trim().toUpperCase(), - verb, - noun, - }, - }; - - return JSON.stringify(envelope); } - private buildStage1BypassAction(input: string): string { + private buildStage1BypassAction(input: string): ParserCascadeEnvelope { const words = input.trim().split(/\s+/); const verb = (words[0] || '').toUpperCase(); const noun = words.slice(1).join(' ').trim(); - const envelope: ParserActionEnvelope = { + const envelope: ParserCascadeEnvelope = { stage: 'regex-v1', - actions: [ - { - type: 'handoff', - reason: 'stage1_disabled', - verb, - noun, - rawInput: input, - }, - ], + output: { + kind: 'handoff_up', + reason: 'stage1_disabled', + verb, + noun, + rawInput: input, + }, debug: { rawInput: input, normalizedInput: input.trim().toUpperCase(), @@ -256,53 +267,46 @@ export class Parser { noun, }, }; - - return JSON.stringify(envelope); + return envelope; } - private isHandoffAction(actionJson: string): boolean { - try { - const envelope = JSON.parse(actionJson) as ParserActionEnvelope; - return envelope.actions.length === 1 && envelope.actions[0]?.type === 'handoff'; - } catch { - return false; - } + private isHandoffEnvelope(envelope: ParserCascadeEnvelope): boolean { + return envelope.output.kind === 'handoff_up'; } - private executeActionJson(actionJson: string): string { - const envelope = JSON.parse(actionJson) as ParserActionEnvelope; + private executeEnvelope(envelope: ParserCascadeEnvelope): string { const executedActions: string[] = []; - if (!envelope.actions.length) { + if (envelope.output.kind === 'handoff_up') { + const result: ParserResult = { + type: 'handoff', + handled: false, + outcomes: [], + actionsExecuted: executedActions, + reason: envelope.output.reason, + debug: { + envelope, + }, + }; + return JSON.stringify(result); + } + + const actions = envelope.output.actions; + if (!actions.length) { const result: ParserResult = { type: 'handoff', handled: false, outcomes: [], actionsExecuted: executedActions, reason: 'empty_action_plan', - debug: { actionJson }, + debug: { envelope }, }; return JSON.stringify(result); } const outcomes: GameActionOutcome[] = []; - for (const action of envelope.actions) { - if (action.type === 'handoff') { - const result: ParserResult = { - type: 'handoff', - handled: false, - outcomes, - actionsExecuted: executedActions, - reason: action.reason, - debug: { - actionJson, - action, - }, - }; - return JSON.stringify(result); - } - + for (const action of actions) { const outcome = this.executeParserAction(action); executedActions.push(this.getExecutedActionName(action)); outcomes.push(outcome); @@ -335,13 +339,6 @@ export class Parser { return this.game.showInventory(); case 'goToTarget': return this.resolveGoToTarget(action.target); - case 'handoff': - return { - status: 'escalate', - code: action.reason, - message: this.game.text('parser.parse_unknown'), - recoverable: true, - }; default: return { status: 'escalate', @@ -366,8 +363,6 @@ export class Parser { return 'showInventory'; case 'goToTarget': return 'goTo'; - case 'handoff': - return 'handoff'; default: return 'unknown'; } @@ -380,7 +375,10 @@ export class Parser { private getEntityLookupTokens(entity: Entity): string[] { const title = this.getPlayerFacingEntityTitle(entity); - return title ? [title.toUpperCase()] : []; + const synonyms = this.game.textAssets.getResolvedObjectListField(entity as any, 'synonyms'); + return Array.from( + new Set([title, ...synonyms].filter((item): item is string => !!item && !!item.trim())) + ).map((item) => item.toUpperCase()); } private getResolutionOptionTitles(entities: Entity[]): string[] | null { @@ -391,54 +389,30 @@ export class Parser { return Array.from(new Set(titles)); } - private getSceneEntitiesForResolution(options: { includeTakeablesOnly: boolean }): Entity[] { - const scene = this.game.sceneManager.currentScene; - if (!scene) return []; - - return (scene.entities || []).filter((entity: Entity) => { - if (entity.disabled || !this.getPlayerFacingEntityTitle(entity)) return false; - if (!options.includeTakeablesOnly) return true; - const isItem = entity.components && entity.components.find((c: any) => c.type === 'Item'); - return !!isItem || !!entity.isTakeable; - }); - } - - private getInventoryEntitiesForResolution(): Entity[] { - return (this.game.inventory || []).filter( - (entity: Entity) => !!this.getPlayerFacingEntityTitle(entity) - ); + private getScopeCandidates(sliceNames: Array<keyof Omit<ParserScope, 'sceneTargets'>>): Entity[] { + const scope = this.activeScope || this.worldModelBuilder.build('', this.pendingState).scope; + const candidates: Entity[] = []; + for (const sliceName of sliceNames) { + candidates.push(...scope[sliceName]); + } + return Array.from(new Set(candidates)); } - private resolveSceneEntityTarget( + private resolveEntityTargetInCandidates( rawTarget: string, - options: { - includeTakeablesOnly: boolean; - includeInventory: boolean; - clarificationKey: string; - } + candidates: Entity[], + clarificationKey: string ): | { status: 'found'; entity: Entity } | { status: 'not_found' } | { status: 'ambiguous'; message: string; options: string[] } | { status: 'escalate'; code: string } { - const scene = this.game.sceneManager.currentScene; - if (!scene) return { status: 'not_found' }; - const normalizedTarget = String(rawTarget || '') .trim() .toUpperCase(); if (!normalizedTarget) return { status: 'not_found' }; - const sceneCandidates = this.getSceneEntitiesForResolution({ - includeTakeablesOnly: options.includeTakeablesOnly, - }); - const inventoryCandidates = options.includeInventory - ? this.getInventoryEntitiesForResolution() - : []; - const exactCandidates = Array.from(new Set([...sceneCandidates, ...inventoryCandidates])); - const partialCandidates = exactCandidates; - - const exactMatches = exactCandidates.filter((entity: Entity) => + const exactMatches = candidates.filter((entity: Entity) => this.getEntityLookupTokens(entity).includes(normalizedTarget) ); if (exactMatches.length === 1) return { status: 'found', entity: exactMatches[0] }; @@ -447,14 +421,14 @@ export class Parser { if (!optionTitles) return { status: 'escalate', code: 'ambiguous_targets_missing_titles' }; return { status: 'ambiguous', - message: this.game.text(options.clarificationKey, { options: optionTitles.join(', ') }), + message: this.game.text(clarificationKey, { options: optionTitles.join(', ') }), options: optionTitles, }; } - const partialMatches = partialCandidates.filter((entity: Entity) => { - const title = this.getPlayerFacingEntityTitle(entity); - return !!title && title.toUpperCase().includes(normalizedTarget); + const partialMatches = candidates.filter((entity: Entity) => { + const lookupTokens = this.getEntityLookupTokens(entity); + return lookupTokens.some((token) => token.includes(normalizedTarget)); }); if (partialMatches.length === 1) return { status: 'found', entity: partialMatches[0] }; if (partialMatches.length > 1) { @@ -462,7 +436,7 @@ export class Parser { if (!optionTitles) return { status: 'escalate', code: 'ambiguous_targets_missing_titles' }; return { status: 'ambiguous', - message: this.game.text(options.clarificationKey, { options: optionTitles.join(', ') }), + message: this.game.text(clarificationKey, { options: optionTitles.join(', ') }), options: optionTitles, }; } @@ -475,7 +449,8 @@ export class Parser { .trim() .toUpperCase(); if (!normalized) return null; - for (const descriptor of this.game.sceneManager.sceneRegistry.values()) { + const scope = this.activeScope || this.worldModelBuilder.build('', this.pendingState).scope; + for (const descriptor of scope.sceneTargets) { if ( descriptor.id.toUpperCase() === normalized || descriptor.name.toUpperCase() === normalized || @@ -488,11 +463,11 @@ export class Parser { } private resolveLookTarget(rawTarget: string): GameActionOutcome { - const resolved = this.resolveSceneEntityTarget(rawTarget, { - includeTakeablesOnly: false, - includeInventory: true, - clarificationKey: 'parser.look_which_one', - }); + const resolved = this.resolveEntityTargetInCandidates( + rawTarget, + this.getScopeCandidates(['visible', 'held']), + 'parser.look_which_one' + ); if (resolved.status === 'escalate') { return { status: 'escalate', code: resolved.code, recoverable: true }; } @@ -527,11 +502,11 @@ export class Parser { }; } - const resolved = this.resolveSceneEntityTarget(rawTarget, { - includeTakeablesOnly: false, - includeInventory: true, - clarificationKey: 'parser.examine_which_one', - }); + const resolved = this.resolveEntityTargetInCandidates( + rawTarget, + this.getScopeCandidates(['examinable']), + 'parser.examine_which_one' + ); if (resolved.status === 'escalate') { return { status: 'escalate', code: resolved.code, recoverable: true }; } @@ -565,18 +540,18 @@ export class Parser { recoverable: true, }; } - const resolved = this.resolveSceneEntityTarget(rawTarget, { - includeTakeablesOnly: true, - includeInventory: false, - clarificationKey: 'parser.take_which_one', - }); + const resolved = this.resolveEntityTargetInCandidates( + rawTarget, + this.getScopeCandidates(['takable']), + 'parser.take_which_one' + ); const broadResolved = resolved.status === 'not_found' - ? this.resolveSceneEntityTarget(rawTarget, { - includeTakeablesOnly: false, - includeInventory: false, - clarificationKey: 'parser.take_which_one', - }) + ? this.resolveEntityTargetInCandidates( + rawTarget, + this.getScopeCandidates(['visible']), + 'parser.take_which_one' + ) : null; if (resolved.status === 'escalate' || broadResolved?.status === 'escalate') { @@ -645,11 +620,11 @@ export class Parser { return this.game.goToScene(sceneMatch.id); } - const resolved = this.resolveSceneEntityTarget(rawTarget, { - includeTakeablesOnly: false, - includeInventory: false, - clarificationKey: 'parser.go_to_which_one', - }); + const resolved = this.resolveEntityTargetInCandidates( + rawTarget, + this.getScopeCandidates(['visible']), + 'parser.go_to_which_one' + ); if (resolved.status === 'escalate') { return { status: 'escalate', code: resolved.code, recoverable: true }; } @@ -751,7 +726,7 @@ export class Parser { private extractRawInput(actionJson: string): string { try { - const envelope = JSON.parse(actionJson) as ParserActionEnvelope; + const envelope = JSON.parse(actionJson) as ParserCascadeEnvelope; return envelope.debug.rawInput; } catch { return ''; @@ -760,8 +735,11 @@ export class Parser { private extractPendingIntent(actionJson: string): 'look' | 'examine' | 'take' | 'goTo' { try { - const envelope = JSON.parse(actionJson) as ParserActionEnvelope; - const firstAction = envelope.actions[0]; + const envelope = JSON.parse(actionJson) as ParserCascadeEnvelope; + if (envelope.output.kind !== 'plan') { + return 'take'; + } + const firstAction = envelope.output.actions[0]; if ( firstAction && (firstAction.type === 'lookTarget' || diff --git a/src/mechanics/ParserWorldModelBuilder.ts b/src/mechanics/ParserWorldModelBuilder.ts new file mode 100644 index 0000000..3611a0e --- /dev/null +++ b/src/mechanics/ParserWorldModelBuilder.ts @@ -0,0 +1,120 @@ +import type { Game } from '../core/Game'; +import type { Entity } from '../entities/Entity'; +import { ComponentSystem } from '../systems/ComponentSystem'; +import type { + ParserContext, + ParserEntityContext, + ParserInventoryItemContext, + ParserPendingState, + ParserScope, + ParserWorldModel, +} from './parserTypes'; + +export class ParserWorldModelBuilder { + private readonly game: Game; + + constructor(game: Game) { + this.game = game; + } + + build(rawInput: string, pendingState: ParserPendingState | null): ParserWorldModel { + return { + context: this.buildContext(rawInput, pendingState), + scope: this.buildScope(), + }; + } + + private buildContext(rawInput: string, pendingState: ParserPendingState | null): ParserContext { + const scene = this.game.sceneManager.currentScene; + const normalizedInput = rawInput.trim().toUpperCase(); + + return { + rawInput, + normalizedInput, + scene: scene + ? { + id: scene.id, + name: scene.name, + title: this.game.textAssets.getResolvedSceneField(scene, 'title'), + description: this.game.textAssets.getResolvedSceneField(scene, 'description'), + } + : null, + entities: scene + ? (scene.entities || []) + .map((entity: any) => ({ + id: entity.name, + type: entity.type, + title: this.game.textAssets.getResolvedObjectField(entity, 'title'), + synonyms: this.game.textAssets.getResolvedObjectListField(entity, 'synonyms'), + description: this.game.textAssets.getResolvedObjectField(entity, 'description'), + details: this.game.textAssets.getResolvedObjectField(entity, 'details'), + interactions: Object.keys(entity.interactions || {}), + })) + .filter((entity: ParserEntityContext) => !!entity.title?.trim()) + : [], + inventory: (this.game.inventory || []) + .map((entity: any) => ({ + id: entity.name, + title: this.game.textAssets.getResolvedObjectField(entity, 'title'), + synonyms: this.game.textAssets.getResolvedObjectListField(entity, 'synonyms'), + description: this.game.textAssets.getResolvedObjectField(entity, 'description'), + details: this.game.textAssets.getResolvedObjectField(entity, 'details'), + })) + .filter((entity: ParserInventoryItemContext) => !!entity.title?.trim()), + pending: pendingState + ? { + intent: pendingState.intent, + question: pendingState.question, + originalInput: pendingState.originalInput, + } + : null, + }; + } + + private buildScope(): ParserScope { + const scene = this.game.sceneManager.currentScene; + const visible = scene + ? (scene.entities || []).filter( + (entity: Entity) => !entity.disabled && !!this.getPlayerFacingEntityTitle(entity) + ) + : []; + const held = (this.game.inventory || []).filter( + (entity: Entity) => !!this.getPlayerFacingEntityTitle(entity) + ); + const takable = visible.filter((entity: Entity) => { + const isItem = + entity.components && entity.components.find((component: any) => component.type === 'Item'); + return !!isItem || !!entity.isTakeable; + }); + const subscene = scene?.activeSubscene + ? visible.filter((entity: Entity) => scene.subsceneEntities.has(entity as any)) + : []; + const reachable = scene + ? visible.filter( + (entity: Entity) => + !ComponentSystem.getInteractionDistanceError(entity as any, scene.player) + ) + : []; + const examinable = this.uniqueEntities([...held, ...subscene, ...reachable]); + const sceneTargets = Array.from(this.game.sceneManager.sceneRegistry.values()); + + return { + visible, + held, + takable, + reachable, + examinable, + subscene, + sceneTargets, + }; + } + + private getPlayerFacingEntityTitle(entity: Entity): string | null { + const title = this.game.textAssets.getResolvedObjectField(entity, 'title'); + return title && title.trim() ? title.trim() : null; + } + + private uniqueEntities(entities: Entity[]): Entity[] { + return Array.from(new Set(entities)); + } +} diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index b050c17..2d141a2 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -1,9 +1,12 @@ import type { GameActionOutcome } from '../core/GameActionTypes'; +import type { Entity } from '../entities/Entity'; +import type { SceneDescriptor } from '../scene/SceneManager'; export type ParserEntityContext = { id: string; type: string; title: string | null; + synonyms: string[]; description: string | null; details: string | null; interactions: string[]; @@ -12,6 +15,7 @@ export type ParserEntityContext = { export type ParserInventoryItemContext = { id: string; title: string | null; + synonyms: string[]; description: string | null; details: string | null; }; @@ -36,6 +40,21 @@ export type ParserContext = { pending: ParserPendingState | null; }; +export type ParserScope = { + visible: Entity[]; + held: Entity[]; + takable: Entity[]; + reachable: Entity[]; + examinable: Entity[]; + subscene: Entity[]; + sceneTargets: SceneDescriptor[]; +}; + +export type ParserWorldModel = { + context: ParserContext; + scope: ParserScope; +}; + export type ParserToolAction = | { type: 'lookScene'; @@ -58,18 +77,22 @@ export type ParserToolAction = | { type: 'goToTarget'; target: string | null; - } - | { - type: 'handoff'; - reason: string; - verb: string; - noun: string; - rawInput: string; }; -export type ParserActionEnvelope = { +export type ParserCascadeEnvelope = { stage: 'regex-v1' | 'pending-resolution' | 'nlp-v2'; - actions: ParserToolAction[]; + output: + | { + kind: 'plan'; + actions: ParserToolAction[]; + } + | { + kind: 'handoff_up'; + reason: string; + rawInput: string; + verb: string; + noun: string; + }; debug: { rawInput: string; normalizedInput: string; diff --git a/tasks.md b/tasks.md new file mode 100644 index 0000000..a1cc4b7 --- /dev/null +++ b/tasks.md @@ -0,0 +1,65 @@ +# Parser Tasks + +## Current Scope + +These tasks cover the parser roadmap described in `Parser.md`, excluding the future LLM cascade. + +## Backlog + +- [x] Replace the separate `ParserContextBuilder` / `ParserScopeBuilder` idea with one `ParserWorldModelBuilder` that returns both `context` and `scope`. +- [x] Define explicit scope slices: + - `visible` + - `held` + - `takable` + - `reachable` + - `examinable` + - `subscene` + - `sceneTargets` +- [x] Replace ad-hoc resolution helpers with scope-driven resolution. +- [x] Unify stage outputs so `Stage 1.1` and `Stage 1.2` emit the same Core-facing envelope. +- [ ] Refactor `Parser Core` around the unified envelope/protocol. +- [ ] Separate pre-API escalation from post-API escalation in `Parser Core`. +- [ ] Support linear plan execution in `Parser Core` without requiring LLM. +- [ ] Add optional `synonyms` to object TA schema. +- [x] Include `synonyms` in the default object TA template. +- [x] Extend parser target resolution to use: + - `title` + - `synonyms` + - partial matching + - clarification on ambiguity +- [ ] Expand `#PEEK` debug output with: + - scope data + - unified envelope data + - Core decision data +- [ ] Verify that UI, scripts, and game logic continue using the same shared `Game API`. +- [ ] Add regression tests / smoke checks for: + - `#STAGE1-ON/OFF` + - `#STAGE2-ON/OFF` + - clarification flows + - inventory-aware resolution + - `synonyms` + - pre-API escalation + - post-API escalation + - linear plan execution without LLM + +## Suggested Order + +1. Extract a single world-model builder that produces context and scope together. +2. Unify cascade envelopes. +3. Refactor `Parser Core` around the unified protocol. +4. Add `synonyms` support to TA and target resolution. +5. Improve `#PEEK`. +6. Run regression checks and clean up boundaries with `Game API`. + +## Plan For Step 3 + +- [x] Define a single `CascadeEnvelope` shape that both `Stage 1.1` and `Stage 1.2` emit. +- [ ] Replace the current action/handoff JSON split with the unified envelope. +- [ ] Make `Parser Core` consume the unified envelope directly instead of inferring behavior from ad-hoc action types. +- [ ] Split `Parser Core` flow into explicit phases: + - envelope intake + - pre-API validation/resolution + - API plan execution + - post-API outcome analysis +- [ ] Introduce a minimal linear plan execution path in `Core` for non-LLM producers. +- [ ] Expose enough debug data in `#PEEK` to inspect envelope and Core decisions while refactoring. From 30e1360b8497a85d9fee4c14f2d9c67b7648cd80 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 16 Mar 2026 21:13:36 +0200 Subject: [PATCH 21/75] Refactor: unify parser core envelope flow --- src/mechanics/Parser.ts | 108 +++++++++++++++++++++++++++-------- src/mechanics/parserTypes.ts | 14 +++++ tasks.md | 18 +++--- 3 files changed, 108 insertions(+), 32 deletions(-) diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index a3ca159..1f6c546 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -11,6 +11,7 @@ import type { Entity } from '../entities/Entity'; import type { SceneDescriptor } from '../scene/SceneManager'; import type { ParserCascadeEnvelope, + ParserCoreDecision, ParserPendingState, ParserResponse, ParserResult, @@ -45,6 +46,7 @@ export class Parser { const context = worldModel.context; this.activeScope = worldModel.scope; const contextJson = JSON.stringify(context); + const scopeJson = JSON.stringify(this.buildPeekScopeSummary(worldModel.scope)); let envelope = actionEnvelope || (this.game.console?.parserStage1Enabled === false @@ -63,8 +65,8 @@ export class Parser { } const envelopeJson = JSON.stringify(envelope); - const resultJson = this.executeEnvelope(envelope); - const response = this.buildResponse(resultJson, envelopeJson, contextJson); + const resultJson = this.runParserCore(envelope); + const response = this.buildResponse(resultJson, envelopeJson, contextJson, scopeJson); if (response.debugMessages?.length) { for (const message of response.debugMessages) { @@ -274,36 +276,78 @@ export class Parser { return envelope.output.kind === 'handoff_up'; } - private executeEnvelope(envelope: ParserCascadeEnvelope): string { - const executedActions: string[] = []; + private runParserCore(envelope: ParserCascadeEnvelope): string { + const decision = this.makeCoreDecision(envelope); + return this.executeCoreDecision(decision); + } + private makeCoreDecision(envelope: ParserCascadeEnvelope): ParserCoreDecision { if (envelope.output.kind === 'handoff_up') { + return { + kind: 'handoff_up', + reason: envelope.output.reason, + envelope, + }; + } + + return { + kind: 'execute_plan', + envelope, + actions: envelope.output.actions, + }; + } + + private executeCoreDecision(decision: ParserCoreDecision): string { + const executedActions: string[] = []; + + if (decision.kind === 'handoff_up') { const result: ParserResult = { type: 'handoff', handled: false, outcomes: [], actionsExecuted: executedActions, - reason: envelope.output.reason, + reason: decision.reason, + coreDecision: decision, debug: { - envelope, + envelope: decision.envelope, + phase: 'pre_api', }, }; return JSON.stringify(result); } - const actions = envelope.output.actions; - if (!actions.length) { + if (!decision.actions.length) { const result: ParserResult = { type: 'handoff', handled: false, outcomes: [], actionsExecuted: executedActions, reason: 'empty_action_plan', - debug: { envelope }, + coreDecision: decision, + debug: { + envelope: decision.envelope, + phase: 'pre_api', + }, }; return JSON.stringify(result); } + const outcomes = this.executeCorePlan(decision.actions, executedActions); + + const result: ParserResult = { + type: 'outcomes', + handled: true, + outcomes, + actionsExecuted: executedActions, + coreDecision: decision, + }; + return JSON.stringify(result); + } + + private executeCorePlan( + actions: ParserToolAction[], + executedActions: string[] + ): GameActionOutcome[] { const outcomes: GameActionOutcome[] = []; for (const action of actions) { @@ -316,13 +360,7 @@ export class Parser { } } - const result: ParserResult = { - type: 'outcomes', - handled: true, - outcomes, - actionsExecuted: executedActions, - }; - return JSON.stringify(result); + return outcomes; } private executeParserAction(action: ParserToolAction): GameActionOutcome { @@ -651,15 +689,19 @@ export class Parser { private buildResponse( resultJson: string, - actionJson: string, - contextJson: string + envelopeJson: string, + contextJson: string, + scopeJson: string ): ParserResponse { const result = JSON.parse(resultJson) as ParserResult; const nlpDebug = this.nlpCascade.getLastDebugInfo(); + const coreDecisionJson = result.coreDecision ? JSON.stringify(result.coreDecision) : undefined; const peekMessages = this.game.console?.parserPeekEnabled ? [ `[Parser peek] context=${contextJson}`, - `[Parser peek] actions=${actionJson}`, + `[Parser peek] scope=${scopeJson}`, + `[Parser peek] envelope=${envelopeJson}`, + ...(coreDecisionJson ? [`[Parser peek] core=${coreDecisionJson}`] : []), `[Parser peek] result=${resultJson}`, ...(nlpDebug ? [`[Parser peek] nlp=${JSON.stringify(nlpDebug)}`] : []), ] @@ -671,7 +713,9 @@ export class Parser { nextPendingState: null, debugMessages: peekMessages || [ `[Parser handoff] context=${contextJson}`, - `[Parser handoff] actions=${actionJson}`, + `[Parser handoff] scope=${scopeJson}`, + `[Parser handoff] envelope=${envelopeJson}`, + ...(coreDecisionJson ? [`[Parser handoff] core=${coreDecisionJson}`] : []), `[Parser handoff] result=${resultJson}`, ], }; @@ -684,9 +728,9 @@ export class Parser { return { playerMessage: clarification.message || this.game.text('parser.parse_unknown'), nextPendingState: { - intent: this.extractPendingIntent(actionJson), + intent: this.extractPendingIntent(envelopeJson), question: clarification.message || this.game.text('parser.parse_unknown'), - originalInput: this.extractRawInput(actionJson), + originalInput: this.extractRawInput(envelopeJson), }, debugMessages: peekMessages, }; @@ -699,7 +743,9 @@ export class Parser { nextPendingState: null, debugMessages: peekMessages || [ `[Parser handoff] context=${contextJson}`, - `[Parser handoff] actions=${actionJson}`, + `[Parser handoff] scope=${scopeJson}`, + `[Parser handoff] envelope=${envelopeJson}`, + ...(coreDecisionJson ? [`[Parser handoff] core=${coreDecisionJson}`] : []), `[Parser handoff] result=${resultJson}`, ], }; @@ -768,4 +814,20 @@ export class Parser { const firstWord = trimmed.split(/\s+/)[0]?.toUpperCase() || ''; return getStage1CommandWords(this.game.textAssets.getParserLexicon()).has(firstWord); } + + private buildPeekScopeSummary(scope: ParserScope): Record<string, unknown> { + return { + visible: scope.visible.map((entity) => entity.name), + held: scope.held.map((entity) => entity.name), + takable: scope.takable.map((entity) => entity.name), + reachable: scope.reachable.map((entity) => entity.name), + examinable: scope.examinable.map((entity) => entity.name), + subscene: scope.subscene.map((entity) => entity.name), + sceneTargets: scope.sceneTargets.map((scene) => ({ + id: scene.id, + name: scene.name, + title: scene.title, + })), + }; + } } diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index 2d141a2..5fef2ba 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -111,6 +111,7 @@ export type ParserResult = handled: boolean; outcomes: GameActionOutcome[]; actionsExecuted: string[]; + coreDecision?: ParserCoreDecision; } | { type: 'handoff'; @@ -119,6 +120,19 @@ export type ParserResult = actionsExecuted: string[]; reason: string; debug: Record<string, unknown>; + coreDecision?: ParserCoreDecision; + }; + +export type ParserCoreDecision = + | { + kind: 'handoff_up'; + reason: string; + envelope: ParserCascadeEnvelope; + } + | { + kind: 'execute_plan'; + envelope: ParserCascadeEnvelope; + actions: ParserToolAction[]; }; export type ParserResponse = { diff --git a/tasks.md b/tasks.md index a1cc4b7..596b711 100644 --- a/tasks.md +++ b/tasks.md @@ -17,9 +17,9 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut - `sceneTargets` - [x] Replace ad-hoc resolution helpers with scope-driven resolution. - [x] Unify stage outputs so `Stage 1.1` and `Stage 1.2` emit the same Core-facing envelope. -- [ ] Refactor `Parser Core` around the unified envelope/protocol. -- [ ] Separate pre-API escalation from post-API escalation in `Parser Core`. -- [ ] Support linear plan execution in `Parser Core` without requiring LLM. +- [x] Refactor `Parser Core` around the unified envelope/protocol. +- [x] Separate pre-API escalation from post-API escalation in `Parser Core`. +- [x] Support linear plan execution in `Parser Core` without requiring LLM. - [ ] Add optional `synonyms` to object TA schema. - [x] Include `synonyms` in the default object TA template. - [x] Extend parser target resolution to use: @@ -27,7 +27,7 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut - `synonyms` - partial matching - clarification on ambiguity -- [ ] Expand `#PEEK` debug output with: +- [x] Expand `#PEEK` debug output with: - scope data - unified envelope data - Core decision data @@ -54,12 +54,12 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut ## Plan For Step 3 - [x] Define a single `CascadeEnvelope` shape that both `Stage 1.1` and `Stage 1.2` emit. -- [ ] Replace the current action/handoff JSON split with the unified envelope. -- [ ] Make `Parser Core` consume the unified envelope directly instead of inferring behavior from ad-hoc action types. -- [ ] Split `Parser Core` flow into explicit phases: +- [x] Replace the current action/handoff JSON split with the unified envelope. +- [x] Make `Parser Core` consume the unified envelope directly instead of inferring behavior from ad-hoc action types. +- [x] Split `Parser Core` flow into explicit phases: - envelope intake - pre-API validation/resolution - API plan execution - post-API outcome analysis -- [ ] Introduce a minimal linear plan execution path in `Core` for non-LLM producers. -- [ ] Expose enough debug data in `#PEEK` to inspect envelope and Core decisions while refactoring. +- [x] Introduce a minimal linear plan execution path in `Core` for non-LLM producers. +- [x] Expose enough debug data in `#PEEK` to inspect envelope and Core decisions while refactoring. From dc2d770c816828c873fd12666b066667a84832d2 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 16 Mar 2026 22:25:29 +0200 Subject: [PATCH 22/75] Docs: sync Parser.md with current implementation --- Parser.md | 141 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 117 insertions(+), 24 deletions(-) diff --git a/Parser.md b/Parser.md index 7075a62..3328c99 100644 --- a/Parser.md +++ b/Parser.md @@ -41,13 +41,13 @@ Parser должен: Ключевой момент: - `Player Input` и `Parser Context` — это **две отдельные сущности**; -- `Context Builder` работает только от состояния игры; +- `ParserWorldModelBuilder` работает только от состояния игры; - ввод игрока проходит каскады **последовательно**, а не параллельно. ```mermaid flowchart TD GS[Game state] - CB[Context builder] + CB[ParserWorldModelBuilder] CTX[Parser context] U[Input] L1[Stage1 Regex] @@ -109,11 +109,13 @@ flowchart TD Это не parser-слой. Это слой движка. -### 2. Context Builder +### 2. ParserWorldModelBuilder -`Context Builder` не использует ввод игрока. +`ParserWorldModelBuilder` не использует ввод игрока для определения intent или target. -Он получает только состояние игры и строит `Parser Context`: упрощённый снимок мира, пригодный для parser-а. +Он получает состояние игры, а также metadata текущего parser-цикла, и строит единый `ParserWorldModel`: +- `context` +- `scope` Текущий context включает: - `rawInput` и `normalizedInput` как metadata текущего цикла parser-а; @@ -122,8 +124,17 @@ flowchart TD - инвентарь игрока; - `pending state`, если parser уже ждёт уточнение. +Текущий scope включает: +- `visible` +- `held` +- `takable` +- `reachable` +- `examinable` +- `subscene` +- `sceneTargets` + Важно: -- `Context Builder` не интерпретирует пользовательский ввод; +- `ParserWorldModelBuilder` не интерпретирует пользовательский ввод; - он не выбирает intent; - он не определяет target; - он лишь даёт parser-у картину мира. @@ -155,9 +166,9 @@ flowchart TD } ``` -### 3. Scope Builder +### 3. Scope Model -`Scope` — это не отдельный каскад, а структурированная часть context. +`Scope` — это не отдельный каскад и не отдельный runtime subsystem, а структурированная часть `ParserWorldModel`. То есть: - `context` = всё, что parser знает о мире; @@ -169,7 +180,7 @@ flowchart TD - `EXAMINE` использует инвентарь, объекты активной subscene и объекты в пределах допустимой дистанции; - `GO TO` использует сценовые цели и достижимые сценовые объекты. -Планируемая модель scope: +Текущая модель scope: ```ts type ParserScope = { @@ -294,7 +305,7 @@ flowchart TD `Parser Core` — центральный оркестратор всей системы. Он получает: -- action candidate от активного каскада; +- cascade envelope от активного каскада; - outcomes от `Game API` по отдельному каналу. Именно `Core` принимает решения: @@ -309,7 +320,7 @@ flowchart TD ```mermaid flowchart TD - IN[Action candidate] + IN[Cascade envelope] OUT[API outcomes] CORE[Parser Core] RES[Resolve and validate] @@ -371,9 +382,11 @@ Parser сначала проверяет: - не является ли ввод продолжением уже незавершённой команды; - или это новая команда. -### Step 3. Context is built +### Step 3. World model is built -`Context Builder` строит `Parser Context` из состояния игры. +`ParserWorldModelBuilder` строит `ParserWorldModel` из состояния игры: +- `context` +- `scope` ### Step 4. Stage 1 runs sequentially @@ -383,7 +396,7 @@ Parser сначала проверяет: ### Step 5. Core resolves, validates, and decides -`Core` получает action candidate, применяет context/scope, и решает: +`Core` получает cascade envelope, применяет context/scope, и решает: - можно ли продолжать; - нужен ли API call block; - нужен ли clarification; @@ -453,18 +466,25 @@ Parser сначала проверяет: --- -## Current Actions +## Current Envelope And Actions + +Сейчас lower cascades (`Stage 1.1` и `Stage 1.2`) уже отдают единый `ParserCascadeEnvelope`. -Текущие action types parser-а: +Текущие `ParserToolAction`: - `lookScene` - `lookTarget` - `examineTarget` - `takeTarget` - `showInventory` - `goToTarget` -- `handoff` -Нижние каскады сейчас обычно выдают именно такие действия. +Текущий envelope имеет вид: +- `output.kind = 'plan'` +- `output.kind = 'handoff_up'` + +То есть: +- handoff больше не кодируется отдельным fake-action; +- `Parser Core` принимает envelope напрямую и сам решает, что это значит до API. --- @@ -766,10 +786,19 @@ sequenceDiagram При `#PEEK-ON` parser выводит: - `context=...` -- `actions=...` +- `scope=...` +- `envelope=...` +- `core=...` - `result=...` - `nlp=...` при участии NLP-слоя +Это даёт возможность смотреть отдельно: +- world model snapshot; +- scope slices; +- cascade output; +- решение `Core`; +- итоговые outcomes. + ### Stage toggles Можно изолированно тестировать разные уровни: @@ -906,24 +935,86 @@ type ParserRelation = { - `src/mechanics/Parser.ts` - главный orchestrator parser-а - - context building - stage orchestration - target resolution - pending clarification + - unified envelope intake + - `Parser Core` + - pre-API decision making + - linear plan execution - response building +- `src/mechanics/ParserWorldModelBuilder.ts` + - строит `ParserWorldModel` + - собирает `ParserContext` + - собирает `ParserScope` + - добавляет parser-facing данные по: + - scene entities + - inventory + - subscene + - scene registry + - object `synonyms` + - `src/mechanics/NlpCascade.ts` - Stage 1.2 (`NLP.js`) - intent recognition + target cleanup + - возвращает тот же `ParserCascadeEnvelope`, что и regex-слой + +- `src/mechanics/parserLanguage.ts` + - stage1 lexicon helpers + - command matching + - target normalization + - parser language-pack access helpers + +- `src/mechanics/parserTypes.ts` + - parser-facing types + - `ParserWorldModel` + - `ParserScope` + - `ParserCascadeEnvelope` + - `ParserCoreDecision` + - `ParserToolAction` - `src/core/Game.ts` - semantic runtime tools - world operations on resolved scene/entity targets - access checks and structured outcomes +- `src/core/IGame.ts` + - shared `Game API` contract used by parser and other clients + - `src/core/TextAssetManager.ts` - service text assets - scene/object text resolution + - parser lexicon assets + - parser training assets + - object list fields such as `synonyms` + +- `src/core/Console.ts` + - console command handling before gameplay parser + - gameplay input preprocessor + - stage toggles: + - `#STAGE1-ON/OFF` + - `#STAGE2-ON/OFF` + - parser debug toggle: + - `#PEEK-ON/OFF` + +- `src/components/UIOverlay.tsx` + - entry point from UI input to console preprocessor and gameplay parser + +### Code Map By Architecture Block + +| Architecture block | Main files | Key methods / responsibilities | +|---|---|---| +| Player input entry | `src/components/UIOverlay.tsx`, `src/core/Console.ts` | `UIOverlay` routes typed input into `console.preprocessGameplayInput(...)` before parser execution | +| Console preprocessor | `src/core/Console.ts` | `preprocessGameplayInput(...)`, stage toggles, shorthand expansion | +| World model builder | `src/mechanics/ParserWorldModelBuilder.ts` | `build(...)` returns `{ context, scope }` | +| Stage 1.1 regex | `src/mechanics/Parser.ts`, `src/mechanics/parserLanguage.ts` | `runStage1(...)`, `matchStage1Intent(...)`, `normalizeTargetForIntent(...)` | +| Stage 1.2 NLP | `src/mechanics/NlpCascade.ts` | `parse(...)`, training on parser language assets, envelope generation | +| Parser Core | `src/mechanics/Parser.ts` | `runParserCore(...)`, `makeCoreDecision(...)`, `executeCoreDecision(...)`, `executeCorePlan(...)` | +| Scope-driven resolution | `src/mechanics/Parser.ts` | `resolveLookTarget(...)`, `resolveExamineTarget(...)`, `resolveTakeTarget(...)`, `resolveGoToTarget(...)`, `resolveEntityTargetInCandidates(...)` | +| Shared gameplay API | `src/core/Game.ts`, `src/core/IGame.ts` | `lookScene(...)`, `lookEntity(...)`, `examineEntity(...)`, `takeEntity(...)`, `goToScene(...)`, `goToEntity(...)`, `showInventory()` | +| Text assets | `src/core/TextAssetManager.ts`, `public/text/system/*.json` | `getParserLexicon()`, `getParserTraining()`, `getResolvedObjectListField(...)` | +| Parser debugging | `src/mechanics/Parser.ts`, `src/core/Console.ts` | `#PEEK`, stage toggles, debug output for `scope/envelope/core/result/nlp` | ### Separation of concerns @@ -955,7 +1046,9 @@ flowchart TD - parser-mediator v1; - первый каскад с двумя уровнями (`regex` + `NLP.js`); -- shared action package model; +- unified cascade envelope model; +- `ParserWorldModelBuilder`; +- explicit scope slices; - parser-owned target resolution; - inventory-aware `LOOK` / `EXAMINE`; - отдельный `EXAMINE` + `details`; @@ -963,13 +1056,12 @@ flowchart TD - parser debug via `#PEEK`; - stage toggles via console; - Game API с resolved targets; +- linear plan execution in `Parser Core` for non-LLM producers; - базовая groundwork for future stage-2 DSL. ### Дальше -- explicit `Scope Builder` как отдельный subsystem; - parser relations (`on`, `under`, `in`, `behind`, ...); -- parser language assets for lexicon/training; - richer stage-2 (LLM) handoff; - полноценный DSL execution loop; - более сложные semantic actions (`use`, `open`, `talkTo`, ...); @@ -982,7 +1074,7 @@ flowchart TD 1. Parser — единственный слой, интерпретирующий язык игрока. 2. `Game API` — общий gameplay API для parser-а, UI, scripts и игровой логики. 3. `Game` и runtime не должны парсить текст и резолвить текстовые цели. -4. `Context Builder` строит context только из состояния игры. +4. `ParserWorldModelBuilder` строит world model только из состояния игры. 5. `Player Input` и `Parser Context` — отдельные входы parser-а. 6. Stage processing последовательный, а не параллельный. 7. Первый каскад имеет два внутренних уровня: `regex`, затем `NLP`. @@ -994,5 +1086,6 @@ flowchart TD 13. Object TA может содержать опциональное поле `synonyms` для повышения точности target resolution. 14. Player-facing messages никогда не должны показывать технические `id`. 15. Всё language-specific должно жить в text assets. +16. Console preprocessor работает до gameplay parser-а и отвечает за shorthand-ы и stage toggles. Эта архитектура делает parser фундаментом для постепенного перехода от классического IF-style command parser-а к полноценному Game Master и orchestrator. From 3cc4e8d0ced428931e17a8a57a226905eb9a4c2b Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 16 Mar 2026 22:34:02 +0200 Subject: [PATCH 23/75] Refactor: formalize object text asset synonyms --- ParserSmoke.md | 143 +++++++++++++++++++++++++++++++++++ src/core/TextAssetManager.ts | 59 ++++++++++++--- tasks.md | 3 +- 3 files changed, 193 insertions(+), 12 deletions(-) create mode 100644 ParserSmoke.md diff --git a/ParserSmoke.md b/ParserSmoke.md new file mode 100644 index 0000000..ea359a4 --- /dev/null +++ b/ParserSmoke.md @@ -0,0 +1,143 @@ +# Parser Smoke Checks + +These checks are intended for quick manual regression testing of the current parser stack in `Scanline`, before the future LLM cascade exists. + +Run them in a scene that contains at least: +- one visible object with `title` +- one object with `details` +- one takeable item +- one inventory item after pickup +- one object or scene target addressable via `synonyms` + +## Baseline + +1. Enable parser debug when needed: + - `#PEEK-ON` +2. Ensure both lower layers are enabled: + - `#STAGE1-ON` + - `#STAGE2-ON` + +Expected: +- parser accepts commands normally +- `#PEEK` shows `context`, `scope`, `envelope`, `core`, `result` + +## Stage Toggles + +1. Disable stage 1: + - `#STAGE1-OFF` +2. Enter a phrase that only NLP should understand: + - `go over to the office` +3. Re-enable stage 1: + - `#STAGE1-ON` +4. Disable stage 2: + - `#STAGE2-OFF` +5. Retry the same NLP-only phrase. +6. Re-enable stage 2: + - `#STAGE2-ON` + +Expected: +- with `#STAGE1-OFF`, stage 1 bypasses and stage 2 can still parse +- with `#STAGE2-OFF`, stage 1 handoff does not reach NLP + +## Clarification Flow + +1. `take` +2. respond with a target, for example: + - `key` + +Repeat for: +- `examine` +- `go to` + +Expected: +- parser asks clarification question +- second input is treated as continuation +- `pendingState` clears after completion or failure + +## Inventory-Aware Resolution + +1. Pick up a visible item: + - `take id` +2. Check inventory: + - `i` +3. Look at carried item: + - `look id` + - `look id card` +4. Examine carried item: + - `x id` + +Expected: +- carried item appears in inventory +- `LOOK` and `EXAMINE` can resolve inventory items +- `TAKE` and `GO TO` do not use inventory as target space + +## Synonyms + +Use an object that has `synonyms` in its TA. + +Examples: +- `look logotype` +- `look recorder` +- `take radio` +- `go to tape recorder` + +Expected: +- parser resolves by `title` or `synonyms` +- ambiguity clarification appears if multiple candidates match + +## EXAMINE vs LOOK + +1. `look boombox` +2. `x boombox` + +Expected: +- `LOOK` returns short `description` +- `EXAMINE` returns `details` +- if `details` are missing, parser escalates instead of inventing text locally + +## Pre-API vs Post-API Escalation + +Pre-API example: +- use input unsupported by stage 1 and stage 2 disabled + +Post-API example: +- `x logo` when object has no `details` + +Expected: +- pre-API escalation appears in `core` +- post-API escalation appears in `result.outcomes` + +## Console Preprocessor + +1. `i` +2. `l logo` +3. `x boombox` + +Control case: +- `where i am?` + +Expected: +- `i` becomes `INVENTORY` +- `l ...` becomes `LOOK ...` +- `x ...` becomes `EXAMINE ...` +- normal sentences containing `i` are not rewritten to inventory commands + +## Scope Checks + +With `#PEEK-ON`, verify: +- `visible` contains visible scene entities +- `held` contains inventory entities +- `takable` contains only takeable scene entities +- `examinable` contains held, reachable, and subscene entities +- `sceneTargets` contains registered destination scenes + +## Current Success Criteria + +The smoke run is considered healthy when: +- no parser exceptions appear in the console +- `#PEEK` shows coherent `scope`, `envelope`, and `core` data +- clarification flows work +- inventory-aware resolution works +- synonym resolution works +- stage toggles work +- `LOOK` and `EXAMINE` remain distinct diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 261e856..dc45bc7 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -4,6 +4,16 @@ import type { ParserLexiconAsset, ParserTrainingAsset } from '../mechanics/parse type TextAssetValue = string | string[]; type TextAssetData = Record<string, TextAssetValue>; +export type SceneTextAssetData = TextAssetData & { + title?: string; + description?: string; +}; +export type ObjectTextAssetData = TextAssetData & { + title?: string; + description?: string; + details?: string; + synonyms?: string[]; +}; const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { parser: { @@ -185,8 +195,8 @@ const DEFAULT_PARSER_TRAINING: ParserTrainingAsset = { }; export class TextAssetManager { - private sceneCache = new Map<string, TextAssetData | null>(); - private objectCache = new Map<string, TextAssetData | null>(); + private sceneCache = new Map<string, SceneTextAssetData | null>(); + private objectCache = new Map<string, ObjectTextAssetData | null>(); private serviceCache = new Map<string, TextAssetData>(); private parserLexiconCache: ParserLexiconAsset = structuredClone(DEFAULT_PARSER_LEXICON); private parserTrainingCache: ParserTrainingAsset = structuredClone(DEFAULT_PARSER_TRAINING); @@ -235,7 +245,7 @@ export class TextAssetManager { return this.parserTrainingCache; } - buildDefaultSceneAsset(scene: Scene): TextAssetData { + buildDefaultSceneAsset(scene: Scene): SceneTextAssetData { return { title: scene.name || scene.id || 'Untitled Scene', description: @@ -243,7 +253,7 @@ export class TextAssetManager { }; } - buildDefaultObjectAsset(obj: SceneObject): TextAssetData { + buildDefaultObjectAsset(obj: SceneObject): ObjectTextAssetData { const fallbackTitle = (obj as any).customName || obj.name || obj.type || 'Object'; const fallbackDescription = (obj as any).description || 'You see nothing special.'; return { @@ -291,13 +301,16 @@ export class TextAssetManager { this.objectCache.delete(this.normalizeId(obj.name)); } - async readSceneAsset(scene: Scene, forceReload: boolean = false): Promise<TextAssetData | null> { + async readSceneAsset( + scene: Scene, + forceReload: boolean = false + ): Promise<SceneTextAssetData | null> { const sceneId = this.normalizeId(scene?.id || ''); if (!sceneId) return null; if (!forceReload && this.sceneCache.has(sceneId)) { return this.sceneCache.get(sceneId) || null; } - const data = await this.fetchJson(this.getSceneAssetUrl(sceneId)); + const data = this.normalizeSceneAssetData(await this.fetchJson(this.getSceneAssetUrl(sceneId))); this.sceneCache.set(sceneId, data); return data; } @@ -305,14 +318,16 @@ export class TextAssetManager { async readObjectAsset( obj: SceneObject, forceReload: boolean = false - ): Promise<TextAssetData | null> { + ): Promise<ObjectTextAssetData | null> { if (!obj?.name || obj.type === 'Walkbox') return null; const objectId = this.normalizeId(obj?.name || ''); if (!objectId) return null; if (!forceReload && this.objectCache.has(objectId)) { return this.objectCache.get(objectId) || null; } - const data = await this.fetchJson(this.getObjectAssetUrl(objectId)); + const data = this.normalizeObjectAssetData( + await this.fetchJson(this.getObjectAssetUrl(objectId)) + ); this.objectCache.set(objectId, data); return data; } @@ -484,6 +499,24 @@ export class TextAssetManager { return (await this.fetchUnknownJson(url)) as TextAssetData | null; } + private normalizeSceneAssetData(asset: TextAssetData | null): SceneTextAssetData | null { + if (!asset) return null; + const normalized: SceneTextAssetData = { ...asset }; + if (typeof asset.title === 'string') normalized.title = asset.title; + if (typeof asset.description === 'string') normalized.description = asset.description; + return normalized; + } + + private normalizeObjectAssetData(asset: TextAssetData | null): ObjectTextAssetData | null { + if (!asset) return null; + const normalized: ObjectTextAssetData = { ...asset }; + if (typeof asset.title === 'string') normalized.title = asset.title; + if (typeof asset.description === 'string') normalized.description = asset.description; + if (typeof asset.details === 'string') normalized.details = asset.details; + normalized.synonyms = this.resolveListField(asset, 'synonyms'); + return normalized; + } + private async fetchUnknownJson(url: string): Promise<unknown | null> { try { const response = await fetch(`${url}?t=${Date.now()}`); @@ -548,7 +581,7 @@ export class TextAssetManager { targetObjectId: string ): Promise<void> { const sourceUrl = this.getObjectAssetUrl(sourceObjectId); - const sourceData = await this.fetchJson(sourceUrl); + const sourceData = this.normalizeObjectAssetData(await this.fetchJson(sourceUrl)); if (!sourceData) return; const targetPath = this.getObjectAssetProjectPath(targetObjectId); @@ -573,9 +606,13 @@ export class TextAssetManager { if (!targetSceneId) return; if (sourceSceneId && sourceSceneId !== targetSceneId) { - const targetData = await this.fetchJson(this.getSceneAssetUrl(targetSceneId)); + const targetData = this.normalizeSceneAssetData( + await this.fetchJson(this.getSceneAssetUrl(targetSceneId)) + ); if (!targetData) { - const sourceData = await this.fetchJson(this.getSceneAssetUrl(sourceSceneId)); + const sourceData = this.normalizeSceneAssetData( + await this.fetchJson(this.getSceneAssetUrl(sourceSceneId)) + ); if (sourceData) { await this.saveFile( this.getSceneAssetProjectPath(targetSceneId), diff --git a/tasks.md b/tasks.md index 596b711..bed50e8 100644 --- a/tasks.md +++ b/tasks.md @@ -20,7 +20,7 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut - [x] Refactor `Parser Core` around the unified envelope/protocol. - [x] Separate pre-API escalation from post-API escalation in `Parser Core`. - [x] Support linear plan execution in `Parser Core` without requiring LLM. -- [ ] Add optional `synonyms` to object TA schema. +- [x] Add optional `synonyms` to object TA schema. - [x] Include `synonyms` in the default object TA template. - [x] Extend parser target resolution to use: - `title` @@ -41,6 +41,7 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut - pre-API escalation - post-API escalation - linear plan execution without LLM + - manual checklist drafted in `ParserSmoke.md` ## Suggested Order From b1d62bce17feef5672956d19a96acbcd4d3958e4 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 16 Mar 2026 22:45:13 +0200 Subject: [PATCH 24/75] Docs: sync object text asset fields --- Parser.md | 28 ++++++++++++++++++++++++++-- src/core/TextAssetManager.ts | 1 + 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Parser.md b/Parser.md index 3328c99..c9b39bf 100644 --- a/Parser.md +++ b/Parser.md @@ -443,6 +443,12 @@ Parser сначала проверяет: - `Game` не делает disambiguation; - `Game` не разбирает user input. +Уточнение по UI: +- текущий UI-клик по объекту считается корректным, если он показывает player-facing `title` объекта в консоли; +- UI-клик не обязан вызывать `lookEntity(...)`; +- это presentation-level behavior, а не parser semantics; +- в будущем parser-side `LOOK` тоже может использовать схожее перечисление видимых названий объектов, не требуя маршрутизации UI через parser. + Следствие: - на `Scanline` можно сделать не только parser-driven игру; - при расширении полномочий UI на этом же API можно построить чистый point-and-click quest. @@ -528,6 +534,22 @@ Parser: - помогает точнее определять target без обращения к LLM; - должно входить в шаблон нового object TA, даже если список пустой. +Поле `details`: +- является стандартным полем object TA; +- используется действием `EXAMINE`; +- тоже входит в стандартный шаблон нового object TA. + +Стандартный шаблон нового object TA: + +```json +{ + "title": "Object", + "description": "You see nothing special.", + "details": "", + "synonyms": [] +} +``` + ### Inventory-aware resolution Инвентарь является частью доступного текстового мира для non-movement действий. @@ -987,6 +1009,7 @@ type ParserRelation = { - scene/object text resolution - parser lexicon assets - parser training assets + - object fields such as `details` - object list fields such as `synonyms` - `src/core/Console.ts` @@ -1013,7 +1036,7 @@ type ParserRelation = { | Parser Core | `src/mechanics/Parser.ts` | `runParserCore(...)`, `makeCoreDecision(...)`, `executeCoreDecision(...)`, `executeCorePlan(...)` | | Scope-driven resolution | `src/mechanics/Parser.ts` | `resolveLookTarget(...)`, `resolveExamineTarget(...)`, `resolveTakeTarget(...)`, `resolveGoToTarget(...)`, `resolveEntityTargetInCandidates(...)` | | Shared gameplay API | `src/core/Game.ts`, `src/core/IGame.ts` | `lookScene(...)`, `lookEntity(...)`, `examineEntity(...)`, `takeEntity(...)`, `goToScene(...)`, `goToEntity(...)`, `showInventory()` | -| Text assets | `src/core/TextAssetManager.ts`, `public/text/system/*.json` | `getParserLexicon()`, `getParserTraining()`, `getResolvedObjectListField(...)` | +| Text assets | `src/core/TextAssetManager.ts`, `public/text/system/*.json` | `getParserLexicon()`, `getParserTraining()`, `getResolvedObjectField(...)`, `getResolvedObjectListField(...)` | | Parser debugging | `src/mechanics/Parser.ts`, `src/core/Console.ts` | `#PEEK`, stage toggles, debug output for `scope/envelope/core/result/nlp` | ### Separation of concerns @@ -1083,9 +1106,10 @@ flowchart TD 10. `Core` может эскалировать как до API, так и после API. 11. `Core` — центр clarification, orchestration, iteration и final response. 12. DSL/protocol общения с `Core` должен быть единым для всех каскадов, даже если нижние уровни используют только простой subset. -13. Object TA может содержать опциональное поле `synonyms` для повышения точности target resolution. +13. Object TA содержит стандартные parser-relevant поля `title`, `description`, `details`; также может содержать опциональное поле `synonyms` для повышения точности target resolution. 14. Player-facing messages никогда не должны показывать технические `id`. 15. Всё language-specific должно жить в text assets. 16. Console preprocessor работает до gameplay parser-а и отвечает за shorthand-ы и stage toggles. +17. UI-клик по объекту может показывать `title` напрямую, не вызывая parser semantics `LOOK`. Эта архитектура делает parser фундаментом для постепенного перехода от классического IF-style command parser-а к полноценному Game Master и orchestrator. diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index dc45bc7..6d45fd8 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -259,6 +259,7 @@ export class TextAssetManager { return { title: fallbackTitle, description: fallbackDescription, + details: '', synonyms: [], }; } From 96c2b119b4e08222096e4c4ddf74a0489c9f1519 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 16 Mar 2026 23:46:51 +0200 Subject: [PATCH 25/75] Feature: add parser command assets and plan validation --- Commands.md | 444 ++++++++++++++++++ public/text/system/commands/index.json | 3 + .../text/system/commands/teleport_with.json | 31 ++ public/text/system/parser.json | 1 + src/core/Game.ts | 19 + src/core/IGame.ts | 1 + src/core/TextAssetManager.ts | 121 ++++- src/mechanics/Parser.ts | 396 +++++++++++++++- src/mechanics/parserCommands.ts | 42 ++ src/mechanics/parserTypes.ts | 94 +++- tasks.md | 6 + 11 files changed, 1145 insertions(+), 13 deletions(-) create mode 100644 Commands.md create mode 100644 public/text/system/commands/index.json create mode 100644 public/text/system/commands/teleport_with.json create mode 100644 src/mechanics/parserCommands.ts diff --git a/Commands.md b/Commands.md new file mode 100644 index 0000000..eebe0f4 --- /dev/null +++ b/Commands.md @@ -0,0 +1,444 @@ +# Commands + +## Summary + +`Commands` in `Scanline` are parser-level action specifications that describe **custom gameplay commands** without hardcoding one-off logic into `Parser.ts`. + +The goal is to let us add commands such as: +- `TELEPORT WITH ID CARD` +- `UNLOCK DOOR WITH KEY` +- `REPAIR BOOMBOX WITH SOLDERING IRON` +- `USE ITEM ON TARGET` + +while reusing the same generic parser systems for: +- target resolution +- ambiguity clarification +- missing-argument clarification +- no-effect handling +- linear plan execution in `Parser Core` + +This document describes the first draft of the **custom command asset format** and how it maps into the parser architecture. + +--- + +## Why Custom Command Assets Exist + +Many story-specific commands are not generic enough to justify new built-in parser verbs, but they still need: +- natural language recognition +- reusable clarification behavior +- structured execution +- optional custom text + +If each of these is implemented as a custom branch in `Parser.ts`, the parser becomes hard to maintain. + +Instead: +- the parser provides the shared machinery +- each custom command is described by data +- `Parser Core` executes a generic plan + +This is also the best preparation for the future LLM cascade: +- lower layers and mocked scenarios can emit the same plan format +- `Core` can be hardened before real LLM integration + +--- + +## Position In The Architecture + +Custom command assets belong to the **parser layer**, not to `Game`. + +They are: +- language-aware +- target-aware +- clarification-aware +- plan-oriented + +They are not: +- runtime world logic +- arbitrary scripts +- direct `Game API` calls + +The flow is: + +1. Player input arrives +2. Stage 1 tries built-in parser logic +3. Stage 1 also checks custom command assets +4. A matching command asset produces a parser envelope / plan +5. `Parser Core` resolves arguments and executes the plan +6. `Game API` performs the actual world operations + +--- + +## Guiding Principles + +1. Custom commands should be described by **data**, not ad-hoc parser code. +2. Clarification rules should stay **generic** whenever possible. +3. The command system should reuse: + - `ParserWorldModel` + - scope slices + - pending clarification + - unified envelope + - `Parser Core` +4. Command-specific messages should be **overrides**, not separate parser logic. +5. Plans should remain **linear and constrained** in the first version. + +--- + +## Command Asset Location + +Proposed location: + +- `public/text/system/commands/<command_id>.json` + +Examples: +- `public/text/system/commands/teleport_with.json` +- `public/text/system/commands/unlock_with.json` + +These files are parser text assets, similar in spirit to: +- `public/text/system/parser.json` +- `public/text/system/parser-lexicon.json` +- `public/text/system/parser-training.json` + +--- + +## First-Draft Command Asset Format + +Example: + +```json +{ + "id": "teleport_with", + "phrases": ["teleport with", "teleport"], + "arguments": [ + { + "name": "item", + "kind": "entity", + "required": true, + "scopes": ["held", "takable"], + "validation": { + "allowedTitles": ["your ID card"] + }, + "messages": { + "missing": "Teleport with what?", + "ambiguous": "Which item do you want to teleport with: {options}?", + "notFound": "You don't have anything like that.", + "noEffect": "That doesn't work." + } + } + ], + "plan": [ + { "type": "resolveArgumentEntity", "arg": "item", "saveAs": "teleport_item" }, + { "type": "ensureHeldEntity", "ref": "teleport_item", "noEffectMessageId": "no_effect" }, + { "type": "goToSceneById", "sceneId": "test1" }, + { "type": "removeInventoryEntity", "ref": "teleport_item" }, + { "type": "showText", "messageId": "success" } + ], + "messages": { + "success": "You vanish in a flash and arrive somewhere else." + } +} +``` + +--- + +## Field Reference + +### `id` + +Unique command id. + +Example: + +```json +"id": "teleport_with" +``` + +Used for: +- debugging +- command registry +- future analytics / tracing + +### `phrases` + +List of trigger phrases recognized by lower parser layers. + +Example: + +```json +"phrases": ["teleport with"] +``` + +Notes: +- first draft should keep this simple +- exact phrase matching is enough for v1 +- later this can evolve into richer grammar or language-pack integration + +### `arguments` + +Describes the arguments required by the command. + +Example: + +```json +{ + "name": "item", + "kind": "entity", + "required": true, + "scopes": ["held", "takable"] +} +``` + +First-draft fields: +- `name` +- `kind` +- `required` +- `scopes` +- optional `validation` +- optional `messages` + +For v1 we only need: +- `kind: "entity"` + +### `validation` + +Optional command-specific acceptance rules that run **after normal parser resolution**. + +This is important: +- resolution and ambiguity should remain generic +- command validation should decide whether the resolved object is valid for this command + +Example: + +```json +"validation": { + "allowedTitles": ["your ID card"] +} +``` + +First-draft validation fields: +- `allowedEntityIds` +- `allowedTitles` +- `allowedSynonyms` + +If validation fails, parser should use the command-specific `noEffect` message when available, or fall back to the standard parser no-effect message. + +### `plan` + +Linear list of parser-planned actions. + +This is the core of the command asset. + +The plan is: +- declarative +- validated by `Parser Core` +- executed one step at a time + +No arbitrary code is allowed here. + +### `messages` + +Optional command-specific message overrides. + +These should be used only when generic parser messages are not enough. + +The parser should still have shared defaults for: +- missing argument +- ambiguity +- target not found +- no effect +- generic failure + +--- + +## Standard vs Custom Messages + +The command system should not require every command to reinvent the same UX. + +The parser should provide generic standard flows for: + +- missing argument +- ambiguous target +- target not found +- no effect +- generic failure + +Examples of generic messages: +- `Use what?` +- `Which item do you mean: ...?` +- `You don't see any ... here.` +- `That doesn't work.` +- `Nothing happens.` + +Command assets may override those when the scene needs more specific flavour text. + +This keeps parser UX consistent while still allowing authored exceptions. + +--- + +## Relationship To Pending Clarification + +Custom commands should reuse the same pending clarification machinery as built-in commands. + +That means: +- if an argument is missing, parser asks a question +- if multiple candidates match, parser asks which one +- the next input can continue the same command + +This is important: +- we should not build a second clarification system just for custom commands + +--- + +## Relationship To Scope + +Argument resolution should always happen through parser scope. + +Example: + +```json +"scopes": ["held", "takable"] +``` + +This means: +- the parser may look in inventory +- then among takeable scene objects + +The command asset does not bypass scope rules. +It only says which scope slices are legal for that argument. + +--- + +## Relationship To DSL + +Custom command assets are one of the producers of the unified parser DSL. + +They are not a separate execution system. + +Built-in commands and future LLM outputs should converge on the same general model: +- envelope +- plan +- `Parser Core` +- structured outcomes + +This is why `TELEPORT WITH` is useful as a test scenario: +- it exercises a richer plan +- without needing a real LLM yet + +--- + +## First-Draft Planned Actions Needed For `TELEPORT WITH` + +To support the first realistic custom command scenario, the first DSL expansion should include: + +```ts +type ParserPlannedAction = + | { type: 'resolveArgumentEntity'; arg: string; saveAs: string } + | { type: 'ensureHeldEntity'; ref: string } + | { type: 'goToSceneById'; sceneId: string } + | { type: 'removeInventoryEntity'; ref: string } + | { type: 'showText'; textKey: string; params?: Record<string, string> }; +``` + +These actions are intentionally generic. + +They are useful not only for teleportation, but later for: +- unlocking +- repairing +- giving +- consuming +- scripted inventory-driven actions + +--- + +## Plan State + +To support command plans, `Parser Core` needs a small plan-state dictionary. + +Example: + +```ts +type ParserPlanState = Record<string, unknown>; +``` + +Use: +- `saveAs` writes into plan state +- later actions use `ref` to read from it + +Example: +- resolve `item` and save as `teleport_item` +- later remove `teleport_item` from inventory + +--- + +## Required Shared Game API Support + +For the first real custom command plan, the shared `Game API` will likely need: + +- `removeInventoryEntity(entity)` + +This is not specific to teleportation. +It will also be useful for: +- consuming items +- giving items away +- one-use puzzle items +- future `use X on Y` flows + +This should live in shared gameplay API, not inside parser-only logic. + +--- + +## First Example: `TELEPORT WITH` + +Planned parser behavior: + +Input: + +```text +teleport with id card +``` + +Expected flow: + +1. Match custom command spec `teleport_with` +2. Resolve `item` inside `held + takable` +3. If missing: + - ask `Teleport with what?` +4. If ambiguous: + - ask which item +5. If the resolved item is not valid for this command: + - report generic or command-specific no-effect +6. If found in scene but not held: + - try to pick it up +7. If item still unavailable: + - report failure +8. If item is available: + - go to scene `test1` + - remove item from inventory + - show success message + +This gives us a realistic multi-step scenario while still using the lower cascade. + +--- + +## Implementation Order + +1. Add command-spec types +2. Add command asset loading to parser text layer +3. Extend parser DSL and plan state +4. Add shared API support such as `removeInventoryEntity(...)` +5. Add custom command matching in stage1 +6. Implement `teleport_with.json` +7. Run smoke tests + +--- + +## Future Expansion + +Later, command assets may grow to support: +- multiple arguments +- typed targets like `entity`, `scene`, `inventory-item` +- richer scope policies +- optional conditions +- richer message overrides +- LLM-generated plans that still reuse the same execution model + +But the first version should stay deliberately small and stable. diff --git a/public/text/system/commands/index.json b/public/text/system/commands/index.json new file mode 100644 index 0000000..fab3b7e --- /dev/null +++ b/public/text/system/commands/index.json @@ -0,0 +1,3 @@ +{ + "commands": ["teleport_with"] +} diff --git a/public/text/system/commands/teleport_with.json b/public/text/system/commands/teleport_with.json new file mode 100644 index 0000000..2aa69a5 --- /dev/null +++ b/public/text/system/commands/teleport_with.json @@ -0,0 +1,31 @@ +{ + "id": "teleport_with", + "phrases": ["teleport with", "teleport"], + "arguments": [ + { + "name": "item", + "kind": "entity", + "required": true, + "scopes": ["held", "takable"], + "validation": { + "allowedTitles": ["your ID card"] + }, + "messages": { + "missing": "Teleport with what?", + "ambiguous": "Which item do you want to teleport with: {options}?", + "notFound": "You don't have anything like that.", + "noEffect": "That doesn't work." + } + } + ], + "plan": [ + { "type": "resolveArgumentEntity", "arg": "item", "saveAs": "teleport_item" }, + { "type": "ensureHeldEntity", "ref": "teleport_item", "noEffectMessageId": "no_effect" }, + { "type": "goToSceneById", "sceneId": "test1" }, + { "type": "removeInventoryEntity", "ref": "teleport_item" }, + { "type": "showText", "messageId": "success" } + ], + "messages": { + "success": "You vanish in a flash and arrive somewhere else." + } +} diff --git a/public/text/system/parser.json b/public/text/system/parser.json index a107ea9..a2ea1fc 100644 --- a/public/text/system/parser.json +++ b/public/text/system/parser.json @@ -20,5 +20,6 @@ "use_missing_item": "You don't have the {item}.", "use_no_effect_pair": "Using the {item} on the {target} does nothing.", "use_no_effect_single": "You try to use the {target}, but nothing happens.", + "command_no_effect": "That doesn't work.", "parse_unknown": "I don't understand." } diff --git a/src/core/Game.ts b/src/core/Game.ts index 0720bbf..ea92e59 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -639,6 +639,25 @@ export class Game implements IGame { }; } + removeInventoryEntity(entity: Entity): GameActionOutcome { + const index = this.inventory.indexOf(entity); + if (index === -1) { + return { + status: 'failed', + code: 'inventory_item_not_found', + recoverable: true, + }; + } + + this.inventory.splice(index, 1); + return { + status: 'ok', + code: 'inventory_item_removed', + data: { entityId: entity.name }, + effects: ['removed_from_inventory'], + }; + } + showInventory(): GameActionOutcome { const inventoryTitles = this.inventory .map((entity: any) => this.getPlayerFacingEntityTitle(entity)) diff --git a/src/core/IGame.ts b/src/core/IGame.ts index 7ce5488..ec245c5 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -22,6 +22,7 @@ export interface IGame { lookEntity(entity: Entity): GameActionOutcome; examineEntity(entity: Entity): GameActionOutcome; takeEntity(entity: Entity): GameActionOutcome; + removeInventoryEntity(entity: Entity): GameActionOutcome; showInventory(): GameActionOutcome; goToScene(sceneId: string): GameActionOutcome; goToEntity(entity: Entity): GameActionOutcome; diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 6d45fd8..3d3c886 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -1,6 +1,7 @@ import type { Scene } from '../scene/Scene'; import type { SceneObject } from '../entities/SceneObject'; import type { ParserLexiconAsset, ParserTrainingAsset } from '../mechanics/parserLanguage'; +import type { ParserCommandSpec } from '../mechanics/parserTypes'; type TextAssetValue = string | string[]; type TextAssetData = Record<string, TextAssetValue>; @@ -38,6 +39,7 @@ const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { use_missing_item: "You don't have the {item}.", use_no_effect_pair: 'Using the {item} on the {target} does nothing.', use_no_effect_single: 'You try to use the {target}, but nothing happens.', + command_no_effect: "That doesn't work.", parse_unknown: "I don't understand.", }, engine: { @@ -194,14 +196,50 @@ const DEFAULT_PARSER_TRAINING: ParserTrainingAsset = { ], }; +const DEFAULT_PARSER_COMMANDS: ParserCommandSpec[] = [ + { + id: 'teleport_with', + phrases: ['teleport with', 'teleport'], + arguments: [ + { + name: 'item', + kind: 'entity', + required: true, + scopes: ['held', 'takable'], + messages: { + missing: 'Teleport with what?', + ambiguous: 'Which item do you want to teleport with: {options}?', + notFound: "You don't have anything like that.", + noEffect: "That doesn't work.", + }, + validation: { + allowedTitles: ['your ID card'], + }, + }, + ], + plan: [ + { type: 'resolveArgumentEntity', arg: 'item', saveAs: 'teleport_item' }, + { type: 'ensureHeldEntity', ref: 'teleport_item', noEffectMessageId: 'no_effect' }, + { type: 'goToSceneById', sceneId: 'test1' }, + { type: 'removeInventoryEntity', ref: 'teleport_item' }, + { type: 'showText', messageId: 'success' }, + ], + messages: { + success: 'You vanish in a flash and arrive somewhere else.', + }, + }, +]; + export class TextAssetManager { private sceneCache = new Map<string, SceneTextAssetData | null>(); private objectCache = new Map<string, ObjectTextAssetData | null>(); private serviceCache = new Map<string, TextAssetData>(); private parserLexiconCache: ParserLexiconAsset = structuredClone(DEFAULT_PARSER_LEXICON); private parserTrainingCache: ParserTrainingAsset = structuredClone(DEFAULT_PARSER_TRAINING); + private parserCommandsCache: ParserCommandSpec[] = structuredClone(DEFAULT_PARSER_COMMANDS); private parserLexiconLoaded = false; private parserTrainingLoaded = false; + private parserCommandsLoaded = false; private normalizeId(id: string): string { return String(id || '') @@ -245,6 +283,10 @@ export class TextAssetManager { return this.parserTrainingCache; } + getParserCommands(): ParserCommandSpec[] { + return this.parserCommandsCache; + } + buildDefaultSceneAsset(scene: Scene): SceneTextAssetData { return { title: scene.name || scene.id || 'Untitled Scene', @@ -346,7 +388,11 @@ export class TextAssetManager { } async preloadParserLanguageAssets(): Promise<void> { - await Promise.all([this.readParserLexiconAsset(true), this.readParserTrainingAsset(true)]); + await Promise.all([ + this.readParserLexiconAsset(true), + this.readParserTrainingAsset(true), + this.readParserCommandAssets(true), + ]); } clearCaches(): void { @@ -355,8 +401,10 @@ export class TextAssetManager { this.serviceCache.clear(); this.parserLexiconCache = structuredClone(DEFAULT_PARSER_LEXICON); this.parserTrainingCache = structuredClone(DEFAULT_PARSER_TRAINING); + this.parserCommandsCache = structuredClone(DEFAULT_PARSER_COMMANDS); this.parserLexiconLoaded = false; this.parserTrainingLoaded = false; + this.parserCommandsLoaded = false; } async readParserLexiconAsset(forceReload: boolean = false): Promise<ParserLexiconAsset> { @@ -399,6 +447,39 @@ export class TextAssetManager { return this.parserTrainingCache; } + async readParserCommandAssets(forceReload: boolean = false): Promise<ParserCommandSpec[]> { + if (!forceReload && this.parserCommandsLoaded) { + return this.parserCommandsCache; + } + + const index = (await this.fetchUnknownJson('/text/system/commands/index.json')) as { + commands?: string[]; + } | null; + const ids = Array.isArray(index?.commands) ? index.commands.filter(Boolean) : []; + + if (!ids.length) { + this.parserCommandsCache = structuredClone(DEFAULT_PARSER_COMMANDS); + this.parserCommandsLoaded = true; + return this.parserCommandsCache; + } + + const loaded = await Promise.all( + ids.map(async (id) => { + const asset = (await this.fetchUnknownJson( + `/text/system/commands/${id}.json` + )) as ParserCommandSpec | null; + return this.normalizeParserCommandSpec(asset); + }) + ); + + const commands = loaded.filter((command): command is ParserCommandSpec => !!command); + this.parserCommandsCache = commands.length + ? commands + : structuredClone(DEFAULT_PARSER_COMMANDS); + this.parserCommandsLoaded = true; + return this.parserCommandsCache; + } + async readServiceAsset(domain: string, forceReload: boolean = false): Promise<TextAssetData> { const normalizedDomain = String(domain || '') .trim() @@ -500,6 +581,44 @@ export class TextAssetManager { return (await this.fetchUnknownJson(url)) as TextAssetData | null; } + private normalizeParserCommandSpec(spec: ParserCommandSpec | null): ParserCommandSpec | null { + if ( + !spec?.id || + !Array.isArray(spec.phrases) || + !Array.isArray(spec.arguments) || + !Array.isArray(spec.plan) + ) { + return null; + } + + return { + id: String(spec.id), + phrases: spec.phrases.map((item) => String(item).trim()).filter(Boolean), + arguments: spec.arguments.map((arg) => ({ + name: String(arg.name), + kind: arg.kind === 'entity' ? 'entity' : 'entity', + required: arg.required !== false, + scopes: Array.isArray(arg.scopes) ? arg.scopes.filter(Boolean) : [], + messages: arg.messages || undefined, + validation: arg.validation + ? { + allowedEntityIds: Array.isArray(arg.validation.allowedEntityIds) + ? arg.validation.allowedEntityIds.map((item) => String(item).trim()).filter(Boolean) + : undefined, + allowedTitles: Array.isArray(arg.validation.allowedTitles) + ? arg.validation.allowedTitles.map((item) => String(item).trim()).filter(Boolean) + : undefined, + allowedSynonyms: Array.isArray(arg.validation.allowedSynonyms) + ? arg.validation.allowedSynonyms.map((item) => String(item).trim()).filter(Boolean) + : undefined, + } + : undefined, + })), + plan: spec.plan, + messages: spec.messages || undefined, + }; + } + private normalizeSceneAssetData(asset: TextAssetData | null): SceneTextAssetData | null { if (!asset) return null; const normalized: SceneTextAssetData = { ...asset }; diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 1f6c546..449465b 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -1,5 +1,6 @@ import type { GameActionOutcome } from '../core/GameActionTypes'; import { NlpCascade } from './NlpCascade'; +import { matchParserCommandSpec } from './parserCommands'; import { getStage1CommandWords, isLookSceneWord, @@ -7,11 +8,15 @@ import { normalizeTargetForIntent, } from './parserLanguage'; import { ParserWorldModelBuilder } from './ParserWorldModelBuilder'; -import type { Entity } from '../entities/Entity'; +import { Entity } from '../entities/Entity'; import type { SceneDescriptor } from '../scene/SceneManager'; import type { ParserCascadeEnvelope, + ParserCommandActionSpec, + ParserCommandArgumentValidation, + ParserCommandSpec, ParserCoreDecision, + ParserPlanState, ParserPendingState, ParserResponse, ParserResult, @@ -95,6 +100,54 @@ export class Parser { return null; } + if (this.pendingState.intent === 'custom') { + const pendingEnvelopeJson = this.pendingState.pendingEnvelopeJson; + if (!pendingEnvelopeJson) { + this.pendingState = null; + return null; + } + + try { + const envelope = JSON.parse(pendingEnvelopeJson) as ParserCascadeEnvelope; + if (envelope.output.kind !== 'plan') { + this.pendingState = null; + return null; + } + + const patchedActions = envelope.output.actions.map((action) => { + if ( + action.type === 'resolveArgumentEntity' && + (!this.pendingState?.pendingArg || action.arg === this.pendingState.pendingArg) + ) { + return { + ...action, + query: input.trim(), + }; + } + return action; + }); + + return { + ...envelope, + stage: 'pending-resolution', + output: { + kind: 'plan', + actions: patchedActions, + }, + debug: { + ...envelope.debug, + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + noun: input.trim(), + pendingIntent: this.pendingState.commandId || 'custom', + }, + }; + } catch { + this.pendingState = null; + return null; + } + } + const action: ParserToolAction = { type: this.pendingState.intent === 'look' @@ -127,6 +180,7 @@ export class Parser { private runStage1(input: string): ParserCascadeEnvelope { const lexicon = this.game.textAssets.getParserLexicon(); const match = matchStage1Intent(input, lexicon); + const commandMatch = matchParserCommandSpec(input, this.game.textAssets.getParserCommands()); const words = input.trim().split(/\s+/); const verb = (words[0] || '').toUpperCase(); const noun = words.slice(1).join(' ').trim(); @@ -229,6 +283,13 @@ export class Parser { }; } default: + if (commandMatch) { + return this.buildCustomCommandEnvelope( + input, + commandMatch.command, + commandMatch.remainder + ); + } return { stage: 'regex-v1', output: { @@ -299,6 +360,7 @@ export class Parser { private executeCoreDecision(decision: ParserCoreDecision): string { const executedActions: string[] = []; + const planState: ParserPlanState = {}; if (decision.kind === 'handoff_up') { const result: ParserResult = { @@ -332,7 +394,7 @@ export class Parser { return JSON.stringify(result); } - const outcomes = this.executeCorePlan(decision.actions, executedActions); + const outcomes = this.executeCorePlan(decision.actions, executedActions, planState); const result: ParserResult = { type: 'outcomes', @@ -346,12 +408,13 @@ export class Parser { private executeCorePlan( actions: ParserToolAction[], - executedActions: string[] + executedActions: string[], + planState: ParserPlanState ): GameActionOutcome[] { const outcomes: GameActionOutcome[] = []; for (const action of actions) { - const outcome = this.executeParserAction(action); + const outcome = this.executeParserAction(action, planState); executedActions.push(this.getExecutedActionName(action)); outcomes.push(outcome); @@ -363,7 +426,10 @@ export class Parser { return outcomes; } - private executeParserAction(action: ParserToolAction): GameActionOutcome { + private executeParserAction( + action: ParserToolAction, + planState: ParserPlanState + ): GameActionOutcome { switch (action.type) { case 'lookScene': return this.game.lookScene(); @@ -377,6 +443,22 @@ export class Parser { return this.game.showInventory(); case 'goToTarget': return this.resolveGoToTarget(action.target); + case 'resolveArgumentEntity': + return this.executeResolveArgumentEntity(action, planState); + case 'ensureHeldEntity': + return this.executeEnsureHeldEntity(action, planState); + case 'goToSceneById': + return this.game.goToScene(action.sceneId); + case 'removeInventoryEntity': + return this.executeRemoveInventoryEntity(action, planState); + case 'showText': + return { + status: 'ok', + code: 'custom_message', + message: + action.message || + (action.textKey ? this.game.text(action.textKey, action.params) : undefined), + }; default: return { status: 'escalate', @@ -401,6 +483,16 @@ export class Parser { return 'showInventory'; case 'goToTarget': return 'goTo'; + case 'resolveArgumentEntity': + return 'resolveArgumentEntity'; + case 'ensureHeldEntity': + return 'ensureHeldEntity'; + case 'goToSceneById': + return 'goToSceneById'; + case 'removeInventoryEntity': + return 'removeInventoryEntity'; + case 'showText': + return 'showText'; default: return 'unknown'; } @@ -482,6 +574,49 @@ export class Parser { return { status: 'not_found' }; } + private resolveEntityTargetWithMessages( + rawTarget: string | null, + candidates: Entity[], + messages?: { + missing?: string; + ambiguous?: string; + notFound?: string; + } + ): + | { status: 'found'; entity: Entity } + | { status: 'not_found'; message: string } + | { status: 'needs_clarification'; message: string; options: string[] } + | { status: 'escalate'; code: string } { + if (!rawTarget) { + return { + status: 'not_found', + message: messages?.missing || this.game.text('parser.parse_unknown'), + }; + } + + const resolved = this.resolveEntityTargetInCandidates( + rawTarget, + candidates, + 'parser.look_which_one' + ); + if (resolved.status === 'found') return resolved; + if (resolved.status === 'escalate') return resolved; + if (resolved.status === 'not_found') { + return { + status: 'not_found', + message: + messages?.notFound || this.game.text('parser.look_not_found', { target: rawTarget }), + }; + } + + return { + status: 'needs_clarification', + message: + messages?.ambiguous?.replace('{options}', resolved.options.join(', ')) || resolved.message, + options: resolved.options, + }; + } + private resolveSceneTarget(rawTarget: string): SceneDescriptor | null { const normalized = String(rawTarget || '') .trim() @@ -687,6 +822,194 @@ export class Parser { }; } + private executeResolveArgumentEntity( + action: Extract<ParserToolAction, { type: 'resolveArgumentEntity' }>, + planState: ParserPlanState + ): GameActionOutcome { + const resolution = this.resolveEntityTargetWithMessages( + action.query, + this.getScopeCandidates(action.scopes), + action.messages + ); + + if (resolution.status === 'escalate') { + return { status: 'escalate', code: resolution.code, recoverable: true }; + } + + if (resolution.status === 'not_found') { + return { + status: action.query ? 'failed' : 'needs_clarification', + code: action.query ? 'custom_command_target_not_found' : 'custom_command_missing_argument', + message: resolution.message, + data: { + pendingArg: action.arg, + commandId: action.commandId, + }, + recoverable: true, + }; + } + + if (resolution.status === 'needs_clarification') { + return { + status: 'needs_clarification', + code: 'custom_command_ambiguous_argument', + message: resolution.message, + data: { + pendingArg: action.arg, + commandId: action.commandId, + options: resolution.options, + }, + recoverable: true, + }; + } + + if (!this.isEntityValidForCommandArgument(resolution.entity, action.validation)) { + return { + status: 'failed', + code: 'custom_command_invalid_argument', + message: action.messages?.noEffect || this.game.text('parser.command_no_effect'), + data: { + arg: action.arg, + commandId: action.commandId, + entityId: resolution.entity.name, + }, + recoverable: true, + }; + } + + planState[action.saveAs] = resolution.entity; + return { + status: 'ok', + code: 'argument_resolved', + data: { + arg: action.arg, + saveAs: action.saveAs, + entityId: resolution.entity.name, + }, + }; + } + + private executeEnsureHeldEntity( + action: Extract<ParserToolAction, { type: 'ensureHeldEntity' }>, + planState: ParserPlanState + ): GameActionOutcome { + const entity = planState[action.ref]; + if (!(entity instanceof Entity)) { + return { + status: 'escalate', + code: 'missing_plan_entity_ref', + data: { ref: action.ref }, + recoverable: true, + }; + } + + if (this.game.inventory.includes(entity)) { + return { + status: 'ok', + code: 'entity_already_held', + data: { ref: action.ref, entityId: entity.name }, + }; + } + + const outcome = this.game.takeEntity(entity); + if (outcome.status === 'failed' && action.noEffectMessage) { + return { + ...outcome, + message: action.noEffectMessage, + }; + } + return outcome; + } + + private executeRemoveInventoryEntity( + action: Extract<ParserToolAction, { type: 'removeInventoryEntity' }>, + planState: ParserPlanState + ): GameActionOutcome { + const entity = planState[action.ref]; + if (!(entity instanceof Entity)) { + return { + status: 'escalate', + code: 'missing_plan_entity_ref', + data: { ref: action.ref }, + recoverable: true, + }; + } + return this.game.removeInventoryEntity(entity); + } + + private buildCustomCommandEnvelope( + input: string, + command: ParserCommandSpec, + remainder: string + ): ParserCascadeEnvelope { + const actions = command.plan + .map((step) => this.mapCommandPlanStep(command, step, remainder)) + .filter((action): action is ParserToolAction => !!action); + + return { + stage: 'regex-v1', + output: { + kind: 'plan', + actions, + }, + debug: { + rawInput: input, + normalizedInput: input.trim().toUpperCase(), + verb: command.id.toUpperCase(), + noun: remainder, + }, + }; + } + + private mapCommandPlanStep( + command: ParserCommandSpec, + step: ParserCommandActionSpec, + remainder: string + ): ParserToolAction | null { + switch (step.type) { + case 'resolveArgumentEntity': { + const argSpec = command.arguments.find((arg) => arg.name === step.arg); + if (!argSpec) return null; + return { + type: 'resolveArgumentEntity', + commandId: command.id, + arg: step.arg, + query: remainder || null, + scopes: argSpec.scopes, + saveAs: step.saveAs, + messages: argSpec.messages, + validation: argSpec.validation, + }; + } + case 'ensureHeldEntity': + return { + type: 'ensureHeldEntity', + ref: step.ref, + noEffectMessage: + (step.noEffectMessageId && command.messages?.[step.noEffectMessageId]) || + command.arguments[0]?.messages?.noEffect, + }; + case 'goToSceneById': + return { + type: 'goToSceneById', + sceneId: step.sceneId, + }; + case 'removeInventoryEntity': + return { + type: 'removeInventoryEntity', + ref: step.ref, + }; + case 'showText': + return { + type: 'showText', + message: step.messageId ? command.messages?.[step.messageId] : step.text, + params: step.params, + }; + default: + return null; + } + } + private buildResponse( resultJson: string, envelopeJson: string, @@ -725,13 +1048,29 @@ export class Parser { (outcome) => outcome.status === 'needs_clarification' ); if (clarification) { + const clarificationData = (clarification.data || {}) as Record<string, unknown>; + const pendingArg = + typeof clarificationData.pendingArg === 'string' ? clarificationData.pendingArg : undefined; + const commandId = + typeof clarificationData.commandId === 'string' ? clarificationData.commandId : undefined; + const nextPendingState = + pendingArg && commandId + ? { + intent: 'custom' as const, + question: clarification.message || this.game.text('parser.parse_unknown'), + originalInput: this.extractRawInput(envelopeJson), + pendingEnvelopeJson: envelopeJson, + pendingArg, + commandId, + } + : { + intent: this.extractPendingIntent(envelopeJson), + question: clarification.message || this.game.text('parser.parse_unknown'), + originalInput: this.extractRawInput(envelopeJson), + }; return { playerMessage: clarification.message || this.game.text('parser.parse_unknown'), - nextPendingState: { - intent: this.extractPendingIntent(envelopeJson), - question: clarification.message || this.game.text('parser.parse_unknown'), - originalInput: this.extractRawInput(envelopeJson), - }, + nextPendingState, debugMessages: peekMessages, }; } @@ -812,7 +1151,10 @@ export class Parser { if (!trimmed) return false; if (trimmed.startsWith('#') || trimmed.startsWith('-')) return true; const firstWord = trimmed.split(/\s+/)[0]?.toUpperCase() || ''; - return getStage1CommandWords(this.game.textAssets.getParserLexicon()).has(firstWord); + if (getStage1CommandWords(this.game.textAssets.getParserLexicon()).has(firstWord)) { + return true; + } + return !!matchParserCommandSpec(trimmed, this.game.textAssets.getParserCommands()); } private buildPeekScopeSummary(scope: ParserScope): Record<string, unknown> { @@ -830,4 +1172,36 @@ export class Parser { })), }; } + + private isEntityValidForCommandArgument( + entity: Entity, + validation?: ParserCommandArgumentValidation + ): boolean { + if (!validation) return true; + + const normalizedEntityId = entity.name.trim().toUpperCase(); + const normalizedTitle = (this.getPlayerFacingEntityTitle(entity) || '').trim().toUpperCase(); + const normalizedSynonyms = this.game.textAssets + .getResolvedObjectListField(entity as any, 'synonyms') + .map((item: string) => item.trim().toUpperCase()) + .filter(Boolean); + + const matchesAllowedEntityIds = + !validation.allowedEntityIds?.length || + validation.allowedEntityIds.some( + (item) => String(item).trim().toUpperCase() === normalizedEntityId + ); + const matchesAllowedTitles = + !validation.allowedTitles?.length || + validation.allowedTitles.some( + (item) => String(item).trim().toUpperCase() === normalizedTitle + ); + const matchesAllowedSynonyms = + !validation.allowedSynonyms?.length || + validation.allowedSynonyms.some((item) => + normalizedSynonyms.includes(String(item).trim().toUpperCase()) + ); + + return matchesAllowedEntityIds && matchesAllowedTitles && matchesAllowedSynonyms; + } } diff --git a/src/mechanics/parserCommands.ts b/src/mechanics/parserCommands.ts new file mode 100644 index 0000000..6a886b2 --- /dev/null +++ b/src/mechanics/parserCommands.ts @@ -0,0 +1,42 @@ +import type { ParserCommandSpec } from './parserTypes'; + +export type ParserCommandMatch = { + command: ParserCommandSpec; + matchedPhrase: string; + remainder: string; +}; + +function sortByLengthDesc(values: string[]): string[] { + return [...values].sort((a, b) => b.length - a.length); +} + +function startsWithPhrase(input: string, phrase: string): boolean { + if (input === phrase) return true; + return input.startsWith(`${phrase} `); +} + +export function matchParserCommandSpec( + input: string, + commands: ParserCommandSpec[] +): ParserCommandMatch | null { + const trimmed = input.trim(); + if (!trimmed) return null; + const lowered = trimmed.toLowerCase(); + + for (const command of commands) { + const phrases = sortByLengthDesc( + (command.phrases || []).map((item) => item.trim()).filter(Boolean) + ); + for (const phrase of phrases) { + const normalizedPhrase = phrase.toLowerCase(); + if (!startsWithPhrase(lowered, normalizedPhrase)) continue; + return { + command, + matchedPhrase: phrase, + remainder: trimmed.slice(phrase.length).trim(), + }; + } + } + + return null; +} diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index 5fef2ba..2953fdb 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -21,9 +21,70 @@ export type ParserInventoryItemContext = { }; export type ParserPendingState = { - intent: 'look' | 'examine' | 'take' | 'goTo'; + intent: 'look' | 'examine' | 'take' | 'goTo' | 'custom'; question: string; originalInput: string; + pendingEnvelopeJson?: string; + pendingArg?: string; + commandId?: string; +}; + +export type ParserScopeSlice = keyof Omit<ParserScope, 'sceneTargets'>; + +export type ParserCommandArgumentMessages = { + missing?: string; + ambiguous?: string; + notFound?: string; + noEffect?: string; +}; + +export type ParserCommandArgumentValidation = { + allowedEntityIds?: string[]; + allowedTitles?: string[]; + allowedSynonyms?: string[]; +}; + +export type ParserCommandArgumentSpec = { + name: string; + kind: 'entity'; + required: boolean; + scopes: ParserScopeSlice[]; + messages?: ParserCommandArgumentMessages; + validation?: ParserCommandArgumentValidation; +}; + +export type ParserCommandActionSpec = + | { + type: 'resolveArgumentEntity'; + arg: string; + saveAs: string; + } + | { + type: 'ensureHeldEntity'; + ref: string; + noEffectMessageId?: string; + } + | { + type: 'goToSceneById'; + sceneId: string; + } + | { + type: 'removeInventoryEntity'; + ref: string; + } + | { + type: 'showText'; + messageId?: string; + text?: string; + params?: Record<string, string>; + }; + +export type ParserCommandSpec = { + id: string; + phrases: string[]; + arguments: ParserCommandArgumentSpec[]; + plan: ParserCommandActionSpec[]; + messages?: Record<string, string>; }; export type ParserContext = { @@ -77,6 +138,35 @@ export type ParserToolAction = | { type: 'goToTarget'; target: string | null; + } + | { + type: 'resolveArgumentEntity'; + commandId: string; + arg: string; + query: string | null; + scopes: ParserScopeSlice[]; + saveAs: string; + messages?: ParserCommandArgumentMessages; + validation?: ParserCommandArgumentValidation; + } + | { + type: 'ensureHeldEntity'; + ref: string; + noEffectMessage?: string; + } + | { + type: 'goToSceneById'; + sceneId: string; + } + | { + type: 'removeInventoryEntity'; + ref: string; + } + | { + type: 'showText'; + message?: string; + textKey?: string; + params?: Record<string, string>; }; export type ParserCascadeEnvelope = { @@ -135,6 +225,8 @@ export type ParserCoreDecision = actions: ParserToolAction[]; }; +export type ParserPlanState = Record<string, unknown>; + export type ParserResponse = { playerMessage?: string; debugMessages?: string[]; diff --git a/tasks.md b/tasks.md index bed50e8..72675db 100644 --- a/tasks.md +++ b/tasks.md @@ -4,6 +4,12 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the future LLM cascade. +## Current Focus + +- [x] Introduce parser custom command assets (`Commands.md`, command TA loading, shared command spec format). +- [x] Expand parser DSL/Core so lower layers can mock richer Stage-2-style plans. +- [ ] Implement `TELEPORT WITH` as the first custom command scenario driven by command TA. + ## Backlog - [x] Replace the separate `ParserContextBuilder` / `ParserScopeBuilder` idea with one `ParserWorldModelBuilder` that returns both `context` and `scope`. From 3ce0867233e0f7b66f8bb6cedb7d2a0d0fe74743 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Tue, 17 Mar 2026 01:04:58 +0200 Subject: [PATCH 26/75] Feature: add multi-argument parser commands --- Commands.md | 115 ++++++++++++++++++++- Parser.md | 79 ++++++++++---- public/text/system/commands/index.json | 2 +- public/text/system/commands/use_on.json | 46 +++++++++ src/core/TextAssetManager.ts | 49 +++++++++ src/mechanics/Parser.ts | 130 ++++++++++++++++++++++-- src/mechanics/parserCommands.ts | 78 ++++++++++++++ src/mechanics/parserTypes.ts | 3 + tasks.md | 3 +- 9 files changed, 471 insertions(+), 34 deletions(-) create mode 100644 public/text/system/commands/use_on.json diff --git a/Commands.md b/Commands.md index eebe0f4..0a58cfb 100644 --- a/Commands.md +++ b/Commands.md @@ -80,6 +80,8 @@ The flow is: - `Parser Core` 4. Command-specific messages should be **overrides**, not separate parser logic. 5. Plans should remain **linear and constrained** in the first version. +6. Multi-argument commands should be expressed through the same generic machinery, not special parser branches. +7. Words like `with`, `on`, `to`, `in`, `under` should usually be treated as grammar hints for binding arguments or relations, not as standalone commands. --- @@ -171,6 +173,7 @@ Notes: - first draft should keep this simple - exact phrase matching is enough for v1 - later this can evolve into richer grammar or language-pack integration +- in most cases, `phrases` should represent the verb-level command (`use`, `unlock`, `teleport`), while prepositions like `with` or `on` are handled by argument grammar ### `arguments` @@ -285,6 +288,7 @@ That means: This is important: - we should not build a second clarification system just for custom commands +- clarification may happen for any individual argument in a multi-argument command --- @@ -305,6 +309,44 @@ This means: The command asset does not bypass scope rules. It only says which scope slices are legal for that argument. +### Multi-Argument Commands + +Commands may declare more than one argument. + +Important distinction: +- the **command** is usually the verb or verb phrase (`use`, `unlock`, `teleport`) +- words like `with`, `on`, `to`, `in`, `under` are usually **argument-binding markers** +- they help parser assign roles to arguments, but they are not usually separate commands in themselves + +For v1, arguments after the first may define `separatorsBefore`, for example: + +```json +{ + "name": "target", + "kind": "entity", + "required": true, + "scopes": ["visible", "held", "examinable"], + "separatorsBefore": ["on"] +} +``` + +With this, input like: + +```text +use key on door +``` + +is parsed as: +- `item = key` +- `target = door` + +So for parser architecture purposes, `USE` is the command, while `ON` is a grammar hint that introduces the next argument. + +If the separator is missing: +- earlier arguments keep the remaining text they can claim +- later required arguments may remain unresolved +- the usual parser clarification flow asks for the missing argument + --- ## Relationship To DSL @@ -335,7 +377,13 @@ type ParserPlannedAction = | { type: 'ensureHeldEntity'; ref: string } | { type: 'goToSceneById'; sceneId: string } | { type: 'removeInventoryEntity'; ref: string } - | { type: 'showText'; textKey: string; params?: Record<string, string> }; + | { + type: 'showText'; + textKey?: string; + messageId?: string; + params?: Record<string, string>; + paramsFromRefs?: Record<string, string>; + }; ``` These actions are intentionally generic. @@ -347,6 +395,21 @@ They are useful not only for teleportation, but later for: - consuming - scripted inventory-driven actions +`paramsFromRefs` allows `showText` to interpolate values from resolved plan state. + +Example: + +```json +{ + "type": "showText", + "messageId": "no_effect_pair", + "paramsFromRefs": { + "item": "use_item", + "target": "use_target" + } +} +``` + --- ## Plan State @@ -419,6 +482,56 @@ This gives us a realistic multi-step scenario while still using the lower cascad --- +## Second Example: `USE X ON Y` + +This is the first generic multi-argument command supported by the current system. + +Example command asset shape: + +```json +{ + "id": "use_on", + "phrases": ["use"], + "arguments": [ + { + "name": "item", + "kind": "entity", + "required": true, + "scopes": ["held", "takable"] + }, + { + "name": "target", + "kind": "entity", + "required": true, + "scopes": ["visible", "held", "examinable"], + "separatorsBefore": ["on"] + } + ], + "plan": [ + { "type": "resolveArgumentEntity", "arg": "item", "saveAs": "use_item" }, + { "type": "ensureHeldEntity", "ref": "use_item" }, + { "type": "resolveArgumentEntity", "arg": "target", "saveAs": "use_target" }, + { + "type": "showText", + "messageId": "no_effect_pair", + "paramsFromRefs": { + "item": "use_item", + "target": "use_target" + } + } + ] +} +``` + +This command is useful as a parser-system milestone because it exercises: +- multi-argument parsing +- per-argument clarification +- shared scope rules +- plan-state reuse +- dynamic final messaging from resolved refs + +--- + ## Implementation Order 1. Add command-spec types diff --git a/Parser.md b/Parser.md index c9b39bf..446aa79 100644 --- a/Parser.md +++ b/Parser.md @@ -241,6 +241,10 @@ Stage 1 на самом деле состоит из двух внутренни - `what do i have?` - `go over to the office` +Важно: +- глагол или verb phrase обычно определяет саму команду; +- слова вроде `with`, `on`, `to`, `in`, `under` обычно являются не отдельными командами, а grammar hints для связывания аргументов или relation semantics. + Важно: - `Stage 1.2` не занимается world reasoning; - не должен сам принимать игровые решения; @@ -508,6 +512,16 @@ Parser: - поддерживает partial matching; - поддерживает clarification при неоднозначности. +При этом parser полезно различает: +- **command verb**: например `use`, `unlock`, `look`, `teleport` +- **grammar markers / relations**: например `with`, `on`, `to`, `in`, `under` + +Эти слова не обязательно являются частью самой команды. +Чаще они помогают parser-у: +- назначать роли аргументам; +- выбирать relation-aware scope; +- понимать структуру одной и той же команды в разных формулировках. + ### Object TA fields relevant to target resolution Для object TA важны не только: @@ -723,28 +737,38 @@ type CascadeEnvelope = ### Первый вариант `ParserPlannedAction` -Для первого DSL достаточно ограниченного набора шагов: +Целевой DSL может быть богаче, но текущая реализация уже поддерживает полезный ограниченный subset: ```ts type ParserPlannedAction = - | { type: 'resolveEntity'; source: 'visible' | 'held' | 'takable' | 'examinable' | 'reachable'; query: string; saveAs: string } - | { type: 'resolveScene'; query: string; saveAs: string } - | { type: 'checkInventoryContains'; query: string; saveAs?: string } - | { type: 'checkResolved'; ref: string } - | { type: 'checkState'; scope: 'scene' | 'entity'; ref?: string; key: string; expected?: string | number | boolean } - | { type: 'lookScene' } - | { type: 'lookEntity'; ref: string } - | { type: 'examineEntity'; ref: string } - | { type: 'takeEntity'; ref: string } - | { type: 'showInventory' } - | { type: 'goToScene'; ref: string } - | { type: 'goToEntity'; ref: string } - | { type: 'removeInventoryItem'; ref: string } - | { type: 'addInventoryItem'; ref: string } - | { type: 'askPlayer'; question: string; saveAs?: string } - | { type: 'showMessage'; text: string }; + | { + type: 'resolveArgumentEntity'; + commandId: string; + arg: string; + query: string | null; + scopes: ParserScopeSlice[]; + saveAs: string; + messages?: ParserCommandArgumentMessages; + validation?: ParserCommandArgumentValidation; + } + | { type: 'ensureHeldEntity'; ref: string; noEffectMessage?: string } + | { type: 'goToSceneById'; sceneId: string } + | { type: 'removeInventoryEntity'; ref: string } + | { + type: 'showText'; + message?: string; + textKey?: string; + params?: Record<string, string>; + paramsFromRefs?: Record<string, string>; + }; ``` +Этого уже хватает для: +- `TELEPORT WITH item`; +- двухаргументных custom commands вроде `USE X ON Y`; +- generic clarification и validation на уровне `Parser Core`; +- подстановки resolved entity titles в финальные сообщения через `paramsFromRefs`. + ### Почему DSL должен быть ограниченным Это важно для безопасности и устойчивости архитектуры. @@ -845,12 +869,14 @@ Parser должен быть локализуемым без переписыв - aliases; - articles; - polite prefixes; - - prepositional phrases. + - prepositional phrases and grammar markers. Текущая раскладка: - `public/text/system/parser.json` — player-facing parser strings; - `public/text/system/parser-lexicon.json` — stage1 lexicon и normalization vocabulary; - `public/text/system/parser-training.json` — training phrases для NLP-слоя. +- `public/text/system/commands/*.json` — custom command assets; +- `Commands.md` — формат и принципы command TA. Текущее применение: - `Stage 1.1` использует `parser-lexicon.json` для: @@ -984,10 +1010,14 @@ type ParserRelation = { - `src/mechanics/parserLanguage.ts` - stage1 lexicon helpers - - command matching - target normalization - parser language-pack access helpers +- `src/mechanics/parserCommands.ts` + - parser custom command matching + - phrase matching + - multi-argument splitting through `separatorsBefore` + - `src/mechanics/parserTypes.ts` - parser-facing types - `ParserWorldModel` @@ -995,6 +1025,8 @@ type ParserRelation = { - `ParserCascadeEnvelope` - `ParserCoreDecision` - `ParserToolAction` + - `ParserCommandSpec` + - `ParserPlanState` - `src/core/Game.ts` - semantic runtime tools @@ -1009,6 +1041,7 @@ type ParserRelation = { - scene/object text resolution - parser lexicon assets - parser training assets + - parser command assets - object fields such as `details` - object list fields such as `synonyms` @@ -1032,11 +1065,12 @@ type ParserRelation = { | Console preprocessor | `src/core/Console.ts` | `preprocessGameplayInput(...)`, stage toggles, shorthand expansion | | World model builder | `src/mechanics/ParserWorldModelBuilder.ts` | `build(...)` returns `{ context, scope }` | | Stage 1.1 regex | `src/mechanics/Parser.ts`, `src/mechanics/parserLanguage.ts` | `runStage1(...)`, `matchStage1Intent(...)`, `normalizeTargetForIntent(...)` | +| Custom command matching | `src/mechanics/parserCommands.ts`, `src/mechanics/Parser.ts` | `matchParserCommandSpec(...)`, `buildCustomCommandEnvelope(...)`, multi-argument extraction | | Stage 1.2 NLP | `src/mechanics/NlpCascade.ts` | `parse(...)`, training on parser language assets, envelope generation | | Parser Core | `src/mechanics/Parser.ts` | `runParserCore(...)`, `makeCoreDecision(...)`, `executeCoreDecision(...)`, `executeCorePlan(...)` | | Scope-driven resolution | `src/mechanics/Parser.ts` | `resolveLookTarget(...)`, `resolveExamineTarget(...)`, `resolveTakeTarget(...)`, `resolveGoToTarget(...)`, `resolveEntityTargetInCandidates(...)` | -| Shared gameplay API | `src/core/Game.ts`, `src/core/IGame.ts` | `lookScene(...)`, `lookEntity(...)`, `examineEntity(...)`, `takeEntity(...)`, `goToScene(...)`, `goToEntity(...)`, `showInventory()` | -| Text assets | `src/core/TextAssetManager.ts`, `public/text/system/*.json` | `getParserLexicon()`, `getParserTraining()`, `getResolvedObjectField(...)`, `getResolvedObjectListField(...)` | +| Shared gameplay API | `src/core/Game.ts`, `src/core/IGame.ts` | `lookScene(...)`, `lookEntity(...)`, `examineEntity(...)`, `takeEntity(...)`, `goToScene(...)`, `goToEntity(...)`, `showInventory()`, `removeInventoryEntity(...)` | +| Text assets | `src/core/TextAssetManager.ts`, `public/text/system/*.json` | `getParserLexicon()`, `getParserTraining()`, `getParserCommands()`, `getResolvedObjectField(...)`, `getResolvedObjectListField(...)` | | Parser debugging | `src/mechanics/Parser.ts`, `src/core/Console.ts` | `#PEEK`, stage toggles, debug output for `scope/envelope/core/result/nlp` | ### Separation of concerns @@ -1080,6 +1114,9 @@ flowchart TD - stage toggles via console; - Game API с resolved targets; - linear plan execution in `Parser Core` for non-LLM producers; +- parser custom command assets via `public/text/system/commands/*.json`; +- first generic multi-step command `TELEPORT WITH`; +- first generic two-argument command path `USE X ON Y`; - базовая groundwork for future stage-2 DSL. ### Дальше diff --git a/public/text/system/commands/index.json b/public/text/system/commands/index.json index fab3b7e..486c353 100644 --- a/public/text/system/commands/index.json +++ b/public/text/system/commands/index.json @@ -1,3 +1,3 @@ { - "commands": ["teleport_with"] + "commands": ["teleport_with", "use_on"] } diff --git a/public/text/system/commands/use_on.json b/public/text/system/commands/use_on.json new file mode 100644 index 0000000..564fe18 --- /dev/null +++ b/public/text/system/commands/use_on.json @@ -0,0 +1,46 @@ +{ + "id": "use_on", + "phrases": ["use"], + "arguments": [ + { + "name": "item", + "kind": "entity", + "required": true, + "scopes": ["held", "reachable"], + "messages": { + "missing": "Use what on what?", + "ambiguous": "Which item do you mean: {options}?", + "notFound": "You don't see anything like that here.", + "noEffect": "That doesn't work." + } + }, + { + "name": "target", + "kind": "entity", + "required": true, + "scopes": ["held", "reachable"], + "separatorsBefore": ["on", "with"], + "messages": { + "missing": "Use it on what?", + "ambiguous": "Which target do you mean: {options}?", + "notFound": "You don't see anything like that here.", + "noEffect": "That doesn't work." + } + } + ], + "plan": [ + { "type": "resolveArgumentEntity", "arg": "item", "saveAs": "use_item" }, + { "type": "resolveArgumentEntity", "arg": "target", "saveAs": "use_target" }, + { + "type": "showText", + "messageId": "no_effect_pair", + "paramsFromRefs": { + "item": "use_item", + "target": "use_target" + } + } + ], + "messages": { + "no_effect_pair": "Using the {item} on the {target} does nothing." + } +} diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 3d3c886..8ed96b5 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -228,6 +228,52 @@ const DEFAULT_PARSER_COMMANDS: ParserCommandSpec[] = [ success: 'You vanish in a flash and arrive somewhere else.', }, }, + { + id: 'use_on', + phrases: ['use'], + arguments: [ + { + name: 'item', + kind: 'entity', + required: true, + scopes: ['held', 'reachable'], + messages: { + missing: 'Use what on what?', + ambiguous: 'Which item do you mean: {options}?', + notFound: "You don't see anything like that here.", + noEffect: "That doesn't work.", + }, + }, + { + name: 'target', + kind: 'entity', + required: true, + scopes: ['held', 'reachable'], + separatorsBefore: ['on', 'with'], + messages: { + missing: 'Use it on what?', + ambiguous: 'Which target do you mean: {options}?', + notFound: "You don't see anything like that here.", + noEffect: "That doesn't work.", + }, + }, + ], + plan: [ + { type: 'resolveArgumentEntity', arg: 'item', saveAs: 'use_item' }, + { type: 'resolveArgumentEntity', arg: 'target', saveAs: 'use_target' }, + { + type: 'showText', + messageId: 'no_effect_pair', + paramsFromRefs: { + item: 'use_item', + target: 'use_target', + }, + }, + ], + messages: { + no_effect_pair: 'Using the {item} on the {target} does nothing.', + }, + }, ]; export class TextAssetManager { @@ -599,6 +645,9 @@ export class TextAssetManager { kind: arg.kind === 'entity' ? 'entity' : 'entity', required: arg.required !== false, scopes: Array.isArray(arg.scopes) ? arg.scopes.filter(Boolean) : [], + separatorsBefore: Array.isArray(arg.separatorsBefore) + ? arg.separatorsBefore.map((item) => String(item).trim()).filter(Boolean) + : undefined, messages: arg.messages || undefined, validation: arg.validation ? { diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 449465b..57ffb54 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -10,6 +10,7 @@ import { import { ParserWorldModelBuilder } from './ParserWorldModelBuilder'; import { Entity } from '../entities/Entity'; import type { SceneDescriptor } from '../scene/SceneManager'; +import { ComponentSystem } from '../systems/ComponentSystem'; import type { ParserCascadeEnvelope, ParserCommandActionSpec, @@ -287,7 +288,7 @@ export class Parser { return this.buildCustomCommandEnvelope( input, commandMatch.command, - commandMatch.remainder + commandMatch.argumentValues ); } return { @@ -451,14 +452,22 @@ export class Parser { return this.game.goToScene(action.sceneId); case 'removeInventoryEntity': return this.executeRemoveInventoryEntity(action, planState); - case 'showText': + case 'showText': { + const resolvedParams = this.resolveShowTextParams( + action.params, + action.paramsFromRefs, + planState + ); return { status: 'ok', code: 'custom_message', message: - action.message || - (action.textKey ? this.game.text(action.textKey, action.params) : undefined), + (action.message + ? this.interpolateTemplate(action.message, resolvedParams) + : undefined) || + (action.textKey ? this.game.text(action.textKey, resolvedParams) : undefined), }; + } default: return { status: 'escalate', @@ -837,6 +846,12 @@ export class Parser { } if (resolution.status === 'not_found') { + const distanceFailure = action.query + ? this.resolveDistanceFailureForArgument(action.query, action.scopes) + : null; + if (distanceFailure) { + return distanceFailure; + } return { status: action.query ? 'failed' : 'needs_clarification', code: action.query ? 'custom_command_target_not_found' : 'custom_command_missing_argument', @@ -889,6 +904,54 @@ export class Parser { }; } + private resolveDistanceFailureForArgument( + rawTarget: string, + scopes: Array<keyof Omit<ParserScope, 'sceneTargets'>> + ): GameActionOutcome | null { + if (!scopes.includes('reachable') || scopes.includes('visible')) { + return null; + } + + const broadResolved = this.resolveEntityTargetInCandidates( + rawTarget, + this.getScopeCandidates(['held', 'visible']), + 'parser.look_which_one' + ); + if (broadResolved.status !== 'found') { + return null; + } + + if (this.game.inventory.includes(broadResolved.entity)) { + return null; + } + + const scene = this.game.sceneManager.currentScene; + const player = scene?.player; + if (!scene || !player) { + return null; + } + + const distanceError = ComponentSystem.getInteractionDistanceError( + broadResolved.entity as any, + player + ); + + if (!distanceError) { + return null; + } + + return { + status: 'failed', + code: 'custom_command_target_too_far', + message: distanceError, + data: { + target: rawTarget, + entityId: broadResolved.entity.name, + }, + recoverable: true, + }; + } + private executeEnsureHeldEntity( action: Extract<ParserToolAction, { type: 'ensureHeldEntity' }>, planState: ParserPlanState @@ -940,10 +1003,10 @@ export class Parser { private buildCustomCommandEnvelope( input: string, command: ParserCommandSpec, - remainder: string + argumentValues: Record<string, string | null> ): ParserCascadeEnvelope { const actions = command.plan - .map((step) => this.mapCommandPlanStep(command, step, remainder)) + .map((step) => this.mapCommandPlanStep(command, step, argumentValues)) .filter((action): action is ParserToolAction => !!action); return { @@ -956,7 +1019,9 @@ export class Parser { rawInput: input, normalizedInput: input.trim().toUpperCase(), verb: command.id.toUpperCase(), - noun: remainder, + noun: Object.values(argumentValues) + .filter((value): value is string => !!value) + .join(' '), }, }; } @@ -964,7 +1029,7 @@ export class Parser { private mapCommandPlanStep( command: ParserCommandSpec, step: ParserCommandActionSpec, - remainder: string + argumentValues: Record<string, string | null> ): ParserToolAction | null { switch (step.type) { case 'resolveArgumentEntity': { @@ -974,11 +1039,11 @@ export class Parser { type: 'resolveArgumentEntity', commandId: command.id, arg: step.arg, - query: remainder || null, + query: argumentValues[step.arg] || null, scopes: argSpec.scopes, saveAs: step.saveAs, - messages: argSpec.messages, validation: argSpec.validation, + messages: argSpec.messages, }; } case 'ensureHeldEntity': @@ -1004,6 +1069,7 @@ export class Parser { type: 'showText', message: step.messageId ? command.messages?.[step.messageId] : step.text, params: step.params, + paramsFromRefs: step.paramsFromRefs, }; default: return null; @@ -1173,6 +1239,50 @@ export class Parser { }; } + private resolveShowTextParams( + directParams: Record<string, string> | undefined, + paramsFromRefs: Record<string, string> | undefined, + planState: ParserPlanState + ): Record<string, string> | undefined { + const resolved: Record<string, string> = { ...(directParams || {}) }; + + for (const [paramName, refName] of Object.entries(paramsFromRefs || {})) { + const value = planState[refName]; + const displayValue = this.getPlanStateDisplayValue(value); + if (displayValue) { + resolved[paramName] = displayValue; + } + } + + return Object.keys(resolved).length ? resolved : undefined; + } + + private getPlanStateDisplayValue(value: unknown): string | null { + if (value instanceof Entity) { + return this.getPlayerFacingEntityTitle(value) || null; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (value && typeof value === 'object') { + const maybeScene = value as { title?: unknown; name?: unknown; id?: unknown }; + if (typeof maybeScene.title === 'string' && maybeScene.title.trim()) + return maybeScene.title.trim(); + if (typeof maybeScene.name === 'string' && maybeScene.name.trim()) + return maybeScene.name.trim(); + if (typeof maybeScene.id === 'string' && maybeScene.id.trim()) return maybeScene.id.trim(); + } + return null; + } + + private interpolateTemplate(template: string, params?: Record<string, string>): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_match, token: string) => { + const value = params[token]; + return value === undefined || value === null ? `{${token}}` : String(value); + }); + } + private isEntityValidForCommandArgument( entity: Entity, validation?: ParserCommandArgumentValidation diff --git a/src/mechanics/parserCommands.ts b/src/mechanics/parserCommands.ts index 6a886b2..66784e0 100644 --- a/src/mechanics/parserCommands.ts +++ b/src/mechanics/parserCommands.ts @@ -4,6 +4,7 @@ export type ParserCommandMatch = { command: ParserCommandSpec; matchedPhrase: string; remainder: string; + argumentValues: Record<string, string | null>; }; function sortByLengthDesc(values: string[]): string[] { @@ -15,6 +16,82 @@ function startsWithPhrase(input: string, phrase: string): boolean { return input.startsWith(`${phrase} `); } +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function findSeparatorMatch( + input: string, + separators: string[] +): { index: number; separator: string } | null { + const lowered = input.toLowerCase(); + let bestMatch: { index: number; separator: string } | null = null; + + for (const separator of separators) { + const normalized = separator.trim().toLowerCase(); + if (!normalized) continue; + const pattern = new RegExp(`\\s+${escapeRegex(normalized)}\\s+`, 'i'); + const match = pattern.exec(lowered); + if (!match || match.index < 0) continue; + + if (!bestMatch || match.index < bestMatch.index) { + bestMatch = { index: match.index, separator: separator.trim() }; + } + } + + return bestMatch; +} + +function extractArgumentValues( + command: ParserCommandSpec, + remainder: string +): Record<string, string | null> { + const values: Record<string, string | null> = {}; + const args = command.arguments || []; + + if (!args.length) { + return values; + } + + let cursor = remainder.trim(); + + for (let index = 0; index < args.length; index += 1) { + const current = args[index]; + const next = args[index + 1]; + + if (!next) { + values[current.name] = cursor || null; + continue; + } + + const separators = (next.separatorsBefore || []).map((item) => item.trim()).filter(Boolean); + if (!separators.length) { + values[current.name] = cursor || null; + cursor = ''; + continue; + } + + const separatorMatch = findSeparatorMatch(cursor, separators); + if (!separatorMatch) { + values[current.name] = cursor || null; + cursor = ''; + continue; + } + + values[current.name] = cursor.slice(0, separatorMatch.index).trim() || null; + + const loweredCursor = cursor.toLowerCase(); + const separatorPattern = new RegExp( + `\\s+${escapeRegex(separatorMatch.separator.toLowerCase())}\\s+`, + 'i' + ); + const fullMatch = separatorPattern.exec(loweredCursor); + cursor = fullMatch ? cursor.slice(fullMatch.index + fullMatch[0].length).trim() : ''; + } + + return values; +} + export function matchParserCommandSpec( input: string, commands: ParserCommandSpec[] @@ -34,6 +111,7 @@ export function matchParserCommandSpec( command, matchedPhrase: phrase, remainder: trimmed.slice(phrase.length).trim(), + argumentValues: extractArgumentValues(command, trimmed.slice(phrase.length).trim()), }; } } diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index 2953fdb..614998b 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -49,6 +49,7 @@ export type ParserCommandArgumentSpec = { kind: 'entity'; required: boolean; scopes: ParserScopeSlice[]; + separatorsBefore?: string[]; messages?: ParserCommandArgumentMessages; validation?: ParserCommandArgumentValidation; }; @@ -77,6 +78,7 @@ export type ParserCommandActionSpec = messageId?: string; text?: string; params?: Record<string, string>; + paramsFromRefs?: Record<string, string>; }; export type ParserCommandSpec = { @@ -167,6 +169,7 @@ export type ParserToolAction = message?: string; textKey?: string; params?: Record<string, string>; + paramsFromRefs?: Record<string, string>; }; export type ParserCascadeEnvelope = { diff --git a/tasks.md b/tasks.md index 72675db..bc5c27d 100644 --- a/tasks.md +++ b/tasks.md @@ -8,7 +8,8 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut - [x] Introduce parser custom command assets (`Commands.md`, command TA loading, shared command spec format). - [x] Expand parser DSL/Core so lower layers can mock richer Stage-2-style plans. -- [ ] Implement `TELEPORT WITH` as the first custom command scenario driven by command TA. +- [x] Implement `TELEPORT WITH` as the first custom command scenario driven by command TA. +- [x] Extend command assets to support multi-argument parsing for flows like `USE X ON Y`. ## Backlog From 0f629be18164a6046f11b235ac38b95b3564b242 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Tue, 17 Mar 2026 01:45:09 +0200 Subject: [PATCH 27/75] Refactor: add relation-aware parser resolution --- Commands.md | 5 + Parser.md | 20 +- public/text/system/parser-lexicon.json | 9 +- public/text/system/parser.json | 3 + src/core/TextAssetManager.ts | 14 ++ src/mechanics/Parser.ts | 259 +++++++++++++++++++---- src/mechanics/ParserWorldModelBuilder.ts | 1 + src/mechanics/parserLanguage.ts | 59 ++++++ src/mechanics/parserTypes.ts | 15 ++ tasks.md | 2 + 10 files changed, 348 insertions(+), 39 deletions(-) diff --git a/Commands.md b/Commands.md index 0a58cfb..54d07de 100644 --- a/Commands.md +++ b/Commands.md @@ -83,6 +83,11 @@ The flow is: 6. Multi-argument commands should be expressed through the same generic machinery, not special parser branches. 7. Words like `with`, `on`, `to`, `in`, `under` should usually be treated as grammar hints for binding arguments or relations, not as standalone commands. +This now has a concrete parser-side consequence: +- built-in `LOOK` / `EXAMINE` can already recognize relation markers such as `under`, `in`, `behind`, `near`; +- custom commands keep using the same idea through grammar markers like `separatorsBefore`; +- full execution of relation semantics still depends on future runtime scene-relation data. + --- ## Command Asset Location diff --git a/Parser.md b/Parser.md index 446aa79..73e6a1b 100644 --- a/Parser.md +++ b/Parser.md @@ -119,7 +119,7 @@ flowchart TD Текущий context включает: - `rawInput` и `normalizedInput` как metadata текущего цикла parser-а; -- текущую сцену (`id`, `name`, `title`, `description`); +- текущую сцену (`id`, `name`, `title`, `description`, `activeSubscene`); - список текстово значимых объектов сцены; - инвентарь игрока; - `pending state`, если parser уже ждёт уточнение. @@ -215,16 +215,26 @@ Stage 1 на самом деле состоит из двух внутренни - пытается распознать canonical-команду; - выделяет базовый `intent`; - нормализует или очищает `target phrase`; +- для `LOOK` / `EXAMINE` умеет извлекать relation grammar вроде `under`, `in`, `behind`, `near`; - собирает унифицированный envelope для `Core`. Подходит для: - `LOOK` - `LOOK LOGO` +- `LOOK UNDER TABLE` - `EXAMINE BOOMBOX` +- `EXAMINE IN DRAWER` - `TAKE KEY` - `INV` - `GO TO OFFICE` +Важно: +- relation-aware parsing уже начинается на уровне `Stage 1.1`; +- пока runtime не хранит явные object relations, `Core` умеет только: + - распознать relation-query; + - разрешить anchor-object через обычный resolution/clarification flow; + - и затем вернуть честный fallback, что spatial relation пока не может быть определена. + ### Stage 1.2 — NLP Layer Этот слой включается только если `Stage 1.1` не справился. @@ -606,6 +616,14 @@ Parser может задавать вопросы, если ввода недо - `GO TO` -> `Where do you want to go?` - ambiguity -> `Which one do you mean ...?` +Важно: +- parser задаёт ambiguity-question только если может показать игроку действительно различимые варианты; +- если несколько кандидатов имеют один и тот же player-facing `title`, parser не должен зацикливать уточнение; +- в таком случае применяется детерминированный tie-break: + - сначала предметы в инвентаре; + - если их несколько, по порядку инвентаря; + - иначе ближайший объект сцены. + Parser хранит `pendingState`: - intent - question diff --git a/public/text/system/parser-lexicon.json b/public/text/system/parser-lexicon.json index 5a1aaab..7b759df 100644 --- a/public/text/system/parser-lexicon.json +++ b/public/text/system/parser-lexicon.json @@ -46,5 +46,12 @@ "i'd like to" ], "articles": ["the", "a", "an", "my"], - "lookSceneWords": ["around", "here", "scene"] + "lookSceneWords": ["around", "here", "scene"], + "relationMarkers": { + "on": ["on"], + "under": ["under", "beneath"], + "in": ["in", "inside"], + "behind": ["behind"], + "near": ["near", "next to", "by"] + } } diff --git a/public/text/system/parser.json b/public/text/system/parser.json index a2ea1fc..ba46aa1 100644 --- a/public/text/system/parser.json +++ b/public/text/system/parser.json @@ -3,8 +3,11 @@ "look_default_object": "You see nothing special about the {target}.", "look_not_found": "You don't see any {target} here.", "look_which_one": "Which one do you mean: {options}?", + "look_relation_prompt": "Look where?", "examine_prompt": "Examine what?", "examine_which_one": "Which one do you want to examine: {options}?", + "examine_relation_prompt": "Examine what area?", + "relation_not_supported": "You can't determine what is {relation} the {target} from here.", "take_prompt": "Take what?", "take_which_one": "Which item do you mean: {options}?", "take_pickup_success": "You picked up the {item}.", diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 8ed96b5..83eb682 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -24,6 +24,9 @@ const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { look_which_one: 'Which one do you mean: {options}?', examine_prompt: 'Examine what?', examine_which_one: 'Which one do you want to examine: {options}?', + look_relation_prompt: 'Look where?', + examine_relation_prompt: 'Examine what area?', + relation_not_supported: "You can't determine what is {relation} the {target} from here.", take_prompt: 'Take what?', take_which_one: 'Which item do you mean: {options}?', take_pickup_success: 'You picked up the {item}.', @@ -106,6 +109,13 @@ const DEFAULT_PARSER_LEXICON: ParserLexiconAsset = { ], articles: ['the', 'a', 'an', 'my'], lookSceneWords: ['around', 'here', 'scene'], + relationMarkers: { + on: ['on'], + under: ['under', 'beneath'], + in: ['in', 'inside'], + behind: ['behind'], + near: ['near', 'next to', 'by'], + }, }; const DEFAULT_PARSER_TRAINING: ParserTrainingAsset = { @@ -472,6 +482,10 @@ export class TextAssetManager { ...DEFAULT_PARSER_LEXICON.normalizationPrefixes, ...(loaded?.normalizationPrefixes || {}), }, + relationMarkers: { + ...DEFAULT_PARSER_LEXICON.relationMarkers, + ...(loaded?.relationMarkers || {}), + }, }; this.parserLexiconLoaded = true; return this.parserLexiconCache; diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 57ffb54..705f4e7 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -2,6 +2,7 @@ import type { GameActionOutcome } from '../core/GameActionTypes'; import { NlpCascade } from './NlpCascade'; import { matchParserCommandSpec } from './parserCommands'; import { + extractRelationTargetForIntent, getStage1CommandWords, isLookSceneWord, matchStage1Intent, @@ -19,6 +20,7 @@ import type { ParserCoreDecision, ParserPlanState, ParserPendingState, + ParserRelationType, ParserResponse, ParserResult, ParserScope, @@ -101,13 +103,8 @@ export class Parser { return null; } - if (this.pendingState.intent === 'custom') { + if (this.pendingState.pendingEnvelopeJson) { const pendingEnvelopeJson = this.pendingState.pendingEnvelopeJson; - if (!pendingEnvelopeJson) { - this.pendingState = null; - return null; - } - try { const envelope = JSON.parse(pendingEnvelopeJson) as ParserCascadeEnvelope; if (envelope.output.kind !== 'plan') { @@ -116,14 +113,20 @@ export class Parser { } const patchedActions = envelope.output.actions.map((action) => { - if ( - action.type === 'resolveArgumentEntity' && - (!this.pendingState?.pendingArg || action.arg === this.pendingState.pendingArg) - ) { - return { - ...action, - query: input.trim(), - }; + if (action.type === 'resolveArgumentEntity') { + if (!this.pendingState?.pendingArg || action.arg === this.pendingState.pendingArg) { + return { + ...action, + query: input.trim(), + }; + } + return action; + } + if (action.type === 'lookRelationTarget') { + return { ...action, anchor: input.trim() || null }; + } + if (action.type === 'examineRelationTarget') { + return { ...action, anchor: input.trim() || null }; } return action; }); @@ -140,7 +143,7 @@ export class Parser { rawInput: input, normalizedInput: input.trim().toUpperCase(), noun: input.trim(), - pendingIntent: this.pendingState.commandId || 'custom', + pendingIntent: this.pendingState.commandId || this.pendingState.intent, }, }; } catch { @@ -188,15 +191,22 @@ export class Parser { switch (match?.intent) { case 'look': { + const relationQuery = extractRelationTargetForIntent(input, 'look', lexicon); const target = normalizeTargetForIntent(input, 'look', lexicon) || match.remainder || noun; return { stage: 'regex-v1', output: { kind: 'plan', actions: [ - !target || isLookSceneWord(target, lexicon) - ? { type: 'lookScene' as const } - : { type: 'lookTarget' as const, target }, + relationQuery + ? { + type: 'lookRelationTarget' as const, + relation: relationQuery.relation, + anchor: relationQuery.anchor, + } + : !target || isLookSceneWord(target, lexicon) + ? { type: 'lookScene' as const } + : { type: 'lookTarget' as const, target }, ], }, debug: { @@ -204,23 +214,32 @@ export class Parser { normalizedInput: input.trim().toUpperCase(), verb, noun, + relation: relationQuery?.relation, + anchor: relationQuery?.anchor, }, }; } - case 'examine': + case 'examine': { + const relationQuery = extractRelationTargetForIntent(input, 'examine', lexicon); return { stage: 'regex-v1', output: { kind: 'plan', actions: [ - { - type: 'examineTarget', - target: - normalizeTargetForIntent(input, 'examine', lexicon) || - match?.remainder || - noun || - null, - }, + relationQuery + ? { + type: 'examineRelationTarget' as const, + relation: relationQuery.relation, + anchor: relationQuery.anchor, + } + : { + type: 'examineTarget', + target: + normalizeTargetForIntent(input, 'examine', lexicon) || + match?.remainder || + noun || + null, + }, ], }, debug: { @@ -228,8 +247,11 @@ export class Parser { normalizedInput: input.trim().toUpperCase(), verb, noun, + relation: relationQuery?.relation, + anchor: relationQuery?.anchor, }, }; + } case 'take': return { stage: 'regex-v1', @@ -436,8 +458,12 @@ export class Parser { return this.game.lookScene(); case 'lookTarget': return this.resolveLookTarget(action.target); + case 'lookRelationTarget': + return this.resolveRelationTarget('look', action.relation, action.anchor); case 'examineTarget': return this.resolveExamineTarget(action.target); + case 'examineRelationTarget': + return this.resolveRelationTarget('examine', action.relation, action.anchor); case 'takeTarget': return this.resolveTakeTarget(action.target); case 'showInventory': @@ -484,8 +510,12 @@ export class Parser { return 'lookScene'; case 'lookTarget': return 'look'; + case 'lookRelationTarget': + return 'lookRelation'; case 'examineTarget': return 'examine'; + case 'examineRelationTarget': + return 'examineRelation'; case 'takeTarget': return 'take'; case 'showInventory': @@ -528,6 +558,58 @@ export class Parser { return Array.from(new Set(titles)); } + private areResolutionOptionsDistinct(entities: Entity[]): boolean { + const titles = this.getResolutionOptionTitles(entities); + if (!titles) return false; + return titles.length === entities.length; + } + + private getEntitySelectionPriority(entity: Entity): { + bucket: number; + order: number; + distance: number; + } { + const inventoryIndex = this.game.inventory.indexOf(entity); + if (inventoryIndex >= 0) { + return { + bucket: 0, + order: inventoryIndex, + distance: 0, + }; + } + + const scene = this.game.sceneManager.currentScene; + const player = scene?.player; + if (player) { + const dx = (entity.x || 0) - (player.x || 0); + const dy = (entity.y || 0) - (player.y || 0); + return { + bucket: 1, + order: Number.MAX_SAFE_INTEGER, + distance: Math.hypot(dx, dy), + }; + } + + return { + bucket: 1, + order: Number.MAX_SAFE_INTEGER, + distance: Number.MAX_SAFE_INTEGER, + }; + } + + private choosePreferredEntity(entities: Entity[]): Entity | null { + if (!entities.length) return null; + return [...entities].sort((left, right) => { + const a = this.getEntitySelectionPriority(left); + const b = this.getEntitySelectionPriority(right); + + if (a.bucket !== b.bucket) return a.bucket - b.bucket; + if (a.order !== b.order) return a.order - b.order; + if (a.distance !== b.distance) return a.distance - b.distance; + return left.name.localeCompare(right.name); + })[0]; + } + private getScopeCandidates(sliceNames: Array<keyof Omit<ParserScope, 'sceneTargets'>>): Entity[] { const scope = this.activeScope || this.worldModelBuilder.build('', this.pendingState).scope; const candidates: Entity[] = []; @@ -556,6 +638,10 @@ export class Parser { ); if (exactMatches.length === 1) return { status: 'found', entity: exactMatches[0] }; if (exactMatches.length > 1) { + if (!this.areResolutionOptionsDistinct(exactMatches)) { + const preferred = this.choosePreferredEntity(exactMatches); + if (preferred) return { status: 'found', entity: preferred }; + } const optionTitles = this.getResolutionOptionTitles(exactMatches); if (!optionTitles) return { status: 'escalate', code: 'ambiguous_targets_missing_titles' }; return { @@ -571,6 +657,10 @@ export class Parser { }); if (partialMatches.length === 1) return { status: 'found', entity: partialMatches[0] }; if (partialMatches.length > 1) { + if (!this.areResolutionOptionsDistinct(partialMatches)) { + const preferred = this.choosePreferredEntity(partialMatches); + if (preferred) return { status: 'found', entity: preferred }; + } const optionTitles = this.getResolutionOptionTitles(partialMatches); if (!optionTitles) return { status: 'escalate', code: 'ambiguous_targets_missing_titles' }; return { @@ -713,6 +803,69 @@ export class Parser { return this.game.examineEntity(resolved.entity); } + private resolveRelationTarget( + intent: 'look' | 'examine', + relation: ParserRelationType, + anchor: string | null + ): GameActionOutcome { + if (!anchor) { + return { + status: 'needs_clarification', + code: + intent === 'look' ? 'missing_look_relation_anchor' : 'missing_examine_relation_anchor', + message: + intent === 'look' + ? this.game.text('parser.look_relation_prompt') + : this.game.text('parser.examine_relation_prompt'), + data: { relation }, + recoverable: true, + }; + } + + const clarificationKey = + intent === 'look' ? 'parser.look_which_one' : 'parser.examine_which_one'; + const candidates = this.getScopeCandidates( + intent === 'look' ? ['visible', 'held'] : ['examinable'] + ); + const resolved = this.resolveEntityTargetInCandidates(anchor, candidates, clarificationKey); + + if (resolved.status === 'escalate') { + return { status: 'escalate', code: resolved.code, recoverable: true }; + } + if (resolved.status === 'not_found') { + return { + status: 'failed', + code: 'relation_anchor_not_found', + message: this.game.text('parser.look_not_found', { target: anchor }), + data: { relation, anchor }, + recoverable: true, + }; + } + if (resolved.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_relation_anchor', + message: resolved.message, + data: { relation, anchor, options: resolved.options }, + recoverable: true, + }; + } + + return { + status: 'failed', + code: 'relation_not_supported', + message: this.game.text('parser.relation_not_supported', { + relation: this.getRelationDisplayText(relation), + target: this.getPlayerFacingEntityTitle(resolved.entity) || anchor, + }), + data: { + relation, + anchorEntityId: resolved.entity.name, + }, + recoverable: true, + }; + } + private resolveTakeTarget(rawTarget: string | null): GameActionOutcome { if (!rawTarget) { return { @@ -1119,6 +1272,8 @@ export class Parser { typeof clarificationData.pendingArg === 'string' ? clarificationData.pendingArg : undefined; const commandId = typeof clarificationData.commandId === 'string' ? clarificationData.commandId : undefined; + const relation = + typeof clarificationData.relation === 'string' ? clarificationData.relation : undefined; const nextPendingState = pendingArg && commandId ? { @@ -1129,11 +1284,18 @@ export class Parser { pendingArg, commandId, } - : { - intent: this.extractPendingIntent(envelopeJson), - question: clarification.message || this.game.text('parser.parse_unknown'), - originalInput: this.extractRawInput(envelopeJson), - }; + : relation + ? { + intent: this.extractPendingIntent(envelopeJson), + question: clarification.message || this.game.text('parser.parse_unknown'), + originalInput: this.extractRawInput(envelopeJson), + pendingEnvelopeJson: envelopeJson, + } + : { + intent: this.extractPendingIntent(envelopeJson), + question: clarification.message || this.game.text('parser.parse_unknown'), + originalInput: this.extractRawInput(envelopeJson), + }; return { playerMessage: clarification.message || this.game.text('parser.parse_unknown'), nextPendingState, @@ -1194,17 +1356,23 @@ export class Parser { if ( firstAction && (firstAction.type === 'lookTarget' || + firstAction.type === 'lookRelationTarget' || firstAction.type === 'examineTarget' || + firstAction.type === 'examineRelationTarget' || firstAction.type === 'takeTarget' || firstAction.type === 'goToTarget') ) { return firstAction.type === 'lookTarget' ? 'look' - : firstAction.type === 'examineTarget' - ? 'examine' - : firstAction.type === 'takeTarget' - ? 'take' - : 'goTo'; + : firstAction.type === 'lookRelationTarget' + ? 'look' + : firstAction.type === 'examineTarget' + ? 'examine' + : firstAction.type === 'examineRelationTarget' + ? 'examine' + : firstAction.type === 'takeTarget' + ? 'take' + : 'goTo'; } } catch { // Fall through to default. @@ -1283,6 +1451,23 @@ export class Parser { }); } + private getRelationDisplayText(relation: ParserRelationType): string { + switch (relation) { + case 'on': + return 'on'; + case 'under': + return 'under'; + case 'in': + return 'in'; + case 'behind': + return 'behind'; + case 'near': + return 'near'; + default: + return relation; + } + } + private isEntityValidForCommandArgument( entity: Entity, validation?: ParserCommandArgumentValidation diff --git a/src/mechanics/ParserWorldModelBuilder.ts b/src/mechanics/ParserWorldModelBuilder.ts index 3611a0e..b6c3bb3 100644 --- a/src/mechanics/ParserWorldModelBuilder.ts +++ b/src/mechanics/ParserWorldModelBuilder.ts @@ -37,6 +37,7 @@ export class ParserWorldModelBuilder { name: scene.name, title: this.game.textAssets.getResolvedSceneField(scene, 'title'), description: this.game.textAssets.getResolvedSceneField(scene, 'description'), + activeSubscene: scene.activeSubscene || null, } : null, entities: scene diff --git a/src/mechanics/parserLanguage.ts b/src/mechanics/parserLanguage.ts index 405cf0c..322437f 100644 --- a/src/mechanics/parserLanguage.ts +++ b/src/mechanics/parserLanguage.ts @@ -1,3 +1,5 @@ +import type { ParserRelationType } from './parserTypes'; + export type ParserIntentId = 'look' | 'examine' | 'take' | 'goTo' | 'showInventory'; export type ParserLexiconAsset = { @@ -6,6 +8,7 @@ export type ParserLexiconAsset = { politePrefixes: string[]; articles: string[]; lookSceneWords: string[]; + relationMarkers: Record<ParserRelationType, string[]>; }; export type ParserTrainingAsset = Record<ParserIntentId, string[]>; @@ -41,6 +44,30 @@ function stripFromList(input: string, phrases: string[]): string { return value.trim(); } +function findLeadingRelation( + input: string, + relationMarkers: Record<ParserRelationType, string[]> +): { relation: ParserRelationType; marker: string } | null { + const lowered = input.trim().toLowerCase(); + const candidates: Array<{ relation: ParserRelationType; marker: string }> = []; + + for (const relation of Object.keys(relationMarkers) as ParserRelationType[]) { + for (const marker of relationMarkers[relation] || []) { + const normalized = marker.trim(); + if (!normalized) continue; + candidates.push({ relation, marker: normalized }); + } + } + + for (const candidate of candidates.sort((a, b) => b.marker.length - a.marker.length)) { + if (startsWithPhrase(lowered, candidate.marker.toLowerCase())) { + return candidate; + } + } + + return null; +} + export function matchStage1Intent(input: string, lexicon: ParserLexiconAsset): Stage1Match | null { const trimmed = input.trim(); if (!trimmed) return null; @@ -100,3 +127,35 @@ export function normalizeTargetForIntent( return value.trim() || null; } + +export function extractRelationTargetForIntent( + input: string, + intent: ParserIntentId, + lexicon: ParserLexiconAsset +): { relation: ParserRelationType; anchor: string | null } | null { + if (intent !== 'look' && intent !== 'examine') { + return null; + } + + let value = input.replace(/[?.!,]+$/g, '').trim(); + if (!value) return null; + + value = stripFromList(value, lexicon.politePrefixes || []); + value = stripFromList(value, lexicon.normalizationPrefixes[intent] || []); + if (!value) return null; + + const relationMatch = findLeadingRelation(value, lexicon.relationMarkers || ({} as any)); + if (!relationMatch) { + return null; + } + + const marker = relationMatch.marker; + const anchor = stripFromList(stripLeadingPhrase(value, value.slice(0, marker.length)), [ + ...(lexicon.articles || []), + ]); + + return { + relation: relationMatch.relation, + anchor: anchor || null, + }; +} diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index 614998b..9bb50c3 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -29,6 +29,8 @@ export type ParserPendingState = { commandId?: string; }; +export type ParserRelationType = 'on' | 'under' | 'in' | 'behind' | 'near'; + export type ParserScopeSlice = keyof Omit<ParserScope, 'sceneTargets'>; export type ParserCommandArgumentMessages = { @@ -97,6 +99,7 @@ export type ParserContext = { name: string; title: string | null; description: string | null; + activeSubscene: string | null; } | null; entities: ParserEntityContext[]; inventory: ParserInventoryItemContext[]; @@ -126,10 +129,20 @@ export type ParserToolAction = type: 'lookTarget'; target: string; } + | { + type: 'lookRelationTarget'; + relation: ParserRelationType; + anchor: string | null; + } | { type: 'examineTarget'; target: string | null; } + | { + type: 'examineRelationTarget'; + relation: ParserRelationType; + anchor: string | null; + } | { type: 'takeTarget'; target: string | null; @@ -191,6 +204,8 @@ export type ParserCascadeEnvelope = { normalizedInput: string; verb: string; noun: string; + relation?: ParserRelationType; + anchor?: string | null; pendingIntent?: string; intent?: string; score?: number; diff --git a/tasks.md b/tasks.md index bc5c27d..412e44a 100644 --- a/tasks.md +++ b/tasks.md @@ -10,6 +10,7 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut - [x] Expand parser DSL/Core so lower layers can mock richer Stage-2-style plans. - [x] Implement `TELEPORT WITH` as the first custom command scenario driven by command TA. - [x] Extend command assets to support multi-argument parsing for flows like `USE X ON Y`. +- [x] Add parser-side relation grammar recognition for queries like `LOOK UNDER TABLE` and `EXAMINE IN DRAWER`. ## Backlog @@ -49,6 +50,7 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut - post-API escalation - linear plan execution without LLM - manual checklist drafted in `ParserSmoke.md` +- [ ] Formalize runtime scene relations so relation-aware parser queries (`under`, `in`, `behind`, `near`) can execute against real world data instead of returning the current fallback message. ## Suggested Order From 4cd45ef00bd27d92bb7922e98bf39ea0644eceeb Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Wed, 18 Mar 2026 03:38:31 +0200 Subject: [PATCH 28/75] Feature: Scene spatial system Add a scene-owned spatial hierarchy for entities and subscenes, with editor authoring fields for parent/relation metadata and nested hierarchy rendering in the scene tree. Integrate spatial data into ParserWorldModelBuilder so relation queries like LOOK UNDER / IN / BEHIND execute against runtime world structure instead of fallback-only parser logic. Tighten parser context for future LLM use by removing noisy empty fields, normalizing spatial nodes, preventing technical ids from leaking to players, and moving scene-transition resolution out of parser scope into Game. --- GDD.md | 54 ++++++ Parser.md | 20 ++- public/text/system/parser.json | 2 + src/components/editor/HierarchyPanel.tsx | 64 ++++++- src/components/editor/PropertiesPanel.tsx | 189 ++++++++++++++++++- src/core/Game.ts | 38 ++++ src/core/IGame.ts | 1 + src/core/TextAssetManager.ts | 2 + src/entities/Entity.ts | 2 + src/entities/SceneObject.ts | 4 + src/entities/TriggerComponents.ts | 4 + src/mechanics/Parser.ts | 209 +++++++++++++++++----- src/mechanics/ParserWorldModelBuilder.ts | 181 ++++++++++++++----- src/mechanics/parserTypes.ts | 55 +++--- src/scene/Scene.ts | 97 +++++++++- src/scene/spatialTypes.ts | 27 +++ src/systems/ComponentSystem.ts | 6 + tasks.md | 93 +++++++++- 18 files changed, 935 insertions(+), 113 deletions(-) create mode 100644 src/scene/spatialTypes.ts diff --git a/GDD.md b/GDD.md index 6afd1c8..f9be145 100644 --- a/GDD.md +++ b/GDD.md @@ -361,6 +361,50 @@ _Disabled_ : (boolean) _Locked_ : (boolean) Объект может быть заблокирован (Locked) для редактирования в редакторе сцены. Заблокированные объекты нельзя выбрать или переместить кликом мыши на экране (они становятся "прозрачными" для кликов), но их всё ещё можно выбрать в списке объектов. В режиме игры это свойство игнорируется. +#### Пространственная вложенность (Spatial) + +Объекты сцены могут быть логически вложены друг в друга. Это относится не к рендерингу и не к parser-у, а к **модели мира**, которой владеет движок игры. Parser только получает эту структуру как часть контекста и использует её при разборе команд вроде `LOOK UNDER TABLE` или `LOOK IN DRAWER`. + +У любого подходящего объекта сцены может быть optional spatial placement: + +- `parentNodeId`: ID родительского узла; +- `relation`: тип вложения относительно родителя. + +Поддерживаются следующие типы отношений: + +- `in` : внутри; +- `on` : на; +- `under` : под; +- `behind` : за. + +Примеры: + +- ключ лежит **под** столом; +- записка лежит **в** верхнем ящике; +- монета лежит **за** картиной. + +Spatial-вложенность не определяет сама по себе: + +- видимость объекта; +- доступность объекта для взаимодействия; +- порядок отрисовки. + +Это отдельные системы. + +Spatial-система нужна для: + +- описания структуры сцены на уровне мира; +- parser-команд с пространственными уточнениями; +- point-and-click логики и скриптов, которым важно понимать, где предмет находится логически; +- редактора сцены, где вложенность должна быть видна и редактируема. + +В редакторе у объектов сцены должны быть доступны: + +- выбор родительского объекта; +- выбор типа отношения (`in`, `on`, `under`, `behind`). + +В списке иерархии сцены вложенные объекты отображаются под своим родителем со сдвигом вправо. + #### Коллайдеры (Collision Box) Объекты Entity (Static и Actor имеют свойства `Collider Width` и `Collider Height`, задающие размер прямоугольной области столкновения, которая по X центрирована по объекту, а по Y нижняя граница прямоугольника коллайдера приходится на нижнюю границу спрайта/прямоугольника объекта. То есть, при увеличении высоты коллайдера он растёт вверх, а при увеличении ширины он растёт в обе стороны от центра объекта. @@ -455,6 +499,16 @@ _Sort Mode_ (v0, v1, v2, v3, ignore) Технически это просто включение (Enable) указанных целей. Для выхода обратно в главную сцену игрок должен кликнуть за пределами объектов этой группы. Пока открыта Subscene передвижения персонажа игрока заблокированы. + В spatial-модели мира `Subscene` также может выступать как **виртуальный spatial node**, то есть узел, в который могут быть вложены другие объекты или подчинённые subscene. Это позволяет описывать, например: + + - `Стол -> Ящики стола -> Верхний ящик -> Записка` + - или `Стол -> (under) -> Ключ` + + Таким образом, вложенность может быть: + + - прямой: объект вложен в другой объект; + - опосредованной: объект вложен в `Subscene`, которая сама вложена в объект. + > Если триггер Subscene относится к переключаемой им группе объектов (имеет такой Group ID как Group_id_1 или Group_id_2), то он не дизейблит самого себя, чтобы не сломать логику переключения. Но другие объекты, которые находятся в этой группе, включая триггеры, будут дизейблиться. - _Switch_ (Target_ID_1, Target_ID_2, state (1 or 2), [Name], [id_key], [sound_1], [sound_2]) : Триггер, который при клике мышью или по команде "открыть Name" подменяет одни объекты другими, а при повторном клике или команде "закрыть Name" подменяет обратно. Например, игрок кликает по двери и она открывается, а при повторном клике закрывается. Технически "открытие/закрытие" это включение одной и выключение другой группы объектов сцены (или subscene). diff --git a/Parser.md b/Parser.md index 73e6a1b..56017d3 100644 --- a/Parser.md +++ b/Parser.md @@ -122,6 +122,7 @@ flowchart TD - текущую сцену (`id`, `name`, `title`, `description`, `activeSubscene`); - список текстово значимых объектов сцены; - инвентарь игрока; +- spatial nodes and relation projection, derived from the runtime scene hierarchy; - `pending state`, если parser уже ждёт уточнение. Текущий scope включает: @@ -162,6 +163,8 @@ flowchart TD } ], "inventory": [], + "spatialNodes": [], + "spatialRelations": [], "pending": null } ``` @@ -981,16 +984,25 @@ NLP-слой полезен, но не является фундаментом p - `examine drawer` - `look in drawer` -Пример будущей relation model: +Runtime relation model is now owned by `Game` / scene data, not by parser. Parser consumes a projection of that model through `ParserWorldModelBuilder`. + +Parser-facing relation projection: ```ts type ParserRelation = { - type: 'on' | 'under' | 'in' | 'behind' | 'near'; - sourceId: string; - targetId: string; + anchorNodeId: string; + relation: 'on' | 'under' | 'in' | 'behind'; + childNodeIds: string[]; }; ``` +Current state: +- `LOOK UNDER X` +- `LOOK IN X` +- `LOOK BEHIND X` + +already execute against real runtime spatial data. `near` remains parser-recognized but is intentionally not executed yet because its runtime semantics are still undefined. + Именно richer context/scope/relations дадут parser-у настоящую "картину мира". --- diff --git a/public/text/system/parser.json b/public/text/system/parser.json index ba46aa1..5b06c9e 100644 --- a/public/text/system/parser.json +++ b/public/text/system/parser.json @@ -7,6 +7,8 @@ "examine_prompt": "Examine what?", "examine_which_one": "Which one do you want to examine: {options}?", "examine_relation_prompt": "Examine what area?", + "relation_empty": "You see nothing {relation} the {target}.", + "relation_contents": "{Relation} the {target} you see: {items}.", "relation_not_supported": "You can't determine what is {relation} the {target} from here.", "take_prompt": "Take what?", "take_which_one": "Which item do you mean: {options}?", diff --git a/src/components/editor/HierarchyPanel.tsx b/src/components/editor/HierarchyPanel.tsx index 574b2dd..3d0816e 100644 --- a/src/components/editor/HierarchyPanel.tsx +++ b/src/components/editor/HierarchyPanel.tsx @@ -46,6 +46,57 @@ export const HierarchyPanel: React.FC = () => { const filteredTriggers = [...(scene?.triggerboxes || [])].filter((item: any) => matchesFilter(item) ); + const filteredEntityOrder = React.useMemo( + () => new Map(filteredEntities.map((entity: any, index: number) => [entity.name, index])), + [filteredEntities] + ); + const hierarchicalEntities = React.useMemo(() => { + const entityByName = new Map(filteredEntities.map((entity: any) => [entity.name, entity])); + const childrenByParent = new Map<string, any[]>(); + const roots: any[] = []; + + const pushChild = (parentId: string, entity: any) => { + const children = childrenByParent.get(parentId) || []; + children.push(entity); + childrenByParent.set(parentId, children); + }; + + filteredEntities.forEach((entity: any) => { + const parentId = + typeof entity?.spatial?.parentNodeId === 'string' ? entity.spatial.parentNodeId.trim() : ''; + if (parentId && parentId !== entity.name && entityByName.has(parentId)) { + pushChild(parentId, entity); + } else { + roots.push(entity); + } + }); + + const sortBySceneOrder = (items: any[]) => + [...items].sort( + (left, right) => + (filteredEntityOrder.get(left.name) ?? Number.MAX_SAFE_INTEGER) - + (filteredEntityOrder.get(right.name) ?? Number.MAX_SAFE_INTEGER) + ); + + const ordered: Array<{ entity: any; depth: number }> = []; + const visited = new Set<string>(); + + const walk = (entity: any, depth: number) => { + if (!entity || visited.has(entity.name)) return; + visited.add(entity.name); + ordered.push({ entity, depth }); + const children = sortBySceneOrder(childrenByParent.get(entity.name) || []); + children.forEach((child) => walk(child, depth + 1)); + }; + + sortBySceneOrder(roots).forEach((entity) => walk(entity, 0)); + + sortBySceneOrder(filteredEntities) + .filter((entity) => !visited.has(entity.name)) + .forEach((entity) => walk(entity, 0)); + + return ordered; + }, [filteredEntities, filteredEntityOrder]); // Helper to resolve display ID for an item, matching how it's identified in the UI const getDisplayId = (item: any): string => { @@ -84,7 +135,12 @@ export const HierarchyPanel: React.FC = () => { return; } - const allItems = ['SCENE', ...filteredEntities, ...filteredWalkboxes, ...filteredTriggers]; + const allItems = [ + 'SCENE', + ...hierarchicalEntities.map((item) => item.entity), + ...filteredWalkboxes, + ...filteredTriggers, + ]; if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); @@ -126,7 +182,7 @@ export const HierarchyPanel: React.FC = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ - filteredEntities, + hierarchicalEntities, filteredWalkboxes, filteredTriggers, hierarchyVersion, @@ -303,14 +359,14 @@ export const HierarchyPanel: React.FC = () => { </div> {/* Entities */} - {filteredEntities.map((ent: any) => { + {hierarchicalEntities.map(({ entity: ent, depth }) => { const isSelected = isItemSelected(ent); return ( <div key={ent.name} style={{ padding: '4px', - paddingLeft: '15px', + paddingLeft: `${8 + depth * 14}px`, marginBottom: '2px', cursor: 'pointer', borderRadius: '4px', diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index f6a0142..1dde24c 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -43,6 +43,58 @@ export const PropertiesPanel: React.FC = () => { const multiObjects = game?.editor?.selectionManager?.hasMultiSelection() ? game.editor.selectionManager.getSelectedObjects() : []; + const spatialRelationOptions = [ + { value: '', label: '(None)' }, + { value: 'in', label: 'In' }, + { value: 'on', label: 'On' }, + { value: 'under', label: 'Under' }, + { value: 'behind', label: 'Behind' }, + ]; + + const getSceneEntityParentOptions = React.useCallback(() => { + const scene = game?.sceneManager?.currentScene; + if (!scene || !obj) { + return [{ value: '', label: '(None)' }]; + } + + const options = scene.entities + .filter((entity) => entity !== obj) + .map((entity) => ({ + value: entity.name, + label: entity.customName?.trim() || entity.name, + })); + + return [{ value: '', label: '(None)' }, ...options]; + }, [game, obj]); + + const getSubsceneNodeOptions = React.useCallback( + (currentNodeId?: string) => { + const scene = game?.sceneManager?.currentScene; + if (!scene) { + return [{ value: '', label: '(None)' }]; + } + + const entityOptions = scene.entities.map((entity) => ({ + value: entity.name, + label: entity.customName?.trim() || entity.name, + })); + + const subsceneOptions = scene + .getSubsceneComponents() + .map(({ triggerbox, component }) => { + const nodeId = (component.nodeId || component.targetGroupId || triggerbox.name || '').trim(); + if (!nodeId || nodeId === currentNodeId) return null; + return { + value: nodeId, + label: component.title?.trim() || component.name?.trim() || nodeId, + }; + }) + .filter((item): item is { value: string; label: string } => !!item); + + return [{ value: '', label: '(None)' }, ...entityOptions, ...subsceneOptions]; + }, + [game] + ); const getSharedValue = (arr: any[], getter: (o: any) => any) => { if (!arr.length) return ''; @@ -1072,6 +1124,48 @@ export const PropertiesPanel: React.FC = () => { </div> </div> + {(selectedObjectType === 'Entity' || + selectedObjectType === 'Actor' || + selectedObjectType === 'Static') && ( + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label">Parent</label> + <Select + value={obj.spatial?.parentNodeId || ''} + onChange={(value) => { + obj.spatial = { + ...(obj.spatial || {}), + parentNodeId: value || null, + relation: value ? obj.spatial?.relation || null : null, + }; + incrementObjectVersion(); + }} + options={getSceneEntityParentOptions()} + style={{ width: '100%' }} + /> + </div> + <div> + <label className="e-label">Relation</label> + <Select + value={obj.spatial?.relation || ''} + onChange={(value) => { + obj.spatial = { + ...(obj.spatial || {}), + parentNodeId: obj.spatial?.parentNodeId || null, + relation: value || null, + }; + incrementObjectVersion(); + }} + options={spatialRelationOptions} + style={{ width: '100%' }} + /> + </div> + </div> + )} + {/* Color & Blend Mode */} <div className="e-row" @@ -1907,7 +2001,15 @@ export const PropertiesPanel: React.FC = () => { if (!obj.components) obj.components = []; if (type === 'Subscene') { - obj.components.push({ type: 'Subscene', targetGroupId: '', name: '' }); + obj.components.push({ + type: 'Subscene', + targetGroupId: '', + name: '', + nodeId: '', + title: '', + description: '', + spatial: { parentNodeId: null, relation: null }, + }); } else if (type === 'Subtrigger') { obj.components.push({ type: 'Subtrigger', target: '' }); } else if (type === 'Item') { @@ -2177,6 +2279,91 @@ export const PropertiesPanel: React.FC = () => { }} /> </div> + <div className="e-row"> + <label className="e-label" style={{ fontSize: '10px' }}> + Node ID + </label> + <input + type="text" + className="e-input" + value={comp.nodeId || ''} + onChange={(e) => { + comp.nodeId = e.target.value; + incrementObjectVersion(); + }} + /> + </div> + <div className="e-row"> + <label className="e-label" style={{ fontSize: '10px' }}> + Title + </label> + <input + type="text" + className="e-input" + value={comp.title || ''} + onChange={(e) => { + comp.title = e.target.value; + incrementObjectVersion(); + }} + /> + </div> + <div className="e-row"> + <label className="e-label" style={{ fontSize: '10px' }}> + Description + </label> + <input + type="text" + className="e-input" + value={comp.description || ''} + onChange={(e) => { + comp.description = e.target.value; + incrementObjectVersion(); + }} + /> + </div> + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label" style={{ fontSize: '10px' }}> + Parent Node + </label> + <Select + value={comp.spatial?.parentNodeId || ''} + onChange={(value) => { + comp.spatial = { + ...(comp.spatial || {}), + parentNodeId: value || null, + relation: value ? comp.spatial?.relation || null : null, + }; + incrementObjectVersion(); + }} + options={getSubsceneNodeOptions( + (comp.nodeId || comp.targetGroupId || obj.name || '').trim() + )} + style={{ width: '100%' }} + /> + </div> + <div> + <label className="e-label" style={{ fontSize: '10px' }}> + Relation + </label> + <Select + value={comp.spatial?.relation || ''} + onChange={(value) => { + comp.spatial = { + ...(comp.spatial || {}), + parentNodeId: comp.spatial?.parentNodeId || null, + relation: value || null, + }; + incrementObjectVersion(); + }} + options={spatialRelationOptions} + style={{ width: '100%' }} + /> + </div> + </div> </> )} diff --git a/src/core/Game.ts b/src/core/Game.ts index ea92e59..d615774 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -563,6 +563,17 @@ export class Game implements IGame { }; } + const description = + this.textAssets.getResolvedObjectField(entity, 'description') || entity.description; + if (description && description.trim()) { + return { + status: 'ok', + code: 'entity_description_fallback', + message: description, + data: { targetType: 'entity', entityId: entity.name }, + }; + } + return { status: 'escalate', code: 'missing_details', @@ -689,6 +700,33 @@ export class Game implements IGame { }; } + goToSceneTarget(target: string): GameActionOutcome { + const normalized = String(target || '').trim().toUpperCase(); + if (!normalized) { + return { + status: 'failed', + code: 'destination_not_found', + recoverable: true, + }; + } + + for (const descriptor of this.sceneManager.sceneRegistry.values()) { + if ( + descriptor.id.toUpperCase() === normalized || + descriptor.name.toUpperCase() === normalized || + (!!descriptor.title && descriptor.title.toUpperCase() === normalized) + ) { + return this.goToScene(descriptor.id); + } + } + + return { + status: 'failed', + code: 'destination_not_found', + recoverable: true, + }; + } + goToScene(sceneId: string): GameActionOutcome { const currentScene = this.sceneManager.currentScene; const activeScene = this.sceneManager.scenes.get(sceneId); diff --git a/src/core/IGame.ts b/src/core/IGame.ts index ec245c5..54e462e 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -24,6 +24,7 @@ export interface IGame { takeEntity(entity: Entity): GameActionOutcome; removeInventoryEntity(entity: Entity): GameActionOutcome; showInventory(): GameActionOutcome; + goToSceneTarget(target: string): GameActionOutcome; goToScene(sceneId: string): GameActionOutcome; goToEntity(entity: Entity): GameActionOutcome; showNotification?(text: string): void; // Optional diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 83eb682..1eea4c2 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -26,6 +26,8 @@ const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { examine_which_one: 'Which one do you want to examine: {options}?', look_relation_prompt: 'Look where?', examine_relation_prompt: 'Examine what area?', + relation_empty: 'You see nothing {relation} the {target}.', + relation_contents: '{Relation} the {target} you see: {items}.', relation_not_supported: "You can't determine what is {relation} the {target} from here.", take_prompt: 'Take what?', take_which_one: 'Which item do you mean: {options}?', diff --git a/src/entities/Entity.ts b/src/entities/Entity.ts index f29177d..935a16d 100644 --- a/src/entities/Entity.ts +++ b/src/entities/Entity.ts @@ -2,6 +2,7 @@ import { Animator } from '../core/Animator'; import type { IGame } from '../core/IGame'; import { SceneObject } from './SceneObject'; import { Theme } from '../utils/Theme'; +import type { SpatialPlacement } from '../scene/spatialTypes'; export interface EntityData { type: string; @@ -36,6 +37,7 @@ export interface EntityData { opacity?: number; // Added blendMode?: string; // Added blur?: number; // Added + spatial?: SpatialPlacement; } export class Entity extends SceneObject { diff --git a/src/entities/SceneObject.ts b/src/entities/SceneObject.ts index a7bdb6a..882fcf1 100644 --- a/src/entities/SceneObject.ts +++ b/src/entities/SceneObject.ts @@ -19,6 +19,8 @@ export class SceneObject { layer: number = 0; visible: boolean = true; // Controls rendering only (optimization/culling) + spatial: { parentNodeId?: string | null; relation?: 'in' | 'on' | 'under' | 'behind' | null } = + {}; /** * List of properties to be serialized to/from JSON. @@ -36,6 +38,7 @@ export class SceneObject { 'components', 'layer', 'visible', + 'spatial', ]; constructor(name: string, type: string) { @@ -45,6 +48,7 @@ export class SceneObject { this.disabled = false; this.layer = 0; this.visible = true; + this.spatial = {}; this.customName = ''; this.textRedirects = {}; this.interactions = {}; diff --git a/src/entities/TriggerComponents.ts b/src/entities/TriggerComponents.ts index 84ad4cd..e4d0421 100644 --- a/src/entities/TriggerComponents.ts +++ b/src/entities/TriggerComponents.ts @@ -6,6 +6,10 @@ export interface SubsceneTrigger extends TriggerComponent { type: 'Subscene'; targetGroupId: string; name?: string; + nodeId?: string; + title?: string; + description?: string | null; + spatial?: { parentNodeId?: string | null; relation?: 'in' | 'on' | 'under' | 'behind' | null }; } export interface Subtrigger extends TriggerComponent { diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 705f4e7..cb7a172 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -10,7 +10,6 @@ import { } from './parserLanguage'; import { ParserWorldModelBuilder } from './ParserWorldModelBuilder'; import { Entity } from '../entities/Entity'; -import type { SceneDescriptor } from '../scene/SceneManager'; import { ComponentSystem } from '../systems/ComponentSystem'; import type { ParserCascadeEnvelope, @@ -24,7 +23,9 @@ import type { ParserResponse, ParserResult, ParserScope, + ParserSpatialNodeContext, ParserToolAction, + ParserWorldModel, } from './parserTypes'; export class Parser { @@ -33,6 +34,7 @@ export class Parser { pendingState: ParserPendingState | null; nlpCascade: NlpCascade; worldModelBuilder: ParserWorldModelBuilder; + activeWorldModel: ParserWorldModel | null; activeScope: ParserScope | null; constructor(game: any) { @@ -41,6 +43,7 @@ export class Parser { this.pendingState = null; this.nlpCascade = new NlpCascade(() => this.game.textAssets); this.worldModelBuilder = new ParserWorldModelBuilder(this.game); + this.activeWorldModel = null; this.activeScope = null; } @@ -51,10 +54,10 @@ export class Parser { this.nlpCascade.clearLastDebugInfo(); const actionEnvelope = this.resolvePendingAction(trimmed); const worldModel = this.worldModelBuilder.build(trimmed, this.pendingState); + this.activeWorldModel = worldModel; const context = worldModel.context; this.activeScope = worldModel.scope; const contextJson = JSON.stringify(context); - const scopeJson = JSON.stringify(this.buildPeekScopeSummary(worldModel.scope)); let envelope = actionEnvelope || (this.game.console?.parserStage1Enabled === false @@ -72,6 +75,7 @@ export class Parser { } } + const scopeJson = JSON.stringify(this.buildPeekScopeSummary(worldModel.scope)); const envelopeJson = JSON.stringify(envelope); const resultJson = this.runParserCore(envelope); const response = this.buildResponse(resultJson, envelopeJson, contextJson, scopeJson); @@ -90,6 +94,7 @@ export class Parser { } } catch (error) { this.pendingState = null; + this.activeWorldModel = null; this.activeScope = null; this.game.console?.log(`[Parser error] ${String(error)}`, 'error'); this.game.log(this.game.text('parser.parse_unknown')); @@ -610,7 +615,7 @@ export class Parser { })[0]; } - private getScopeCandidates(sliceNames: Array<keyof Omit<ParserScope, 'sceneTargets'>>): Entity[] { + private getScopeCandidates(sliceNames: Array<keyof ParserScope>): Entity[] { const scope = this.activeScope || this.worldModelBuilder.build('', this.pendingState).scope; const candidates: Entity[] = []; for (const sliceName of sliceNames) { @@ -619,6 +624,84 @@ export class Parser { return Array.from(new Set(candidates)); } + private getContextEntityById(id: string): { title: string; synonyms?: string[] } | null { + const entities = this.activeWorldModel?.context.entities || []; + return entities.find((entity) => entity.id === id) || null; + } + + private getSpatialNodeLookupTokens(node: ParserSpatialNodeContext): string[] { + const entityContext = this.getContextEntityById(node.id); + const title = entityContext?.title || node.title; + const synonyms = entityContext?.synonyms || []; + return Array.from( + new Set([title, ...synonyms].filter((item): item is string => !!item?.trim())) + ).map((item) => item.trim().toUpperCase()); + } + + private getSpatialNodes(): ParserSpatialNodeContext[] { + return this.activeWorldModel?.context.spatialNodes || []; + } + + private getSpatialNodeById(id: string): ParserSpatialNodeContext | null { + return this.getSpatialNodes().find((node) => node.id === id) || null; + } + + private getSpatialNodeDisplayTitle(node: ParserSpatialNodeContext): string { + const entityContext = this.getContextEntityById(node.id); + return entityContext?.title?.trim() || node.title?.trim() || ''; + } + + private resolveSpatialNodeTarget( + rawTarget: string, + clarificationKey: string + ): + | { status: 'found'; node: ParserSpatialNodeContext } + | { status: 'not_found' } + | { status: 'ambiguous'; message: string; options: string[] } + | { status: 'escalate'; code: string } { + const normalizedTarget = String(rawTarget || '') + .trim() + .toUpperCase(); + if (!normalizedTarget) return { status: 'not_found' }; + + const nodes = this.getSpatialNodes(); + const exactMatches = nodes.filter((node) => + this.getSpatialNodeLookupTokens(node).includes(normalizedTarget) + ); + if (exactMatches.length === 1) return { status: 'found', node: exactMatches[0] }; + if (exactMatches.length > 1) { + const options = Array.from(new Set(exactMatches.map((node) => this.getSpatialNodeDisplayTitle(node)))); + if (options.some((option) => !option) || options.length !== exactMatches.length) { + return { status: 'escalate', code: 'ambiguous_spatial_nodes_missing_titles' }; + } + return { + status: 'ambiguous', + message: this.game.text(clarificationKey, { options: options.join(', ') }), + options, + }; + } + + const partialMatches = nodes.filter((node) => + this.getSpatialNodeLookupTokens(node).some((token) => token.includes(normalizedTarget)) + ); + if (partialMatches.length === 1) return { status: 'found', node: partialMatches[0] }; + if (partialMatches.length > 1) { + const options = Array.from( + new Set(partialMatches.map((node) => this.getSpatialNodeDisplayTitle(node))) + ); + if (options.some((option) => !option) || options.length !== partialMatches.length) { + return { status: 'escalate', code: 'ambiguous_spatial_nodes_missing_titles' }; + } + return { + status: 'ambiguous', + message: this.game.text(clarificationKey, { options: options.join(', ') }), + options, + }; + } + + return { status: 'not_found' }; + } + private resolveEntityTargetInCandidates( rawTarget: string, candidates: Entity[], @@ -716,24 +799,6 @@ export class Parser { }; } - private resolveSceneTarget(rawTarget: string): SceneDescriptor | null { - const normalized = String(rawTarget || '') - .trim() - .toUpperCase(); - if (!normalized) return null; - const scope = this.activeScope || this.worldModelBuilder.build('', this.pendingState).scope; - for (const descriptor of scope.sceneTargets) { - if ( - descriptor.id.toUpperCase() === normalized || - descriptor.name.toUpperCase() === normalized || - (!!descriptor.title && descriptor.title.toUpperCase() === normalized) - ) { - return descriptor; - } - } - return null; - } - private resolveLookTarget(rawTarget: string): GameActionOutcome { const resolved = this.resolveEntityTargetInCandidates( rawTarget, @@ -824,10 +889,7 @@ export class Parser { const clarificationKey = intent === 'look' ? 'parser.look_which_one' : 'parser.examine_which_one'; - const candidates = this.getScopeCandidates( - intent === 'look' ? ['visible', 'held'] : ['examinable'] - ); - const resolved = this.resolveEntityTargetInCandidates(anchor, candidates, clarificationKey); + const resolved = this.resolveSpatialNodeTarget(anchor, clarificationKey); if (resolved.status === 'escalate') { return { status: 'escalate', code: resolved.code, recoverable: true }; @@ -851,18 +913,86 @@ export class Parser { }; } + if (relation === 'near') { + const nodeTitle = this.getSpatialNodeDisplayTitle(resolved.node); + if (!nodeTitle) { + return { + status: 'escalate', + code: 'spatial_node_missing_title', + recoverable: true, + }; + } + return { + status: 'failed', + code: 'relation_not_supported', + message: this.game.text('parser.relation_not_supported', { + relation: this.getRelationDisplayText(relation), + target: nodeTitle, + }), + data: { + relation, + anchorNodeId: resolved.node.id, + }, + recoverable: true, + }; + } + + const matchingRelation = this.activeWorldModel?.context.spatialRelations?.find( + (item) => item.anchorNodeId === resolved.node.id && item.relation === relation + ); + const childNodes = (matchingRelation?.childNodeIds || []) + .map((id) => this.getSpatialNodeById(id)) + .filter((node): node is ParserSpatialNodeContext => !!node); + const anchorTitle = this.getSpatialNodeDisplayTitle(resolved.node); + if (!anchorTitle) { + return { + status: 'escalate', + code: 'spatial_node_missing_title', + recoverable: true, + }; + } + + if (!childNodes.length) { + return { + status: 'ok', + code: 'relation_empty', + message: this.game.text('parser.relation_empty', { + relation: this.getRelationDisplayText(relation), + target: anchorTitle, + }), + data: { + relation, + anchorNodeId: resolved.node.id, + }, + }; + } + + const itemTitles = childNodes + .map((node) => this.getSpatialNodeDisplayTitle(node)) + .filter((title) => !!title); + if (itemTitles.length !== childNodes.length) { + return { + status: 'escalate', + code: 'spatial_node_missing_title', + recoverable: true, + }; + } + + const items = itemTitles.join(', '); return { - status: 'failed', - code: 'relation_not_supported', - message: this.game.text('parser.relation_not_supported', { + status: 'ok', + code: 'relation_contents', + message: this.game.text('parser.relation_contents', { + Relation: this.capitalize(this.getRelationDisplayText(relation)), relation: this.getRelationDisplayText(relation), - target: this.getPlayerFacingEntityTitle(resolved.entity) || anchor, + target: anchorTitle, + items, }), data: { relation, - anchorEntityId: resolved.entity.name, + anchorNodeId: resolved.node.id, + childNodeIds: childNodes.map((node) => node.id), }, - recoverable: true, }; } @@ -950,9 +1080,9 @@ export class Parser { }; } - const sceneMatch = this.resolveSceneTarget(rawTarget); - if (sceneMatch) { - return this.game.goToScene(sceneMatch.id); + const sceneOutcome = this.game.goToSceneTarget(rawTarget); + if (sceneOutcome.status === 'ok') { + return sceneOutcome; } const resolved = this.resolveEntityTargetInCandidates( @@ -1059,7 +1189,7 @@ export class Parser { private resolveDistanceFailureForArgument( rawTarget: string, - scopes: Array<keyof Omit<ParserScope, 'sceneTargets'>> + scopes: Array<keyof ParserScope> ): GameActionOutcome | null { if (!scopes.includes('reachable') || scopes.includes('visible')) { return null; @@ -1399,11 +1529,6 @@ export class Parser { reachable: scope.reachable.map((entity) => entity.name), examinable: scope.examinable.map((entity) => entity.name), subscene: scope.subscene.map((entity) => entity.name), - sceneTargets: scope.sceneTargets.map((scene) => ({ - id: scene.id, - name: scene.name, - title: scene.title, - })), }; } @@ -1468,6 +1593,10 @@ export class Parser { } } + private capitalize(value: string): string { + return value ? value.charAt(0).toUpperCase() + value.slice(1) : value; + } + private isEntityValidForCommandArgument( entity: Entity, validation?: ParserCommandArgumentValidation diff --git a/src/mechanics/ParserWorldModelBuilder.ts b/src/mechanics/ParserWorldModelBuilder.ts index b6c3bb3..e76e104 100644 --- a/src/mechanics/ParserWorldModelBuilder.ts +++ b/src/mechanics/ParserWorldModelBuilder.ts @@ -1,12 +1,16 @@ import type { Game } from '../core/Game'; import type { Entity } from '../entities/Entity'; +import type { Scene } from '../scene/Scene'; import { ComponentSystem } from '../systems/ComponentSystem'; import type { ParserContext, ParserEntityContext, ParserInventoryItemContext, ParserPendingState, + ParserRelationType, ParserScope, + ParserSpatialNodeContext, + ParserSpatialRelationContext, ParserWorldModel, } from './parserTypes'; @@ -27,49 +31,127 @@ export class ParserWorldModelBuilder { private buildContext(rawInput: string, pendingState: ParserPendingState | null): ParserContext { const scene = this.game.sceneManager.currentScene; const normalizedInput = rawInput.trim().toUpperCase(); + const sceneContext = scene ? this.buildSceneContext(scene) : undefined; + const entities = scene ? this.buildEntityContexts(scene) : []; + const inventory = this.buildInventoryContexts(); + const spatialRelations = scene ? this.buildSpatialRelations(scene) : []; + const spatialNodes = scene ? this.buildSpatialNodes(scene) : []; + const pending = pendingState + ? { + intent: pendingState.intent, + question: pendingState.question, + originalInput: pendingState.originalInput, + } + : undefined; - return { + return this.compactRecord({ rawInput, normalizedInput, - scene: scene - ? { - id: scene.id, - name: scene.name, - title: this.game.textAssets.getResolvedSceneField(scene, 'title'), - description: this.game.textAssets.getResolvedSceneField(scene, 'description'), - activeSubscene: scene.activeSubscene || null, - } - : null, - entities: scene - ? (scene.entities || []) - .map((entity: any) => ({ - id: entity.name, - type: entity.type, - title: this.game.textAssets.getResolvedObjectField(entity, 'title'), - synonyms: this.game.textAssets.getResolvedObjectListField(entity, 'synonyms'), - description: this.game.textAssets.getResolvedObjectField(entity, 'description'), - details: this.game.textAssets.getResolvedObjectField(entity, 'details'), - interactions: Object.keys(entity.interactions || {}), - })) - .filter((entity: ParserEntityContext) => !!entity.title?.trim()) - : [], - inventory: (this.game.inventory || []) - .map((entity: any) => ({ + scene: sceneContext, + entities, + inventory, + spatialNodes, + spatialRelations, + pending, + }); + } + + private buildSceneContext(scene: Scene): NonNullable<ParserContext['scene']> { + return this.compactRecord({ + id: scene.id, + title: this.game.textAssets.getResolvedSceneField(scene, 'title') || undefined, + description: this.game.textAssets.getResolvedSceneField(scene, 'description') || undefined, + activeSubscene: scene.activeSubscene || undefined, + }); + } + + private buildEntityContexts(scene: Scene): ParserEntityContext[] { + return (scene.entities || []) + .map((entity: any) => { + const title = this.game.textAssets.getResolvedObjectField(entity, 'title')?.trim(); + if (!title) return null; + const synonyms = this.game.textAssets.getResolvedObjectListField(entity, 'synonyms'); + const interactions = Object.keys(entity.interactions || {}); + return this.compactRecord<ParserEntityContext>({ id: entity.name, - title: this.game.textAssets.getResolvedObjectField(entity, 'title'), + type: entity.type, + title, + synonyms, + description: this.game.textAssets.getResolvedObjectField(entity, 'description') || undefined, + details: this.game.textAssets.getResolvedObjectField(entity, 'details') || undefined, + interactions, + }); + }) + .filter((entity): entity is ParserEntityContext => !!entity); + } + + private buildInventoryContexts(): ParserInventoryItemContext[] { + return (this.game.inventory || []) + .map((entity: any) => { + const title = this.game.textAssets.getResolvedObjectField(entity, 'title')?.trim(); + if (!title) return null; + return this.compactRecord<ParserInventoryItemContext>({ + id: entity.name, + title, synonyms: this.game.textAssets.getResolvedObjectListField(entity, 'synonyms'), - description: this.game.textAssets.getResolvedObjectField(entity, 'description'), - details: this.game.textAssets.getResolvedObjectField(entity, 'details'), - })) - .filter((entity: ParserInventoryItemContext) => !!entity.title?.trim()), - pending: pendingState - ? { - intent: pendingState.intent, - question: pendingState.question, - originalInput: pendingState.originalInput, - } - : null, - }; + description: this.game.textAssets.getResolvedObjectField(entity, 'description') || undefined, + details: this.game.textAssets.getResolvedObjectField(entity, 'details') || undefined, + }); + }) + .filter((entity): entity is ParserInventoryItemContext => !!entity); + } + + private buildSpatialNodes(scene: Scene): ParserSpatialNodeContext[] { + const descriptors = scene.getSpatialNodeDescriptors(); + const spatialIndex = scene.getSpatialIndex(); + const connectedNodeIds = new Set<string>(); + for (const [parentId, children] of spatialIndex.childrenByParentId.entries()) { + if (children.length) connectedNodeIds.add(parentId); + for (const child of children) { + connectedNodeIds.add(child.id); + } + } + + return descriptors + .filter((descriptor) => connectedNodeIds.has(descriptor.id)) + .map((descriptor) => { + if (descriptor.kind === 'entity') { + return this.compactRecord<ParserSpatialNodeContext>({ + id: descriptor.id, + parentNodeId: descriptor.placement?.parentNodeId || undefined, + relation: + (descriptor.placement?.relation as Exclude<ParserRelationType, 'near'> | null) || + undefined, + }); + } + + return this.compactRecord<ParserSpatialNodeContext>({ + id: descriptor.id, + subscene: true, + title: descriptor.title || undefined, + parentNodeId: descriptor.placement?.parentNodeId || undefined, + relation: + (descriptor.placement?.relation as Exclude<ParserRelationType, 'near'> | null) || + undefined, + }); + }); + } + + private buildSpatialRelations(scene: Scene): ParserSpatialRelationContext[] { + const spatialIndex = scene.getSpatialIndex(); + const relations: ParserSpatialRelationContext[] = []; + + for (const [anchorNodeId, relationMap] of spatialIndex.childrenByParentAndRelation.entries()) { + for (const [relation, nodes] of relationMap.entries()) { + relations.push({ + anchorNodeId, + relation, + childNodeIds: nodes.map((node) => node.id), + }); + } + } + + return relations; } private buildScope(): ParserScope { @@ -97,8 +179,6 @@ export class ParserWorldModelBuilder { ) : []; const examinable = this.uniqueEntities([...held, ...subscene, ...reachable]); - const sceneTargets = Array.from(this.game.sceneManager.sceneRegistry.values()); - return { visible, held, @@ -106,7 +186,6 @@ export class ParserWorldModelBuilder { reachable, examinable, subscene, - sceneTargets, }; } @@ -115,6 +194,26 @@ export class ParserWorldModelBuilder { return title && title.trim() ? title.trim() : null; } + private compactRecord<T extends Record<string, unknown>>(value: T): T { + const result: Record<string, unknown> = {}; + for (const [key, entry] of Object.entries(value)) { + if (entry === null || entry === undefined) continue; + if (Array.isArray(entry)) { + if (!entry.length) continue; + result[key] = entry; + continue; + } + if (typeof entry === 'object') { + const nested = this.compactRecord(entry as Record<string, unknown>); + if (!Object.keys(nested).length) continue; + result[key] = nested; + continue; + } + result[key] = entry; + } + return result as T; + } + private uniqueEntities(entities: Entity[]): Entity[] { return Array.from(new Set(entities)); } diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index 9bb50c3..80341fd 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -1,23 +1,36 @@ import type { GameActionOutcome } from '../core/GameActionTypes'; import type { Entity } from '../entities/Entity'; -import type { SceneDescriptor } from '../scene/SceneManager'; export type ParserEntityContext = { id: string; type: string; - title: string | null; - synonyms: string[]; - description: string | null; - details: string | null; - interactions: string[]; + title: string; + synonyms?: string[]; + description?: string; + details?: string; + interactions?: string[]; }; export type ParserInventoryItemContext = { id: string; - title: string | null; - synonyms: string[]; - description: string | null; - details: string | null; + title: string; + synonyms?: string[]; + description?: string; + details?: string; +}; + +export type ParserSpatialNodeContext = { + id: string; + subscene?: true; + title?: string; + parentNodeId?: string; + relation?: Exclude<ParserRelationType, 'near'>; +}; + +export type ParserSpatialRelationContext = { + anchorNodeId: string; + relation: Exclude<ParserRelationType, 'near'>; + childNodeIds: string[]; }; export type ParserPendingState = { @@ -31,7 +44,7 @@ export type ParserPendingState = { export type ParserRelationType = 'on' | 'under' | 'in' | 'behind' | 'near'; -export type ParserScopeSlice = keyof Omit<ParserScope, 'sceneTargets'>; +export type ParserScopeSlice = keyof ParserScope; export type ParserCommandArgumentMessages = { missing?: string; @@ -94,16 +107,17 @@ export type ParserCommandSpec = { export type ParserContext = { rawInput: string; normalizedInput: string; - scene: { + scene?: { id: string; - name: string; - title: string | null; - description: string | null; - activeSubscene: string | null; - } | null; - entities: ParserEntityContext[]; - inventory: ParserInventoryItemContext[]; - pending: ParserPendingState | null; + title?: string; + description?: string; + activeSubscene?: string; + }; + entities?: ParserEntityContext[]; + inventory?: ParserInventoryItemContext[]; + spatialNodes?: ParserSpatialNodeContext[]; + spatialRelations?: ParserSpatialRelationContext[]; + pending?: ParserPendingState; }; export type ParserScope = { @@ -113,7 +127,6 @@ export type ParserScope = { reachable: Entity[]; examinable: Entity[]; subscene: Entity[]; - sceneTargets: SceneDescriptor[]; }; export type ParserWorldModel = { diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index e7591c0..7b353db 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -13,6 +13,13 @@ import { updateSceneCamera } from './SceneCamera'; import { resolveSceneTargets, cleanupClosingSubscene } from './SceneSubscene'; import { handleSceneClick, activateSceneObject } from './SceneInteraction'; import { useEditorStore } from '../store/editorStore'; +import type { + SpatialIndex, + SpatialNodeDescriptor, + SpatialPlacement, + SpatialRelationType, +} from './spatialTypes'; +import type { SubsceneComponent } from '../systems/ComponentSystem'; export interface SceneScaling { enabled: boolean; @@ -33,7 +40,12 @@ export interface SceneData { name: string; mode?: 'Invert' | 'Add' | 'Subtract'; }[]; - triggerboxes: { poly: { x: number; y: number }[]; name: string; script: string }[]; + triggerboxes: { + poly: { x: number; y: number }[]; + name: string; + script: string; + components?: any[]; + }[]; scaling: SceneScaling; entities: EntityData[]; camera?: { x: number; y: number; zoom: number }; @@ -105,6 +117,89 @@ export class Scene { this._activeSubscene = value; } + private normalizeSpatialPlacement(value: SpatialPlacement | undefined | null): SpatialPlacement | null { + if (!value) return null; + const parentNodeId = typeof value.parentNodeId === 'string' ? value.parentNodeId.trim() : ''; + const relation = + value.relation === 'in' || + value.relation === 'on' || + value.relation === 'under' || + value.relation === 'behind' + ? value.relation + : null; + if (!parentNodeId && !relation) return null; + return { + parentNodeId: parentNodeId || null, + relation, + }; + } + + getSubsceneComponents(): Array<{ triggerbox: Triggerbox; component: SubsceneComponent }> { + const result: Array<{ triggerbox: Triggerbox; component: SubsceneComponent }> = []; + for (const triggerbox of this.triggerboxes) { + for (const component of triggerbox.components || []) { + if (component?.type === 'Subscene') { + result.push({ triggerbox, component: component as SubsceneComponent }); + } + } + } + return result; + } + + getSpatialNodeDescriptors(): SpatialNodeDescriptor[] { + const entityNodes: SpatialNodeDescriptor[] = this.entities.map((entity) => ({ + id: entity.name, + kind: 'entity', + title: this.game.textAssets.getResolvedObjectField(entity, 'title')?.trim() || null, + placement: this.normalizeSpatialPlacement((entity as any).spatial), + sourceName: entity.name, + })); + + const subsceneNodes: SpatialNodeDescriptor[] = this.getSubsceneComponents().map( + ({ triggerbox, component }) => ({ + id: (component.nodeId || component.targetGroupId || triggerbox.name || '').trim(), + kind: 'subscene' as const, + title: component.title?.trim() || component.name?.trim() || null, + placement: this.normalizeSpatialPlacement(component.spatial), + sourceName: triggerbox.name, + }) + ); + + return [...entityNodes, ...subsceneNodes].filter((node) => !!node.id); + } + + getSpatialIndex(): SpatialIndex { + const nodeById = new Map<string, SpatialNodeDescriptor>(); + const childrenByParentId = new Map<string, SpatialNodeDescriptor[]>(); + const childrenByParentAndRelation = new Map< + string, + Map<SpatialRelationType, SpatialNodeDescriptor[]> + >(); + + for (const node of this.getSpatialNodeDescriptors()) { + nodeById.set(node.id, node); + const parentId = node.placement?.parentNodeId?.trim(); + const relation = node.placement?.relation || null; + if (!parentId || !relation) continue; + + const existingChildren = childrenByParentId.get(parentId) || []; + existingChildren.push(node); + childrenByParentId.set(parentId, existingChildren); + + const relationMap = childrenByParentAndRelation.get(parentId) || new Map(); + const relationChildren = relationMap.get(relation) || []; + relationChildren.push(node); + relationMap.set(relation, relationChildren); + childrenByParentAndRelation.set(parentId, relationMap); + } + + return { + nodeById, + childrenByParentId, + childrenByParentAndRelation, + }; + } + constructor(game: IGame, id: string, name: string) { this.game = game; this.id = id; diff --git a/src/scene/spatialTypes.ts b/src/scene/spatialTypes.ts new file mode 100644 index 0000000..bf63b8a --- /dev/null +++ b/src/scene/spatialTypes.ts @@ -0,0 +1,27 @@ +export type SpatialRelationType = 'in' | 'on' | 'under' | 'behind'; + +export type SpatialPlacement = { + parentNodeId?: string | null; + relation?: SpatialRelationType | null; +}; + +export type SpatialSubsceneData = { + nodeId?: string; + title?: string; + description?: string | null; + spatial?: SpatialPlacement; +}; + +export type SpatialNodeDescriptor = { + id: string; + kind: 'entity' | 'subscene'; + title: string | null; + placement: SpatialPlacement | null; + sourceName: string; +}; + +export type SpatialIndex = { + nodeById: Map<string, SpatialNodeDescriptor>; + childrenByParentId: Map<string, SpatialNodeDescriptor[]>; + childrenByParentAndRelation: Map<string, Map<SpatialRelationType, SpatialNodeDescriptor[]>>; +}; diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index 1aab592..a974085 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -4,6 +4,7 @@ import { QuadObject } from '../entities/QuadObject'; // or just import them. Circular imports are handled by webpack/vite usually, but let's be careful. // Actually, using them as Types is fine. import type { Actor } from '../entities/Actor'; +import type { SpatialPlacement } from '../scene/spatialTypes'; import { ShadowSystem, type ShadowComponent } from './ShadowSystem'; @@ -12,6 +13,11 @@ import { BackfaceSystem, type BackfaceComponent } from './BackfaceSystem'; export interface SubsceneComponent { type: 'Subscene'; targetGroupId: string; + name?: string; + nodeId?: string; + title?: string; + description?: string | null; + spatial?: SpatialPlacement; } export interface SwitchComponent { diff --git a/tasks.md b/tasks.md index 412e44a..d445aa2 100644 --- a/tasks.md +++ b/tasks.md @@ -12,6 +12,21 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut - [x] Extend command assets to support multi-argument parsing for flows like `USE X ON Y`. - [x] Add parser-side relation grammar recognition for queries like `LOOK UNDER TABLE` and `EXAMINE IN DRAWER`. +## Next Initiative: Spatial Hierarchy In Game + +Goal: +- move spatial world structure into `Game` / scene runtime instead of keeping it as parser-only semantics; +- keep `visibility` and `accessibility` explicitly out of scope for this step; +- let parser consume spatial data as part of world context rather than owning it; +- add editor support so scene authors can assign parent object and relation type. + +Architecture rules for this initiative: +- `spatial` belongs to the world model, not to the parser; +- parser should only read spatial structure through `ParserWorldModelBuilder`; +- visibility/accessibility remain separate concerns and are not part of this task; +- both direct object-to-object nesting and object/subscene nesting must be supported; +- subscene should act as a virtual spatial node as well as a focus/interaction mechanism. + ## Backlog - [x] Replace the separate `ParserContextBuilder` / `ParserScopeBuilder` idea with one `ParserWorldModelBuilder` that returns both `context` and `scope`. @@ -50,7 +65,82 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut - post-API escalation - linear plan execution without LLM - manual checklist drafted in `ParserSmoke.md` -- [ ] Formalize runtime scene relations so relation-aware parser queries (`under`, `in`, `behind`, `near`) can execute against real world data instead of returning the current fallback message. +- [ ] Formalize runtime spatial hierarchy in `Game` so relation-aware parser queries (`under`, `in`, `behind`, `near`) can execute against real world data instead of returning the current fallback message. + +## Spatial Hierarchy Plan + +### 1. Runtime / Scene Model + +- [x] Define shared runtime types for spatial placement: + - `parentNodeId` + - `relation` + - relation enum: `in`, `on`, `under`, `behind` +- [x] Add optional spatial metadata to regular scene entities. +- [x] Extend subscene data so a subscene can act as a virtual spatial node: + - stable node id + - title + - optional description + - optional spatial parent link +- [ ] Decide where subscene spatial metadata lives in scene JSON: + - preferably on the `Subscene` component / triggerbox data so migration stays incremental. +- [x] Build a scene-level spatial index in runtime: + - node lookup by id + - children by parent id + - children grouped by relation +- [ ] Keep this index separate from render hierarchy and separate from visibility/accessibility logic. + +### 2. Parser Integration + +- [x] Extend `ParserWorldModelBuilder` so parser context includes spatial data projected from runtime. +- [x] Define parser-facing relation projection: + - anchor node id + - relation type + - child node ids +- [x] Replace the current relation-query fallback path with real lookup against runtime spatial data. +- [x] Support first real execution cases: + - `LOOK UNDER X` + - `LOOK IN X` + - `LOOK BEHIND X` +- [ ] Keep `near` out of execution until its runtime semantics are clearly defined. +- [ ] Preserve current clarification behavior: + - resolve anchor + - ambiguity handling + - tie-break rules for non-usable ambiguity + +### 3. Editor / UI Authoring + +- [x] Add editor UI for every scene `Entity` to choose: + - parent object / node + - relation type +- [ ] Limit parent candidates to valid nodes in the current scene. +- [x] Add editor UI for `Subscene` to edit: + - title + - node id + - optional parent node + - relation type +- [ ] Ensure authoring UI does not imply visibility/accessibility behavior that is not implemented yet. +- [ ] Add serialization/deserialization support for the new spatial fields. +- [x] Show spatial nesting visually in `HierarchyPanel` for scene entities: + - child entities render below their parent + - nested entities are indented to the right + - flat list order remains stable for roots and fallback cases + +### 4. Migration / Compatibility + +- [ ] Keep existing scenes valid with all spatial fields optional. +- [ ] Preserve current `activeSubscene` / `subsceneEntities` behavior during migration. +- [ ] Make parser relation grammar continue to work even before a scene defines any spatial metadata. +- [ ] Add smoke checks for scenes mixing: + - direct object nesting + - object inside subscene + - subscene inside object + - nested subscene chains + +### 5. Documentation + +- [ ] Update `Parser.md` so it clearly states spatial hierarchy is owned by `Game`, not parser. +- [ ] Add or update documentation for scene spatial schema and subscene-as-node behavior. +- [ ] Document the editor workflow for assigning parent object and relation type. ## Suggested Order @@ -60,6 +150,7 @@ These tasks cover the parser roadmap described in `Parser.md`, excluding the fut 4. Add `synonyms` support to TA and target resolution. 5. Improve `#PEEK`. 6. Run regression checks and clean up boundaries with `Game API`. +7. Introduce runtime spatial hierarchy and then reconnect parser relation queries to it. ## Plan For Step 3 From 38c33701e3c43a0fc4f839717384117921ece05a Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Wed, 18 Mar 2026 16:27:24 +0200 Subject: [PATCH 29/75] Refactor: extend hierarchy nesting to polygon objects Apply the same spatial-tree ordering and indentation logic used for entities to triggerboxes and walkboxes in the editor hierarchy panel, so nested scene objects render consistently regardless of object type. --- src/components/editor/HierarchyPanel.tsx | 197 +++++++---------------- 1 file changed, 62 insertions(+), 135 deletions(-) diff --git a/src/components/editor/HierarchyPanel.tsx b/src/components/editor/HierarchyPanel.tsx index 3d0816e..29f1a29 100644 --- a/src/components/editor/HierarchyPanel.tsx +++ b/src/components/editor/HierarchyPanel.tsx @@ -46,57 +46,61 @@ export const HierarchyPanel: React.FC = () => { const filteredTriggers = [...(scene?.triggerboxes || [])].filter((item: any) => matchesFilter(item) ); - const filteredEntityOrder = React.useMemo( - () => new Map(filteredEntities.map((entity: any, index: number) => [entity.name, index])), - [filteredEntities] + const filteredObjects = React.useMemo( + () => [...filteredEntities, ...filteredWalkboxes, ...filteredTriggers], + [filteredEntities, filteredWalkboxes, filteredTriggers] ); - const hierarchicalEntities = React.useMemo(() => { - const entityByName = new Map(filteredEntities.map((entity: any) => [entity.name, entity])); + const filteredObjectOrder = React.useMemo( + () => new Map(filteredObjects.map((item: any, index: number) => [item.name, index])), + [filteredObjects] + ); + const hierarchicalObjects = React.useMemo(() => { + const objectByName = new Map(filteredObjects.map((item: any) => [item.name, item])); const childrenByParent = new Map<string, any[]>(); const roots: any[] = []; - const pushChild = (parentId: string, entity: any) => { + const pushChild = (parentId: string, item: any) => { const children = childrenByParent.get(parentId) || []; - children.push(entity); + children.push(item); childrenByParent.set(parentId, children); }; - filteredEntities.forEach((entity: any) => { + filteredObjects.forEach((item: any) => { const parentId = - typeof entity?.spatial?.parentNodeId === 'string' ? entity.spatial.parentNodeId.trim() : ''; - if (parentId && parentId !== entity.name && entityByName.has(parentId)) { - pushChild(parentId, entity); + typeof item?.spatial?.parentNodeId === 'string' ? item.spatial.parentNodeId.trim() : ''; + if (parentId && parentId !== item.name && objectByName.has(parentId)) { + pushChild(parentId, item); } else { - roots.push(entity); + roots.push(item); } }); const sortBySceneOrder = (items: any[]) => [...items].sort( (left, right) => - (filteredEntityOrder.get(left.name) ?? Number.MAX_SAFE_INTEGER) - - (filteredEntityOrder.get(right.name) ?? Number.MAX_SAFE_INTEGER) + (filteredObjectOrder.get(left.name) ?? Number.MAX_SAFE_INTEGER) - + (filteredObjectOrder.get(right.name) ?? Number.MAX_SAFE_INTEGER) ); - const ordered: Array<{ entity: any; depth: number }> = []; + const ordered: Array<{ item: any; depth: number }> = []; const visited = new Set<string>(); - const walk = (entity: any, depth: number) => { - if (!entity || visited.has(entity.name)) return; - visited.add(entity.name); - ordered.push({ entity, depth }); - const children = sortBySceneOrder(childrenByParent.get(entity.name) || []); + const walk = (item: any, depth: number) => { + if (!item || visited.has(item.name)) return; + visited.add(item.name); + ordered.push({ item, depth }); + const children = sortBySceneOrder(childrenByParent.get(item.name) || []); children.forEach((child) => walk(child, depth + 1)); }; - sortBySceneOrder(roots).forEach((entity) => walk(entity, 0)); + sortBySceneOrder(roots).forEach((item) => walk(item, 0)); - sortBySceneOrder(filteredEntities) - .filter((entity) => !visited.has(entity.name)) - .forEach((entity) => walk(entity, 0)); + sortBySceneOrder(filteredObjects) + .filter((item) => !visited.has(item.name)) + .forEach((item) => walk(item, 0)); return ordered; - }, [filteredEntities, filteredEntityOrder]); + }, [filteredObjects, filteredObjectOrder]); // Helper to resolve display ID for an item, matching how it's identified in the UI const getDisplayId = (item: any): string => { @@ -137,9 +141,7 @@ export const HierarchyPanel: React.FC = () => { const allItems = [ 'SCENE', - ...hierarchicalEntities.map((item) => item.entity), - ...filteredWalkboxes, - ...filteredTriggers, + ...hierarchicalObjects.map((entry) => entry.item), ]; if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { @@ -182,9 +184,7 @@ export const HierarchyPanel: React.FC = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ - hierarchicalEntities, - filteredWalkboxes, - filteredTriggers, + hierarchicalObjects, hierarchyVersion, selectedObjectId, game.editor, @@ -358,12 +358,27 @@ export const HierarchyPanel: React.FC = () => { Scene </div> - {/* Entities */} - {hierarchicalEntities.map(({ entity: ent, depth }) => { - const isSelected = isItemSelected(ent); + {hierarchicalObjects.map(({ item, depth }, i) => { + const isSelected = isItemSelected(item); + const icon = + item.type === 'Actor' + ? '👤' + : item.type === 'Quad' + ? '▰' + : item.type === 'Walkbox' + ? '👣' + : item.type === 'Triggerbox' + ? '⚡' + : '📦'; + const label = + item.type === 'Walkbox' + ? item.name || `Walkbox ${i}` + : item.type === 'Triggerbox' + ? item.name || `Trigger ${i}` + : item.name; return ( <div - key={ent.name} + key={`${item.type}:${item.name || i}`} style={{ padding: '4px', paddingLeft: `${8 + depth * 14}px`, @@ -377,105 +392,17 @@ export const HierarchyPanel: React.FC = () => { justifyContent: 'space-between', }} onClick={(e) => { - if (e.ctrlKey) game.editor.toggleObjectSelection(ent); - else game.editor.selectObject(ent); - }} - onDoubleClick={() => centerCameraOn(ent)} - > - <div - style={{ display: 'flex', alignItems: 'center', opacity: ent.disabled ? 0.5 : 1.0 }} - > - <span - style={{ - filter: isSelected - ? 'grayscale(100%) brightness(0)' - : 'grayscale(100%) sepia(100%) hue-rotate(75deg) saturate(400%)', - marginRight: '6px', - display: 'inline-block', - textDecoration: ent.disabled ? 'line-through' : 'none', - }} - > - {ent.type === 'Actor' ? '👤' : ent.type === 'Quad' ? '▰' : '📦'} - </span> - {ent.name} - </div> - {ent.locked && <span style={{ fontSize: '10px' }}>🔒</span>} - </div> - ); - })} - - {/* Walkboxes */} - {filteredWalkboxes.map((wb: any, i: number) => { - const isSelected = isItemSelected(wb); - return ( - <div - key={wb.name || i} - style={{ - padding: '4px', - paddingLeft: '15px', - marginBottom: '2px', - cursor: 'pointer', - borderRadius: '4px', - background: isSelected ? 'var(--ui-selection-bg)' : 'transparent', - color: isSelected ? 'var(--ui-selection-text)' : '#aaa', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - }} - onClick={(e) => { - if (e.ctrlKey) game.editor.toggleObjectSelection(wb); - else game.editor.selectObject(wb); + if (e.ctrlKey) game.editor.toggleObjectSelection(item); + else game.editor.selectObject(item); }} - onDoubleClick={() => centerCameraOn(wb)} + onDoubleClick={() => centerCameraOn(item)} > <div - style={{ display: 'flex', alignItems: 'center', opacity: wb.disabled ? 0.5 : 1.0 }} - > - <span - style={{ - filter: isSelected - ? 'grayscale(100%) brightness(0)' - : 'grayscale(100%) sepia(100%) hue-rotate(75deg) saturate(400%)', - marginRight: '6px', - display: 'inline-block', - textDecoration: wb.disabled ? 'line-through' : 'none', - }} - > - 👣 - </span> - {wb.name || `Walkbox ${i}`} - </div> - {wb.locked && <span style={{ fontSize: '10px' }}>🔒</span>} - </div> - ); - })} - - {/* Triggers */} - {filteredTriggers.map((tb: any, i: number) => { - const isSelected = isItemSelected(tb); - return ( - <div - key={tb.name || i} - style={{ - padding: '4px', - paddingLeft: '15px', - marginBottom: '2px', - cursor: 'pointer', - borderRadius: '4px', - background: isSelected ? 'var(--ui-selection-bg)' : 'transparent', - color: isSelected ? 'var(--ui-selection-text)' : '#aaa', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - }} - onClick={(e) => { - if (e.ctrlKey) game.editor.toggleObjectSelection(tb); - else game.editor.selectObject(tb); - }} - onDoubleClick={() => centerCameraOn(tb)} - > - <div - style={{ display: 'flex', alignItems: 'center', opacity: tb.disabled ? 0.5 : 1.0 }} + style={{ + display: 'flex', + alignItems: 'center', + opacity: item.disabled ? 0.5 : 1.0, + }} > <span style={{ @@ -484,14 +411,14 @@ export const HierarchyPanel: React.FC = () => { : 'grayscale(100%) sepia(100%) hue-rotate(75deg) saturate(400%)', marginRight: '6px', display: 'inline-block', - textDecoration: tb.disabled ? 'line-through' : 'none', + textDecoration: item.disabled ? 'line-through' : 'none', }} > - ⚡ + {icon} </span> - {tb.name || `Trigger ${i}`} + {label} </div> - {tb.locked && <span style={{ fontSize: '10px' }}>🔒</span>} + {item.locked && <span style={{ fontSize: '10px' }}>🔒</span>} </div> ); })} From e70f4a800671c73c86296b4b6e519ea49bc128ac Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Wed, 18 Mar 2026 17:47:49 +0200 Subject: [PATCH 30/75] Feature: auto-activate direct subscene children --- GDD.md | 13 ++++ Parser.md | 2 + src/components/editor/PropertiesPanel.tsx | 95 +++++++++++++++++------ src/scene/Scene.ts | 37 ++++++++- src/systems/ComponentSystem.ts | 62 ++++++++++++++- src/systems/types.ts | 2 + tasks.md | 22 +++++- 7 files changed, 199 insertions(+), 34 deletions(-) diff --git a/GDD.md b/GDD.md index f9be145..321e140 100644 --- a/GDD.md +++ b/GDD.md @@ -509,6 +509,19 @@ _Sort Mode_ (v0, v1, v2, v3, ignore) - прямой: объект вложен в другой объект; - опосредованной: объект вложен в `Subscene`, которая сама вложена в объект. + При активации `Subscene` движок включает все объекты, **непосредственно** вложенные в неё через spatial hierarchy. Это относится к любым подходящим объектам сцены: + + - `Entity`; + - `Triggerbox`; + - вложенным `Subscene`. + + При этом раскрывается только **один уровень** вложенности: + + - если `Subscene A` содержит `Subscene B`, то при открытии `A` активируется `B`; + - но содержимое `B` не активируется автоматически, пока пользователь не откроет уже саму `B`. + + Старый механизм `targetGroupId` сохраняется для совместимости и может работать вместе с spatial-вложенностью, но spatial-модель считается источником истины для структуры содержимого. + > Если триггер Subscene относится к переключаемой им группе объектов (имеет такой Group ID как Group_id_1 или Group_id_2), то он не дизейблит самого себя, чтобы не сломать логику переключения. Но другие объекты, которые находятся в этой группе, включая триггеры, будут дизейблиться. - _Switch_ (Target_ID_1, Target_ID_2, state (1 or 2), [Name], [id_key], [sound_1], [sound_2]) : Триггер, который при клике мышью или по команде "открыть Name" подменяет одни объекты другими, а при повторном клике или команде "закрыть Name" подменяет обратно. Например, игрок кликает по двери и она открывается, а при повторном клике закрывается. Технически "открытие/закрытие" это включение одной и выключение другой группы объектов сцены (или subscene). diff --git a/Parser.md b/Parser.md index 56017d3..3ed4520 100644 --- a/Parser.md +++ b/Parser.md @@ -140,6 +140,8 @@ flowchart TD - он не определяет target; - он лишь даёт parser-у картину мира. +Spatial-проекция приходит из `Game` уже в терминах world model. В частности, `Subscene` раскрывает для runtime и parser-а только **непосредственный** уровень вложенности за одну активацию: parser не должен сам вычислять рекурсивное раскрытие поддерева. + Пример context: ```json diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 1dde24c..e7cd908 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -50,18 +50,24 @@ export const PropertiesPanel: React.FC = () => { { value: 'under', label: 'Under' }, { value: 'behind', label: 'Behind' }, ]; + const getSpatialRelationOptions = React.useCallback( + (hasParent: boolean) => + hasParent ? spatialRelationOptions.filter((option) => option.value !== '') : spatialRelationOptions, + [spatialRelationOptions] + ); - const getSceneEntityParentOptions = React.useCallback(() => { + const getSceneSpatialParentOptions = React.useCallback(() => { const scene = game?.sceneManager?.currentScene; if (!scene || !obj) { return [{ value: '', label: '(None)' }]; } - const options = scene.entities - .filter((entity) => entity !== obj) - .map((entity) => ({ - value: entity.name, - label: entity.customName?.trim() || entity.name, + const allObjects = [...scene.entities, ...scene.walkbox, ...scene.triggerboxes]; + const options = allObjects + .filter((item) => item !== obj) + .map((item: any) => ({ + value: item.name, + label: item.customName?.trim() || item.name, })); return [{ value: '', label: '(None)' }, ...options]; @@ -1139,11 +1145,11 @@ export const PropertiesPanel: React.FC = () => { obj.spatial = { ...(obj.spatial || {}), parentNodeId: value || null, - relation: value ? obj.spatial?.relation || null : null, + relation: value ? obj.spatial?.relation || 'in' : null, }; incrementObjectVersion(); }} - options={getSceneEntityParentOptions()} + options={getSceneSpatialParentOptions()} style={{ width: '100%' }} /> </div> @@ -1155,11 +1161,11 @@ export const PropertiesPanel: React.FC = () => { obj.spatial = { ...(obj.spatial || {}), parentNodeId: obj.spatial?.parentNodeId || null, - relation: value || null, + relation: value || (obj.spatial?.parentNodeId ? 'in' : null), }; incrementObjectVersion(); }} - options={spatialRelationOptions} + options={getSpatialRelationOptions(!!obj.spatial?.parentNodeId)} style={{ width: '100%' }} /> </div> @@ -1462,15 +1468,54 @@ export const PropertiesPanel: React.FC = () => { </div> {selectedObjectType === 'Triggerbox' && ( - <div className="e-row"> - <label className="e-label">Layer</label> - <input - type="number" - className="e-input" - value={obj.layer || 0} - onChange={(e) => handleChange('layer', e.target.value, true)} - /> - </div> + <> + <div className="e-row"> + <label className="e-label">Layer</label> + <input + type="number" + className="e-input" + value={obj.layer || 0} + onChange={(e) => handleChange('layer', e.target.value, true)} + /> + </div> + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label">Parent</label> + <Select + value={obj.spatial?.parentNodeId || ''} + onChange={(value) => { + obj.spatial = { + ...(obj.spatial || {}), + parentNodeId: value || null, + relation: value ? obj.spatial?.relation || 'in' : null, + }; + incrementObjectVersion(); + }} + options={getSceneSpatialParentOptions()} + style={{ width: '100%' }} + /> + </div> + <div> + <label className="e-label">Relation</label> + <Select + value={obj.spatial?.parentNodeId ? obj.spatial?.relation || 'in' : obj.spatial?.relation || ''} + onChange={(value) => { + obj.spatial = { + ...(obj.spatial || {}), + parentNodeId: obj.spatial?.parentNodeId || null, + relation: value || (obj.spatial?.parentNodeId ? 'in' : null), + }; + incrementObjectVersion(); + }} + options={getSpatialRelationOptions(!!obj.spatial?.parentNodeId)} + style={{ width: '100%' }} + /> + </div> + </div> + </> )} <div className="e-row"> @@ -2335,7 +2380,7 @@ export const PropertiesPanel: React.FC = () => { comp.spatial = { ...(comp.spatial || {}), parentNodeId: value || null, - relation: value ? comp.spatial?.relation || null : null, + relation: value ? comp.spatial?.relation || 'in' : null, }; incrementObjectVersion(); }} @@ -2350,16 +2395,20 @@ export const PropertiesPanel: React.FC = () => { Relation </label> <Select - value={comp.spatial?.relation || ''} + value={ + comp.spatial?.parentNodeId + ? comp.spatial?.relation || 'in' + : comp.spatial?.relation || '' + } onChange={(value) => { comp.spatial = { ...(comp.spatial || {}), parentNodeId: comp.spatial?.parentNodeId || null, - relation: value || null, + relation: value || (comp.spatial?.parentNodeId ? 'in' : null), }; incrementObjectVersion(); }} - options={spatialRelationOptions} + options={getSpatialRelationOptions(!!comp.spatial?.parentNodeId)} style={{ width: '100%' }} /> </div> diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 7b353db..c1e2c6f 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -126,7 +126,9 @@ export class Scene { value.relation === 'under' || value.relation === 'behind' ? value.relation - : null; + : parentNodeId + ? 'in' + : null; if (!parentNodeId && !relation) return null; return { parentNodeId: parentNodeId || null, @@ -160,7 +162,7 @@ export class Scene { id: (component.nodeId || component.targetGroupId || triggerbox.name || '').trim(), kind: 'subscene' as const, title: component.title?.trim() || component.name?.trim() || null, - placement: this.normalizeSpatialPlacement(component.spatial), + placement: this.normalizeSpatialPlacement(component.spatial || (triggerbox as any).spatial), sourceName: triggerbox.name, }) ); @@ -200,6 +202,34 @@ export class Scene { }; } + getSpatialDescendantObjects(nodeId: string): SceneObject[] { + const normalizedId = String(nodeId || '').trim(); + if (!normalizedId) return []; + + const result = new Set<SceneObject>(); + + const allObjects: SceneObject[] = [...this.entities, ...this.walkbox, ...this.triggerboxes]; + for (const obj of allObjects) { + const objectParentId = + typeof (obj as any).spatial?.parentNodeId === 'string' + ? (obj as any).spatial.parentNodeId.trim() + : ''; + const subsceneComponent = obj.components?.find((component: any) => component?.type === 'Subscene') as + | SubsceneComponent + | undefined; + const subsceneParentId = + typeof subsceneComponent?.spatial?.parentNodeId === 'string' + ? subsceneComponent.spatial.parentNodeId.trim() + : ''; + + if (objectParentId === normalizedId || subsceneParentId === normalizedId) { + result.add(obj); + } + } + + return Array.from(result); + } + constructor(game: IGame, id: string, name: string) { this.game = game; this.id = id; @@ -554,7 +584,8 @@ export class Scene { const sub = obj.components.find((c) => c.type === 'Subscene') as any; if (sub) { // If this trigger opens the CURRENTLY active subscene, ignore it (cursor shouldn't change) - if (this.activeSubscene && sub.targetGroupId === this.activeSubscene) { + const currentSubsceneId = (sub.nodeId || sub.targetGroupId || '').trim(); + if (this.activeSubscene && currentSubsceneId && currentSubsceneId === this.activeSubscene) { return false; } return true; diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index a974085..1c25a77 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -51,6 +51,49 @@ export interface WalkBoxComponent { import type { IGame } from '../core/IGame'; export class ComponentSystem { + private static getDirectSpatialChildren( + rootIds: string[], + scene: ActivationSceneContext + ): SceneObject[] { + const roots = new Set( + rootIds.map((value) => String(value || '').trim()).filter((value) => !!value) + ); + if (roots.size === 0) return []; + + const allObjects: SceneObject[] = [ + ...scene.entities, + ...(scene.walkbox || []), + ...scene.triggerboxes, + ]; + const result = new Set<SceneObject>(); + + for (const obj of allObjects) { + const objectParentId = + typeof (obj as any).spatial?.parentNodeId === 'string' + ? (obj as any).spatial.parentNodeId.trim() + : ''; + + if (objectParentId && roots.has(objectParentId)) { + result.add(obj); + continue; + } + + const subsceneComponent = obj.components?.find((component: any) => component?.type === 'Subscene') as + | SubsceneComponent + | undefined; + const subsceneParentId = + typeof subsceneComponent?.spatial?.parentNodeId === 'string' + ? subsceneComponent.spatial.parentNodeId.trim() + : ''; + + if (subsceneParentId && roots.has(subsceneParentId)) { + result.add(obj); + } + } + + return Array.from(result); + } + private static getPlayerFacingTitle(game: IGame | undefined, entity: SceneObject): string | null { const title = game?.textAssets.getResolvedObjectField(entity, 'title'); return title && title.trim() ? title.trim() : null; @@ -181,7 +224,8 @@ export class ComponentSystem { scene: ActivationSceneContext ): boolean { const targetStr = sub.targetGroupId ? sub.targetGroupId.trim() : ''; - if (!targetStr) return false; + const nodeId = sub.nodeId ? sub.nodeId.trim() : ''; + if (!targetStr && !nodeId) return false; // Proximity Check (if player exists) const player = scene.player; @@ -216,10 +260,20 @@ export class ComponentSystem { } } - scene.activeSubscene = targetStr; + const spatialRootIds = Array.from( + new Set( + [nodeId, targetStr, entity.name] + .map((value) => String(value || '').trim()) + .filter((value) => !!value) + ) + ); + const spatialTargets = this.getDirectSpatialChildren(spatialRootIds, scene); + const groupTargets = targetStr ? scene.resolveTarget(targetStr) : []; + const targets = Array.from(new Set([...groupTargets, ...spatialTargets])); + const activeSubsceneId = nodeId || targetStr; + + scene.activeSubscene = activeSubsceneId; scene.subsceneEntities.clear(); - - const targets = scene.resolveTarget(targetStr); targets.forEach((t) => { t.disabled = false; scene.subsceneEntities.add(t); diff --git a/src/systems/types.ts b/src/systems/types.ts index 522a753..1b4f4a4 100644 --- a/src/systems/types.ts +++ b/src/systems/types.ts @@ -14,8 +14,10 @@ export interface ActivationSceneContext { activeSubscene: string | null; subsceneEntities: Set<SceneObject>; resolveTarget(target: string): SceneObject[]; + getSpatialDescendantObjects?(nodeId: string): SceneObject[]; activateObject(obj: SceneObject, depth?: number): void; findEntity(name: string): Entity | undefined; entities: Entity[]; triggerboxes: SceneObject[]; + walkbox?: SceneObject[]; } diff --git a/tasks.md b/tasks.md index d445aa2..890824f 100644 --- a/tasks.md +++ b/tasks.md @@ -88,6 +88,14 @@ Architecture rules for this initiative: - children by parent id - children grouped by relation - [ ] Keep this index separate from render hierarchy and separate from visibility/accessibility logic. +- [x] Treat `Subscene` as a virtual spatial node while keeping its authored spatial metadata on the `Subscene` component / triggerbox path. +- [x] Auto-activate direct spatial children when opening a `Subscene`: + - direct `Entity` children + - direct `Triggerbox` children + - direct nested `Subscene` children +- [x] Keep `Subscene` activation non-recursive: + - opening parent `Subscene A` reveals only direct children + - children of nested `Subscene B` remain inactive until `B` itself is opened ### 2. Parser Integration @@ -102,7 +110,7 @@ Architecture rules for this initiative: - `LOOK IN X` - `LOOK BEHIND X` - [ ] Keep `near` out of execution until its runtime semantics are clearly defined. -- [ ] Preserve current clarification behavior: +- [x] Preserve current clarification behavior: - resolve anchor - ambiguity handling - tie-break rules for non-usable ambiguity @@ -118,17 +126,23 @@ Architecture rules for this initiative: - node id - optional parent node - relation type +- [x] Add editor UI for `Triggerbox` spatial authoring: + - parent object / node + - relation type - [ ] Ensure authoring UI does not imply visibility/accessibility behavior that is not implemented yet. -- [ ] Add serialization/deserialization support for the new spatial fields. +- [x] Add serialization/deserialization support for the new spatial fields. - [x] Show spatial nesting visually in `HierarchyPanel` for scene entities: - child entities render below their parent - nested entities are indented to the right - flat list order remains stable for roots and fallback cases +- [x] Extend `HierarchyPanel` spatial nesting display to polygon-based scene objects: + - `Triggerbox` + - `Walkbox` ### 4. Migration / Compatibility -- [ ] Keep existing scenes valid with all spatial fields optional. -- [ ] Preserve current `activeSubscene` / `subsceneEntities` behavior during migration. +- [x] Keep existing scenes valid with all spatial fields optional. +- [x] Preserve current `activeSubscene` / `subsceneEntities` behavior during migration. - [ ] Make parser relation grammar continue to work even before a scene defines any spatial metadata. - [ ] Add smoke checks for scenes mixing: - direct object nesting From 2c851424d0f6e59c392cab52f86df3e1f4e17ffa Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Wed, 18 Mar 2026 18:56:41 +0200 Subject: [PATCH 31/75] Refactor: simplify subscene component data --- src/components/editor/PropertiesPanel.tsx | 107 ---------------------- src/scene/Scene.ts | 18 +--- src/systems/ComponentSystem.ts | 22 +---- 3 files changed, 8 insertions(+), 139 deletions(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index e7cd908..b177bdf 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -73,35 +73,6 @@ export const PropertiesPanel: React.FC = () => { return [{ value: '', label: '(None)' }, ...options]; }, [game, obj]); - const getSubsceneNodeOptions = React.useCallback( - (currentNodeId?: string) => { - const scene = game?.sceneManager?.currentScene; - if (!scene) { - return [{ value: '', label: '(None)' }]; - } - - const entityOptions = scene.entities.map((entity) => ({ - value: entity.name, - label: entity.customName?.trim() || entity.name, - })); - - const subsceneOptions = scene - .getSubsceneComponents() - .map(({ triggerbox, component }) => { - const nodeId = (component.nodeId || component.targetGroupId || triggerbox.name || '').trim(); - if (!nodeId || nodeId === currentNodeId) return null; - return { - value: nodeId, - label: component.title?.trim() || component.name?.trim() || nodeId, - }; - }) - .filter((item): item is { value: string; label: string } => !!item); - - return [{ value: '', label: '(None)' }, ...entityOptions, ...subsceneOptions]; - }, - [game] - ); - const getSharedValue = (arr: any[], getter: (o: any) => any) => { if (!arr.length) return ''; const first = getter(arr[0]); @@ -2049,11 +2020,8 @@ export const PropertiesPanel: React.FC = () => { obj.components.push({ type: 'Subscene', targetGroupId: '', - name: '', - nodeId: '', title: '', description: '', - spatial: { parentNodeId: null, relation: null }, }); } else if (type === 'Subtrigger') { obj.components.push({ type: 'Subtrigger', target: '' }); @@ -2310,34 +2278,6 @@ export const PropertiesPanel: React.FC = () => { }} /> </div> - <div className="e-row"> - <label className="e-label" style={{ fontSize: '10px' }}> - Name (Optional) - </label> - <input - type="text" - className="e-input" - value={comp.name || ''} - onChange={(e) => { - comp.name = e.target.value; - incrementObjectVersion(); - }} - /> - </div> - <div className="e-row"> - <label className="e-label" style={{ fontSize: '10px' }}> - Node ID - </label> - <input - type="text" - className="e-input" - value={comp.nodeId || ''} - onChange={(e) => { - comp.nodeId = e.target.value; - incrementObjectVersion(); - }} - /> - </div> <div className="e-row"> <label className="e-label" style={{ fontSize: '10px' }}> Title @@ -2366,53 +2306,6 @@ export const PropertiesPanel: React.FC = () => { }} /> </div> - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label" style={{ fontSize: '10px' }}> - Parent Node - </label> - <Select - value={comp.spatial?.parentNodeId || ''} - onChange={(value) => { - comp.spatial = { - ...(comp.spatial || {}), - parentNodeId: value || null, - relation: value ? comp.spatial?.relation || 'in' : null, - }; - incrementObjectVersion(); - }} - options={getSubsceneNodeOptions( - (comp.nodeId || comp.targetGroupId || obj.name || '').trim() - )} - style={{ width: '100%' }} - /> - </div> - <div> - <label className="e-label" style={{ fontSize: '10px' }}> - Relation - </label> - <Select - value={ - comp.spatial?.parentNodeId - ? comp.spatial?.relation || 'in' - : comp.spatial?.relation || '' - } - onChange={(value) => { - comp.spatial = { - ...(comp.spatial || {}), - parentNodeId: comp.spatial?.parentNodeId || null, - relation: value || (comp.spatial?.parentNodeId ? 'in' : null), - }; - incrementObjectVersion(); - }} - options={getSpatialRelationOptions(!!comp.spatial?.parentNodeId)} - style={{ width: '100%' }} - /> - </div> - </div> </> )} diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index c1e2c6f..bc86c77 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -159,10 +159,10 @@ export class Scene { const subsceneNodes: SpatialNodeDescriptor[] = this.getSubsceneComponents().map( ({ triggerbox, component }) => ({ - id: (component.nodeId || component.targetGroupId || triggerbox.name || '').trim(), + id: (triggerbox.name || component.targetGroupId || '').trim(), kind: 'subscene' as const, - title: component.title?.trim() || component.name?.trim() || null, - placement: this.normalizeSpatialPlacement(component.spatial || (triggerbox as any).spatial), + title: component.title?.trim() || null, + placement: this.normalizeSpatialPlacement((triggerbox as any).spatial), sourceName: triggerbox.name, }) ); @@ -214,15 +214,7 @@ export class Scene { typeof (obj as any).spatial?.parentNodeId === 'string' ? (obj as any).spatial.parentNodeId.trim() : ''; - const subsceneComponent = obj.components?.find((component: any) => component?.type === 'Subscene') as - | SubsceneComponent - | undefined; - const subsceneParentId = - typeof subsceneComponent?.spatial?.parentNodeId === 'string' - ? subsceneComponent.spatial.parentNodeId.trim() - : ''; - - if (objectParentId === normalizedId || subsceneParentId === normalizedId) { + if (objectParentId === normalizedId) { result.add(obj); } } @@ -584,7 +576,7 @@ export class Scene { const sub = obj.components.find((c) => c.type === 'Subscene') as any; if (sub) { // If this trigger opens the CURRENTLY active subscene, ignore it (cursor shouldn't change) - const currentSubsceneId = (sub.nodeId || sub.targetGroupId || '').trim(); + const currentSubsceneId = (obj.name || sub.targetGroupId || '').trim(); if (this.activeSubscene && currentSubsceneId && currentSubsceneId === this.activeSubscene) { return false; } diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index 1c25a77..630e51a 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -4,7 +4,6 @@ import { QuadObject } from '../entities/QuadObject'; // or just import them. Circular imports are handled by webpack/vite usually, but let's be careful. // Actually, using them as Types is fine. import type { Actor } from '../entities/Actor'; -import type { SpatialPlacement } from '../scene/spatialTypes'; import { ShadowSystem, type ShadowComponent } from './ShadowSystem'; @@ -13,11 +12,8 @@ import { BackfaceSystem, type BackfaceComponent } from './BackfaceSystem'; export interface SubsceneComponent { type: 'Subscene'; targetGroupId: string; - name?: string; - nodeId?: string; title?: string; description?: string | null; - spatial?: SpatialPlacement; } export interface SwitchComponent { @@ -78,17 +74,6 @@ export class ComponentSystem { continue; } - const subsceneComponent = obj.components?.find((component: any) => component?.type === 'Subscene') as - | SubsceneComponent - | undefined; - const subsceneParentId = - typeof subsceneComponent?.spatial?.parentNodeId === 'string' - ? subsceneComponent.spatial.parentNodeId.trim() - : ''; - - if (subsceneParentId && roots.has(subsceneParentId)) { - result.add(obj); - } } return Array.from(result); @@ -224,8 +209,7 @@ export class ComponentSystem { scene: ActivationSceneContext ): boolean { const targetStr = sub.targetGroupId ? sub.targetGroupId.trim() : ''; - const nodeId = sub.nodeId ? sub.nodeId.trim() : ''; - if (!targetStr && !nodeId) return false; + if (!targetStr && !entity.name) return false; // Proximity Check (if player exists) const player = scene.player; @@ -262,7 +246,7 @@ export class ComponentSystem { const spatialRootIds = Array.from( new Set( - [nodeId, targetStr, entity.name] + [targetStr, entity.name] .map((value) => String(value || '').trim()) .filter((value) => !!value) ) @@ -270,7 +254,7 @@ export class ComponentSystem { const spatialTargets = this.getDirectSpatialChildren(spatialRootIds, scene); const groupTargets = targetStr ? scene.resolveTarget(targetStr) : []; const targets = Array.from(new Set([...groupTargets, ...spatialTargets])); - const activeSubsceneId = nodeId || targetStr; + const activeSubsceneId = entity.name || targetStr; scene.activeSubscene = activeSubsceneId; scene.subsceneEntities.clear(); From fc56c568d2370062debade9d80478df721c8022f Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Wed, 18 Mar 2026 20:48:21 +0200 Subject: [PATCH 32/75] Feature: improve spatial look output and subscene cleanup --- public/text/system/parser.json | 1 + src/core/Game.ts | 123 ++++++++++++++++++++++++++++++++- src/core/IGame.ts | 4 ++ src/core/TextAssetManager.ts | 1 + src/mechanics/Parser.ts | 89 +++++++----------------- src/scene/Scene.ts | 31 ++++++--- src/scene/SceneInteraction.ts | 16 ++++- src/scene/SceneSubscene.ts | 5 +- 8 files changed, 193 insertions(+), 77 deletions(-) diff --git a/public/text/system/parser.json b/public/text/system/parser.json index 5b06c9e..c61a802 100644 --- a/public/text/system/parser.json +++ b/public/text/system/parser.json @@ -1,5 +1,6 @@ { "look_default_scene": "You are in {scene}.", + "look_scene_contents": "Here is {items}.", "look_default_object": "You see nothing special about the {target}.", "look_not_found": "You don't see any {target} here.", "look_which_one": "Which one do you mean: {options}?", diff --git a/src/core/Game.ts b/src/core/Game.ts index d615774..103bf25 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -6,6 +6,7 @@ import { SceneEditor } from '../tools/SceneEditor'; import { SpriteEditor } from '../tools/SpriteEditor'; import { AssetLoader } from './AssetLoader'; import { Entity } from '../entities/Entity'; +import { SceneObject } from '../entities/SceneObject'; import { registerDemoScripts } from '../scripts/DemoScripts'; import { registerUserScripts } from '../scripts/main'; import { AudioManager } from './AudioManager'; @@ -18,6 +19,7 @@ import { ComponentSystem } from '../systems/ComponentSystem'; import type { IGame } from './IGame'; import type { Scene } from '../scene/Scene'; +import type { SpatialRelationType } from '../scene/spatialTypes'; export class Game implements IGame { public static instance: Game; @@ -441,6 +443,60 @@ export class Game implements IGame { return title && title.trim() ? title.trim() : null; } + private getPlayerFacingObjectTitle(target: SceneObject): string | null { + const title = this.textAssets.getResolvedObjectField(target as any, 'title'); + return title && title.trim() ? title.trim() : null; + } + + private getRelationDisplayText(relation: SpatialRelationType): string { + switch (relation) { + case 'in': + return 'in'; + case 'on': + return 'on'; + case 'under': + return 'under'; + case 'behind': + return 'behind'; + default: + return relation; + } + } + + 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 getSpatialParentMessage(target: SceneObject): string | null { + const scene = this.sceneManager.currentScene; + if (!scene) return null; + + const placement = scene.getSpatialPlacementForObject(target); + if (!placement?.parentNodeId || !placement.relation) return null; + + const itemTitle = this.getPlayerFacingObjectTitle(target); + const parentNode = scene.getSpatialNode(placement.parentNodeId); + const parentTitle = parentNode?.title?.trim() || null; + if (!itemTitle || !parentTitle) return null; + + return this.text('parser.relation_contents', { + Relation: this.capitalize(this.getRelationDisplayText(placement.relation)), + relation: this.getRelationDisplayText(placement.relation), + target: parentTitle, + items: itemTitle, + }); + } + + getSeeMessage(target: SceneObject): string | null { + return this.getSpatialParentMessage(target) || null; + } + private isEntityInInventory(entity: Entity): boolean { return this.inventory.includes(entity); } @@ -491,6 +547,13 @@ export class Game implements IGame { this.textAssets.getResolvedSceneField(targetScene, 'description') || targetScene.description || this.text('parser.look_default_scene', { scene: targetScene.name }); + // Intentionally disabled for now: + // const directItems = this.getDirectSceneLookItems(targetScene); + // const contentsMessage = directItems.length + // ? this.text('parser.look_scene_contents', { + // items: this.formatTitleList(directItems), + // }) + // : ''; return { status: 'ok', code: 'scene_description', @@ -515,10 +578,11 @@ export class Game implements IGame { const description = this.textAssets.getResolvedObjectField(entity, 'description') || entity.description; if (description && description.trim()) { + const spatialMessage = this.getSpatialParentMessage(entity); return { status: 'ok', code: 'entity_description', - message: description, + message: spatialMessage ? `${description.trim()} ${spatialMessage}` : description, data: { targetType: 'entity', entityId: entity.name }, }; } @@ -582,6 +646,63 @@ export class Game implements IGame { }; } + describeSpatialRelation(anchorNodeId: string, relation: SpatialRelationType): GameActionOutcome { + const scene = this.sceneManager.currentScene; + if (!scene) { + return { + status: 'failed', + code: 'no_current_scene', + message: this.text('parser.parse_unknown'), + recoverable: false, + }; + } + + const anchorNode = scene.getSpatialNode(anchorNodeId); + const anchorTitle = anchorNode?.title?.trim() || null; + if (!anchorNode || !anchorTitle) { + return { + status: 'escalate', + code: 'spatial_node_missing_title', + recoverable: true, + }; + } + + const childTitles = scene + .getDirectSpatialChildren(anchorNodeId, relation) + .map((child) => this.getPlayerFacingObjectTitle(child)) + .filter((title): title is string => !!title); + + if (!childTitles.length) { + return { + status: 'ok', + code: 'relation_empty', + message: this.text('parser.relation_empty', { + relation: this.getRelationDisplayText(relation), + target: anchorTitle, + }), + data: { + relation, + anchorNodeId, + }, + }; + } + + return { + status: 'ok', + code: 'relation_contents', + message: this.text('parser.relation_contents', { + Relation: this.capitalize(this.getRelationDisplayText(relation)), + relation: this.getRelationDisplayText(relation), + target: anchorTitle, + items: this.formatTitleList(childTitles), + }), + data: { + relation, + anchorNodeId, + }, + }; + } + takeEntity(entity: Entity): GameActionOutcome { const scene = this.sceneManager.currentScene; if (!scene) { diff --git a/src/core/IGame.ts b/src/core/IGame.ts index 54e462e..e4dd839 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -6,6 +6,8 @@ import { Entity } from '../entities/Entity'; import { TextAssetManager } from './TextAssetManager'; import type { GameActionOutcome } from './GameActionTypes'; import type { Scene } from '../scene/Scene'; +import type { SceneObject } from '../entities/SceneObject'; +import type { SpatialRelationType } from '../scene/spatialTypes'; export interface IGame { assets: AssetLoader; @@ -18,8 +20,10 @@ export interface IGame { showMessage(text: string): void; log(text: string): void; text(key: string, params?: Record<string, string | number>): string; + getSeeMessage(target: SceneObject): string | null; lookScene(scene?: Scene | null): GameActionOutcome; lookEntity(entity: Entity): GameActionOutcome; + describeSpatialRelation(anchorNodeId: string, relation: SpatialRelationType): GameActionOutcome; examineEntity(entity: Entity): GameActionOutcome; takeEntity(entity: Entity): GameActionOutcome; removeInventoryEntity(entity: Entity): GameActionOutcome; diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 1eea4c2..d65bc8c 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -19,6 +19,7 @@ export type ObjectTextAssetData = TextAssetData & { const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { parser: { look_default_scene: 'You are in {scene}.', + look_scene_contents: 'Here is {items}.', look_default_object: 'You see nothing special about the {target}.', look_not_found: "You don't see any {target} here.", look_which_one: 'Which one do you mean: {options}?', diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index cb7a172..8f840a0 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -642,10 +642,6 @@ export class Parser { return this.activeWorldModel?.context.spatialNodes || []; } - private getSpatialNodeById(id: string): ParserSpatialNodeContext | null { - return this.getSpatialNodes().find((node) => node.id === id) || null; - } - private getSpatialNodeDisplayTitle(node: ParserSpatialNodeContext): string { const entityContext = this.getContextEntityById(node.id); return entityContext?.title?.trim() || node.title?.trim() || ''; @@ -844,10 +840,33 @@ export class Parser { this.getScopeCandidates(['examinable']), 'parser.examine_which_one' ); + const broadResolved = + resolved.status === 'not_found' + ? this.resolveEntityTargetInCandidates( + rawTarget, + this.getScopeCandidates(['visible', 'held']), + 'parser.examine_which_one' + ) + : null; if (resolved.status === 'escalate') { return { status: 'escalate', code: resolved.code, recoverable: true }; } if (resolved.status === 'not_found') { + if (broadResolved?.status === 'escalate') { + return { status: 'escalate', code: broadResolved.code, recoverable: true }; + } + if (broadResolved?.status === 'ambiguous') { + return { + status: 'needs_clarification', + code: 'ambiguous_examine_target', + message: broadResolved.message, + data: { target: rawTarget, options: broadResolved.options }, + recoverable: true, + }; + } + if (broadResolved?.status === 'found') { + return this.game.examineEntity(broadResolved.entity); + } return { status: 'failed', code: 'entity_not_found', @@ -937,63 +956,7 @@ export class Parser { }; } - const matchingRelation = this.activeWorldModel?.context.spatialRelations?.find( - (item) => item.anchorNodeId === resolved.node.id && item.relation === relation - ); - const childNodes = (matchingRelation?.childNodeIds || []) - .map((id) => this.getSpatialNodeById(id)) - .filter((node): node is ParserSpatialNodeContext => !!node); - const anchorTitle = this.getSpatialNodeDisplayTitle(resolved.node); - if (!anchorTitle) { - return { - status: 'escalate', - code: 'spatial_node_missing_title', - recoverable: true, - }; - } - - if (!childNodes.length) { - return { - status: 'ok', - code: 'relation_empty', - message: this.game.text('parser.relation_empty', { - relation: this.getRelationDisplayText(relation), - target: anchorTitle, - }), - data: { - relation, - anchorNodeId: resolved.node.id, - }, - }; - } - - const itemTitles = childNodes - .map((node) => this.getSpatialNodeDisplayTitle(node)) - .filter((title) => !!title); - if (itemTitles.length !== childNodes.length) { - return { - status: 'escalate', - code: 'spatial_node_missing_title', - recoverable: true, - }; - } - - const items = itemTitles.join(', '); - 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, - }), - data: { - relation, - anchorNodeId: resolved.node.id, - childNodeIds: childNodes.map((node) => node.id), - }, - }; + return this.game.describeSpatialRelation(resolved.node.id, relation); } private resolveTakeTarget(rawTarget: string | null): GameActionOutcome { @@ -1593,10 +1556,6 @@ export class Parser { } } - private capitalize(value: string): string { - return value ? value.charAt(0).toUpperCase() + value.slice(1) : value; - } - private isEntityValidForCommandArgument( entity: Entity, validation?: ParserCommandArgumentValidation diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index bc86c77..f3df7ae 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -202,26 +202,39 @@ export class Scene { }; } - getSpatialDescendantObjects(nodeId: string): SceneObject[] { + getSpatialNode(id: string): SpatialNodeDescriptor | null { + const normalizedId = String(id || '').trim(); + if (!normalizedId) return null; + return this.getSpatialIndex().nodeById.get(normalizedId) || null; + } + + getDirectSpatialChildren(nodeId: string, relation?: SpatialRelationType): SceneObject[] { const normalizedId = String(nodeId || '').trim(); if (!normalizedId) return []; const result = new Set<SceneObject>(); - const allObjects: SceneObject[] = [...this.entities, ...this.walkbox, ...this.triggerboxes]; + for (const obj of allObjects) { - const objectParentId = - typeof (obj as any).spatial?.parentNodeId === 'string' - ? (obj as any).spatial.parentNodeId.trim() - : ''; - if (objectParentId === normalizedId) { - result.add(obj); - } + const placement = this.normalizeSpatialPlacement((obj as any).spatial); + if (!placement?.parentNodeId || placement.parentNodeId !== normalizedId) continue; + if (relation && placement.relation !== relation) continue; + result.add(obj); } return Array.from(result); } + getSpatialPlacementForObject(obj: SceneObject): SpatialPlacement | null { + return this.normalizeSpatialPlacement((obj as any).spatial); + } + + getSpatialDescendantObjects(nodeId: string): SceneObject[] { + const normalizedId = String(nodeId || '').trim(); + if (!normalizedId) return []; + return this.getDirectSpatialChildren(normalizedId); + } + constructor(game: IGame, id: string, name: string) { this.game = game; this.id = id; diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index 56802f9..05bebc2 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -205,8 +205,11 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { if (subsceneHit) { const titleOwner = resolveSubtriggerTarget(scene, subsceneHit); + const seeMessage = scene.game.getSeeMessage(titleOwner); const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); - if (title && title.trim()) { + if (seeMessage) { + scene.game.log(seeMessage); + } else if (title && title.trim()) { scene.game.log(scene.game.text('engine.click_you_see', { title })); } activateSceneObject(scene, subsceneHit); @@ -221,9 +224,15 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { if (hitObj) { const titleOwner = resolveSubtriggerTarget(scene, hitObj); + const seeMessage = scene.game.getSeeMessage(titleOwner); const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); const activated = activateSceneObject(scene, hitObj); + if (seeMessage) { + scene.game.log(seeMessage); + return; + } + if (title) { scene.game.log(scene.game.text('engine.click_you_see', { title })); return; @@ -237,7 +246,12 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { const visibleHitObj = findTopHitObject(scene, x, y) || findVisibleHitObject(scene, x, y); if (visibleHitObj) { const titleOwner = resolveSubtriggerTarget(scene, visibleHitObj); + const seeMessage = scene.game.getSeeMessage(titleOwner); const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + if (seeMessage) { + scene.game.log(seeMessage); + return; + } if (title && title.trim()) { scene.game.log(scene.game.text('engine.click_you_see', { title })); return; diff --git a/src/scene/SceneSubscene.ts b/src/scene/SceneSubscene.ts index 3e6afe8..9bdd742 100644 --- a/src/scene/SceneSubscene.ts +++ b/src/scene/SceneSubscene.ts @@ -37,7 +37,10 @@ export function resolveSceneTargets(scene: Scene, targetStr: string): SceneObjec } export function cleanupClosingSubscene(scene: Scene, closingId: string): void { - const closingTargets = resolveSceneTargets(scene, closingId); + const closingTargets = new Set<SceneObject>([ + ...resolveSceneTargets(scene, closingId), + ...Array.from(scene.subsceneEntities), + ]); closingTargets.forEach((obj) => { if (!obj.components) return; From ae55113ce0b1eed58424a749993269b7d1aaf66c Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Wed, 18 Mar 2026 20:51:22 +0200 Subject: [PATCH 33/75] Content update --- public/scenes/quad4.json | 1321 ++++++++++++++++------------- public/scenes/test_room (10).json | 1025 ++++++++++++++++++++-- public/scenes/test_room.json | 48 +- public/text/objects/boombox.json | 5 +- 4 files changed, 1711 insertions(+), 688 deletions(-) diff --git a/public/scenes/quad4.json b/public/scenes/quad4.json index d3b7062..93922bb 100644 --- a/public/scenes/quad4.json +++ b/public/scenes/quad4.json @@ -1,34 +1,40 @@ { "id": "quad4", "name": "Test Room", + "description": "You are in Test Room.", + "textRedirects": {}, "filename": "quad4", "walkbox": [], "triggerboxes": [ { - "type": "Triggerbox", "name": "Trig_137", + "type": "Triggerbox", "locked": false, "disabled": false, - "layer": 0, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], + "layer": 0, "visible": true, + "spatial": {}, "poly": [], "script": "" }, { - "type": "Triggerbox", "name": "Trig_29", + "type": "Triggerbox", "locked": false, "disabled": false, - "layer": 0, "groupID": null, "customName": "", + "textRedirects": {}, "interactions": {}, "components": [], + "layer": 0, "visible": true, + "spatial": {}, "poly": [], "script": "" } @@ -42,8 +48,18 @@ }, "entities": [ { - "type": "Entity", "name": "bg", + "type": "Entity", + "locked": true, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -10, + "visible": true, + "spatial": {}, "x": 21, "y": 307, "width": 796.32, @@ -56,38 +72,30 @@ "color": "#AAAAAA", "scale": 0.63, "modelScale": 0.7, - "layer": -10, "parallax": 0.1, "ignoreScaling": false, "animationSpeed": 150, - "locked": true, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Quad", "name": "sunbeam1", - "x": 19.66326736878159, - "y": 8.75, - "color": "#c95618", - "layer": -1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 0.1, - "blendMode": "lighter", - "blur": 0, + "components": [], + "layer": -1, + "visible": true, + "spatial": {}, + "x": 19.66326736878159, + "y": 8.75, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 2.34, @@ -110,17 +118,31 @@ "p": 0.64 } ], + "color": "#c95618", "sortMode": "ignore", + "opacity": 0.1, + "blendMode": "lighter", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Entity", "name": "Static_3", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -1, + "visible": true, + "spatial": {}, "x": 79, "y": 62, "width": 164.69142857142856, @@ -133,23 +155,26 @@ "color": "#AAAAAA", "scale": 0.14076190476190475, "modelScale": 0.2, - "layer": -1, "parallax": 0.65, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Entity", "name": "Static_4", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -1, + "visible": true, + "spatial": {}, "x": -75, "y": 62, "width": 164.69142857142856, @@ -162,38 +187,30 @@ "color": "#AAAAAA", "scale": 0.14076190476190475, "modelScale": 0.2, - "layer": -1, "parallax": 0.65, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Quad", "name": "glass", - "x": 50.35518543794477, - "y": 56.98544526105304, - "color": "#827c3a", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 0.9, - "blendMode": "overlay", - "blur": 0, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": 50.35518543794477, + "y": 56.98544526105304, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -161.15, @@ -216,32 +233,35 @@ "p": 0.65 } ], + "color": "#827c3a", "sortMode": "ignore", + "opacity": 0.9, + "blendMode": "overlay", "isGrid": false, "gridLinesX": 2, "gridLinesY": 2, "lineWidth": 1.6, "gridColor": "#1d1616", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_72", - "x": -92.63369640031912, - "y": -65.95591650446437, - "color": "#947171", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -92.63369640031912, + "y": -65.95591650446437, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -199.36280668861116, @@ -264,32 +284,35 @@ "p": 1 } ], + "color": "#947171", "sortMode": "v2", + "opacity": 1, + "blendMode": "source-over", "isGrid": true, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 0.5, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "wall_2", - "x": 1.5, - "y": -5.5, - "color": "#855d38", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": 1.5, + "y": -5.5, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 201.75257762598838, @@ -312,32 +335,35 @@ "p": 1 } ], + "color": "#855d38", "sortMode": "v1", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "sunbeam3", - "x": 52.16326736878159, - "y": 140.25, - "color": "#c95618", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 0.1, - "blendMode": "lighter", - "blur": 0, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": 52.16326736878159, + "y": 140.25, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 98.68, @@ -360,17 +386,31 @@ "p": 1 } ], + "color": "#c95618", "sortMode": "v0", + "opacity": 0.1, + "blendMode": "lighter", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Entity", "name": "Static_2", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, "x": 58, "y": 54, "width": 55.44680072970669, @@ -383,38 +423,30 @@ "color": "#4a1215", "scale": 0.7, "modelScale": 1, - "layer": 0, "parallax": 0.7, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Quad", "name": "tabletop1_2", - "x": -5.0976058382019325, - "y": 11.589932781834104, - "color": "#1c495e", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -5.0976058382019325, + "y": 11.589932781834104, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -1.8424309747189085, @@ -437,27 +469,27 @@ "p": 0.75 } ], + "color": "#1c495e", "sortMode": "v3", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_1", - "x": -391.5, - "y": 119.5, - "color": "#283743", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -469,10 +501,13 @@ "cullingType": "render" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 0, + "visible": false, + "spatial": {}, + "x": -391.5, + "y": 119.5, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -449, @@ -495,27 +530,27 @@ "p": 1 } ], + "color": "#283743", "sortMode": "v1", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "floor", - "x": 0.5995731785992522, - "y": 18.186447655482453, - "color": "#5b3a1a", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "3d-parallax" @@ -525,10 +560,13 @@ "mode": "Invert" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 0, + "visible": true, + "spatial": {}, + "x": 0.5995731785992522, + "y": 18.186447655482453, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -338, @@ -551,32 +589,35 @@ "p": 1 } ], + "color": "#5b3a1a", "sortMode": "v0", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 9, "gridLinesY": 9, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "shadow_3", - "x": 45.71298290802447, - "y": 163.46124773339642, - "color": "#535246", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "multiply", - "blur": 5, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": 45.71298290802447, + "y": 163.46124773339642, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 48.23546261048095, @@ -599,32 +640,35 @@ "p": 0.77 } ], + "color": "#535246", "sortMode": "v1", + "opacity": 1, + "blendMode": "multiply", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 5 }, { - "type": "Quad", "name": "lightspot1_1", - "x": -240.36825872762117, - "y": 130.71254989539653, - "color": "#c95618", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": "#lightSpots", - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 0.2, - "blendMode": "lighter", - "blur": 2, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -240.36825872762117, + "y": 130.71254989539653, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -315, @@ -647,32 +691,35 @@ "p": 1 } ], + "color": "#c95618", "sortMode": "v1", + "opacity": 0.2, + "blendMode": "lighter", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 2 }, { - "type": "Quad", "name": "lightspot2", - "x": 55.331970640694806, - "y": 141.72490207880932, - "color": "#c95618", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": "#lightSpots", - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 0.2, - "blendMode": "lighter", - "blur": 2, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": 55.331970640694806, + "y": 141.72490207880932, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 6, @@ -695,32 +742,35 @@ "p": 1 } ], + "color": "#c95618", "sortMode": "v1", + "opacity": 0.2, + "blendMode": "lighter", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 2 }, { - "type": "Quad", "name": "lightspot1", - "x": -94.35007280706921, - "y": 132.21254989539653, - "color": "#c95618", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": "#lightSpots", - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 0.2, - "blendMode": "lighter", - "blur": 2, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -94.35007280706921, + "y": 132.21254989539653, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -139.12445128696197, @@ -743,32 +793,35 @@ "p": 1 } ], + "color": "#c95618", "sortMode": "v0", + "opacity": 0.2, + "blendMode": "lighter", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 2 }, { - "type": "Quad", "name": "Quad_303", - "x": 181.47444229637748, - "y": 65.98012041511447, - "color": "#1d2272", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": 181.47444229637748, + "y": 65.98012041511447, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 155.662541981541, @@ -791,17 +844,31 @@ "p": 0.685 } ], + "color": "#1d2272", "sortMode": "v3", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Entity", "name": "Static_231", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, "x": 15, "y": 78, "width": 28.70935763453951, @@ -814,38 +881,30 @@ "color": "#381a1a", "scale": 0.07508698412698413, "modelScale": 0.1, - "layer": 0, "parallax": 0.748, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Quad", "name": "shadow_2", - "x": -135.3242980143822, - "y": 162.32192586595625, - "color": "#535246", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "multiply", - "blur": 5, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -135.3242980143822, + "y": 162.32192586595625, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -132.80181831192573, @@ -868,32 +927,35 @@ "p": 0.7 } ], + "color": "#535246", "sortMode": "v2", + "opacity": 1, + "blendMode": "multiply", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 5 }, { - "type": "Quad", "name": "shadow_1", - "x": 29.72577036587564, - "y": 152.95900020457972, - "color": "#535246", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "multiply", - "blur": 5, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": 29.72577036587564, + "y": 152.95900020457972, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 32.248250068332126, @@ -916,27 +978,27 @@ "p": 0.77 } ], + "color": "#535246", "sortMode": "v3", + "opacity": 1, + "blendMode": "multiply", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 5 }, { - "type": "Quad", "name": "Quad_2", - "x": -189.21706697024933, - "y": 58.32628202579792, - "color": "#ba814b", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -948,10 +1010,13 @@ "cullingType": "render" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 0, + "visible": true, + "spatial": {}, + "x": -189.21706697024933, + "y": 58.32628202579792, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -164.72899239988428, @@ -974,32 +1039,35 @@ "p": 0.75 } ], + "color": "#ba814b", "sortMode": "v3", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_891", - "x": -207, - "y": 76.75, - "color": "#474747", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -207, + "y": 76.75, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -182.04473703579987, @@ -1022,27 +1090,27 @@ "p": 0.75 } ], + "color": "#474747", "sortMode": "v2", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_204", - "x": -206.21706697024933, - "y": 61.32628202579792, - "color": "#636363", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -1067,10 +1135,13 @@ "mode": "Subtract" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 0, + "visible": false, + "spatial": {}, + "x": -206.21706697024933, + "y": 61.32628202579792, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -181.59999999999997, @@ -1093,27 +1164,27 @@ "p": 0.75 } ], + "color": "#636363", "sortMode": "v3", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_4", - "x": -207.71706697024933, - "y": 79.32628202579792, - "color": "#888888", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -1125,10 +1196,13 @@ "cullingType": "render" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 0, + "visible": true, + "spatial": {}, + "x": -207.71706697024933, + "y": 79.32628202579792, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -212, @@ -1151,32 +1225,35 @@ "p": 1 } ], + "color": "#888888", "sortMode": "v2", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "lightspot1_2", - "x": -301.32662389675636, - "y": 144.63442346911063, - "color": "#c95618", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 0.2, - "blendMode": "lighter", - "blur": 2, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -301.32662389675636, + "y": 144.63442346911063, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -449, @@ -1199,75 +1276,78 @@ "p": 1 } ], + "color": "#c95618", "sortMode": "v0", + "opacity": 0.2, + "blendMode": "lighter", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 2 }, { - "type": "Quad", "name": "shadow_hero", - "x": -96.83532994201772, - "y": 90.04937274515352, - "color": "#535246", - "layer": 0, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 0.5, - "blendMode": "multiply", - "blur": 5, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -58.38940219735827, + "y": 90.05888362114656, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { - "x": -96.83532994201772, - "y": 90.04937274515352, - "p": 0.8010489022254632 + "x": -58.38940219735827, + "y": 90.05888362114656, + "p": 0.8018414752248832 }, { - "x": -71.65959761167679, - "y": 90.15046873444555, - "p": 0.8021390174531002 + "x": -33.0497420477009, + "y": 90.17377655121713, + "p": 0.8030099773830363 }, { - "x": -130.56026019579616, - "y": 130.143715311925, + "x": -101.38069836073447, + "y": 132.64466865932502, "p": 0.9969905705682787 }, { - "x": -134.2968063271847, - "y": 118.33601955695332, - "p": 0.9539817613221357 + "x": -103.81194334935691, + "y": 120.404686934742, + "p": 0.9658771764546956 } ], + "color": "#535246", "sortMode": "v2", + "opacity": 0.5, + "blendMode": "multiply", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 5 }, { - "type": "Quad", "name": "Quad_8", - "x": -189.21706697024933, - "y": 58.32628202579792, - "color": "#ba814b", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -1279,10 +1359,13 @@ "cullingType": "render" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 1, + "visible": true, + "spatial": {}, + "x": -189.21706697024933, + "y": 58.32628202579792, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -183.61963904840187, @@ -1305,27 +1388,27 @@ "p": 0.85 } ], + "color": "#ba814b", "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_9", - "x": -214.2170669702493, - "y": 63.88183758135348, - "color": "#646464", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -1337,10 +1420,13 @@ "cullingType": "render" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 1, + "visible": false, + "spatial": {}, + "x": -214.2170669702493, + "y": 63.88183758135348, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -208.61963904840184, @@ -1363,32 +1449,35 @@ "p": 0.85 } ], + "color": "#646464", "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_225", - "x": -188.1239844818758, - "y": 46.14256198347107, - "color": "#292828", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 1, + "visible": true, + "spatial": {}, + "x": -188.1239844818758, + "y": 46.14256198347107, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -211.45932857364383, @@ -1411,32 +1500,35 @@ "p": 1 } ], + "color": "#292828", "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_6", - "x": 179.97444229637748, - "y": 67.48012041511447, - "color": "#191d5d", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 1, + "visible": true, + "spatial": {}, + "x": 179.97444229637748, + "y": 67.48012041511447, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 154.162541981541, @@ -1459,32 +1551,35 @@ "p": 0.67 } ], + "color": "#191d5d", "sortMode": "v3", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "tabletop1", - "x": -20.53908115474743, - "y": 12.675865196363418, - "color": "#2c647d", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 1, + "visible": true, + "spatial": {}, + "x": -20.53908115474743, + "y": 12.675865196363418, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 10, @@ -1507,17 +1602,31 @@ "p": 0.75 } ], + "color": "#2c647d", "sortMode": "v1", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Entity", "name": "table-top-dice", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 1, + "visible": true, + "spatial": {}, "x": 65, "y": 40, "width": 65.1612685556661, @@ -1530,38 +1639,30 @@ "color": "#1d4853", "scale": 0.7, "modelScale": 1, - "layer": 1, "parallax": 0.7, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Quad", "name": "tabletop1_1", - "x": 12.960918845252571, - "y": 13.175865196363418, - "color": "#2c647d", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 1, + "visible": true, + "spatial": {}, + "x": 12.960918845252571, + "y": 13.175865196363418, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 35, @@ -1584,17 +1685,31 @@ "p": 0.7 } ], + "color": "#2c647d", "sortMode": "v3", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Entity", "name": "Static_386", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 1, + "visible": true, + "spatial": {}, "x": 15, "y": 54, "width": 33.18208520382986, @@ -1607,38 +1722,30 @@ "color": "#1d4853", "scale": 0.7, "modelScale": 1, - "layer": 1, "parallax": 0.75, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Quad", "name": "sunbeam_fuse", - "x": -71.60279117198134, - "y": 140.47520024754013, - "color": "#c95618", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 0.2, - "blendMode": "screen", - "blur": 5, + "components": [], + "layer": 1, + "visible": true, + "spatial": {}, + "x": -71.60279117198134, + "y": 140.47520024754013, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -137.70178586019702, @@ -1661,32 +1768,35 @@ "p": 0.7 } ], + "color": "#c95618", "sortMode": "v3", + "opacity": 0.2, + "blendMode": "screen", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 5 }, { - "type": "Quad", "name": "shadow", - "x": 7.712982908024468, - "y": 164.46124773339642, - "color": "#535246", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "multiply", - "blur": 5, + "components": [], + "layer": 1, + "visible": true, + "spatial": {}, + "x": 7.712982908024468, + "y": 164.46124773339642, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 10.235462610480951, @@ -1709,17 +1819,31 @@ "p": 0.77 } ], + "color": "#535246", "sortMode": "v1", + "opacity": 1, + "blendMode": "multiply", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 5 }, { - "type": "Entity", "name": "plant", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, "x": -121, "y": 69, "width": 62.735725714285714, @@ -1732,23 +1856,26 @@ "color": "#AAAAAA", "scale": 0.1452215873015873, "modelScale": 0.2, - "layer": 0, "parallax": 0.648, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Entity", "name": "chair", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 1, + "visible": true, + "spatial": {}, "x": 58, "y": 77, "width": 35.06554666666667, @@ -1761,33 +1888,22 @@ "color": "#AAAAAA", "scale": 0.20997333333333335, "modelScale": 0.28, - "layer": 1, "parallax": 0.69, "ignoreScaling": false, "animationSpeed": 150, - "locked": false, - "disabled": false, - "customName": "", - "groupID": null, - "components": [], - "interactions": {}, "opacity": 1, "blendMode": "source-over", "blur": 0 }, { - "type": "Quad", "name": "Quad_7", - "x": -233.8281780813605, - "y": 74.88183758135345, - "color": "#666666", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -1799,10 +1915,13 @@ "cullingType": "render" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 1, + "visible": false, + "spatial": {}, + "x": -233.8281780813605, + "y": 74.88183758135345, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -238.11111111111117, @@ -1825,37 +1944,27 @@ "p": 1 } ], + "color": "#666666", "sortMode": "v2", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Actor", "name": "miles_ds", - "x": -93.86496524392734, - "y": 90.88536850626807, - "width": 25.013972096100225, - "height": 102.14038605907592, - "baseWidth": 96, - "baseHeight": 392, - "colliderWidth": 32, - "colliderHeight": 6, - "spriteName": "miles_ds-idle-right.json", - "color": "#00ffff", - "scale": 0.26056220933437735, - "modelScale": 0.33, - "layer": 1, - "parallax": 0.8055746633180803, - "ignoreScaling": false, - "animationSpeed": 30, + "type": "Actor", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Shadow", @@ -1865,7 +1974,24 @@ "triggerId": "#lightSpots" } ], - "interactions": {}, + "layer": 1, + "visible": true, + "spatial": {}, + "x": -55.25117461216202, + "y": 90.89600096647854, + "width": 25.013972096100225, + "height": 102.14038605907592, + "baseWidth": 96, + "baseHeight": 392, + "colliderWidth": 32, + "colliderHeight": 6, + "spriteName": "miles_ds-idle-right.json", + "color": "#00ffff", + "scale": 0.26056220933437735, + "modelScale": 0.33, + "parallax": 0.8064607016689532, + "ignoreScaling": false, + "animationSpeed": 30, "opacity": 1, "blendMode": "source-over", "blur": 0, @@ -1890,18 +2016,14 @@ } }, { - "type": "Quad", "name": "Quad_5", - "x": -207.71706697024933, - "y": 79.32628202579792, - "color": "#b47e4b", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -1913,10 +2035,13 @@ "cullingType": "render" } ], - "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "layer": 1, + "visible": true, + "spatial": {}, + "x": -207.71706697024933, + "y": 79.32628202579792, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -212, @@ -1939,32 +2064,35 @@ "p": 1 } ], + "color": "#b47e4b", "sortMode": "v2", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_3", - "x": -245.72222222222223, - "y": 133.91666666666663, - "color": "#474747", - "layer": 1, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 1, + "visible": true, + "spatial": {}, + "x": -245.72222222222223, + "y": 133.91666666666663, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -241, @@ -1987,32 +2115,35 @@ "p": 1 } ], + "color": "#474747", "sortMode": "v2", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "w4_1", - "x": -158.13739111340288, - "y": -247.54630050191656, - "color": "#0a0a0a", - "layer": 2, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 2, + "visible": true, + "spatial": {}, + "x": -158.13739111340288, + "y": -247.54630050191656, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -289, @@ -2035,32 +2166,35 @@ "p": 1 } ], + "color": "#0a0a0a", "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#1c9245", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "bas1", - "x": -173.87012979571483, - "y": 224.39393470676015, - "color": "#0a0a0a", - "layer": 2, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 2, + "visible": true, + "spatial": {}, + "x": -173.87012979571483, + "y": 224.39393470676015, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -449, @@ -2083,32 +2217,35 @@ "p": 1 } ], + "color": "#0a0a0a", "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "bas2", - "x": 251.2525776259888, - "y": -6.459972841696704, - "color": "#0a0a0a", - "layer": 2, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 1, - "blendMode": "source-over", - "blur": 0, + "components": [], + "layer": 2, + "visible": true, + "spatial": {}, + "x": 251.2525776259888, + "y": -6.459972841696704, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 201.75257762598838, @@ -2131,27 +2268,27 @@ "p": 1 } ], + "color": "#0a0a0a", "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 0 }, { - "type": "Quad", "name": "Quad_521", - "x": -187.12107701481986, - "y": 57.681818181818166, - "color": "#855d38", - "layer": 2, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": false, "disabled": false, - "customName": "", "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, "components": [ { "type": "Backface", @@ -2163,10 +2300,13 @@ "cullingType": "render" } ], - "interactions": {}, - "opacity": 0.75, - "blendMode": "source-over", - "blur": 2, + "layer": 2, + "visible": true, + "spatial": {}, + "x": -187.12107701481986, + "y": 57.681818181818166, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": -210.302895196638, @@ -2189,32 +2329,35 @@ "p": 1 } ], + "color": "#855d38", "sortMode": "ignore", + "opacity": 0.75, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 2 }, { - "type": "Quad", "name": "sunbeam2", - "x": 98.16326736878159, - "y": 142.75, - "color": "#c95618", - "layer": 2, - "parallax": 1, - "ignoreScaling": false, + "type": "Quad", "locked": true, "disabled": false, - "customName": "", "groupID": null, - "components": [], + "customName": "", + "textRedirects": {}, "interactions": {}, - "opacity": 0.5, - "blendMode": "screen", - "blur": 10, + "components": [], + "layer": 2, + "visible": true, + "spatial": {}, + "x": 98.16326736878159, + "y": 142.75, + "parallax": 1, + "ignoreScaling": false, "vertices": [ { "x": 88.25, @@ -2237,13 +2380,17 @@ "p": 0.64 } ], + "color": "#c95618", "sortMode": "v3", + "opacity": 0.5, + "blendMode": "screen", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", - "filled": true + "filled": true, + "blur": 10 } ], "camera": { diff --git a/public/scenes/test_room (10).json b/public/scenes/test_room (10).json index 775a7bb..8256b5f 100644 --- a/public/scenes/test_room (10).json +++ b/public/scenes/test_room (10).json @@ -1,119 +1,988 @@ { "id": "test_room (10)", "name": "Test Room", + "description": "You are in Test Room.", + "textRedirects": {}, "filename": "test_room (10)", - "walkbox": [], - "triggerboxes": [], + "walkbox": [ + { + "name": "Walk_997", + "type": "Walkbox", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "poly": [ + { + "x": -79, + "y": 346 + }, + { + "x": -81, + "y": 272 + }, + { + "x": 153, + "y": 272 + }, + { + "x": 153, + "y": 346 + } + ], + "mode": "Add" + }, + { + "name": "Walk_176", + "type": "Walkbox", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "poly": [ + { + "x": 82, + "y": 192 + }, + { + "x": 407, + "y": 195 + }, + { + "x": 408, + "y": 203 + }, + { + "x": 470, + "y": 207 + }, + { + "x": 596, + "y": 220 + }, + { + "x": 624, + "y": 211 + }, + { + "x": 680, + "y": 211 + }, + { + "x": 680, + "y": 297 + }, + { + "x": -234, + "y": 291 + }, + { + "x": -210, + "y": 247 + }, + { + "x": -111, + "y": 249 + }, + { + "x": 77, + "y": 194 + } + ], + "mode": "Add" + } + ], + "triggerboxes": [ + { + "name": "Trig_sub_D", + "type": "Triggerbox", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Subscene", + "targetGroupId": "#D", + "name": "" + } + ], + "layer": 0, + "visible": true, + "spatial": {}, + "poly": [ + { + "x": 27, + "y": 210 + }, + { + "x": 28, + "y": 104 + }, + { + "x": 84, + "y": 94 + }, + { + "x": 83, + "y": 136 + }, + { + "x": 54, + "y": 145 + }, + { + "x": 50, + "y": 160 + }, + { + "x": 76, + "y": 170 + }, + { + "x": 79, + "y": 194 + } + ], + "script": "" + }, + { + "name": "sub_sw_d1", + "type": "Triggerbox", + "locked": false, + "disabled": true, + "groupID": "", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Switch", + "groupId1": "nil", + "groupId2": "#D1", + "state": 1, + "idKey": "", + "sound1": "drawer_open.wav", + "sound2": "drawer_close.wav" + } + ], + "layer": 1, + "visible": true, + "spatial": { + "parentNodeId": "Trig_sub_D", + "relation": "in" + }, + "poly": [ + { + "x": -156.29411764705887, + "y": -171.3529411764706 + }, + { + "x": 425, + "y": -171 + }, + { + "x": 414, + "y": -95 + }, + { + "x": -143, + "y": -95 + } + ], + "script": "" + }, + { + "name": "Trig_834", + "type": "Triggerbox", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "poly": [], + "script": "" + }, + { + "name": "sub_sw_d2", + "type": "Triggerbox", + "locked": false, + "disabled": true, + "groupID": "#D ", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Switch", + "groupId1": "nil", + "groupId2": "#D2", + "state": 1, + "idKey": "", + "sound1": "drawer_open.wav", + "sound2": "drawer_close.wav" + } + ], + "layer": 1, + "visible": true, + "spatial": {}, + "poly": [ + { + "x": -156.29411764705878, + "y": -87.03921568627447 + }, + { + "x": 424.9999999999999, + "y": -86.68627450980387 + }, + { + "x": 413.9999999999999, + "y": -10.68627450980393 + }, + { + "x": -142.99999999999994, + "y": -10.68627450980393 + } + ], + "script": "" + }, + { + "name": "Drawer3", + "type": "Triggerbox", + "locked": false, + "disabled": true, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Subscene", + "targetGroupId": "", + "title": "", + "description": "" + } + ], + "layer": 0, + "visible": true, + "spatial": { + "parentNodeId": "Trig_sub_D", + "relation": "in" + }, + "poly": [ + { + "x": -106, + "y": 4 + }, + { + "x": 389, + "y": 3 + }, + { + "x": 375, + "y": 95 + }, + { + "x": -107, + "y": 91 + } + ], + "script": "" + } + ], "scaling": { - "enabled": false, - "min": 0.5, + "enabled": true, + "min": 0.91, "max": 1, - "horizon": 78, - "front": 300 + "horizon": 193, + "front": 269 }, "entities": [ { + "name": "CityView", "type": "Entity", - "name": "Static_832", - "x": 66, - "y": 39, - "width": 3.5, - "height": 8, - "baseWidth": 3.5, - "baseHeight": 8, - "spriteName": null, + "locked": true, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -2, + "visible": true, + "spatial": {}, + "x": 119.67896209456934, + "y": 234, + "width": 821.6, + "height": 551.2, + "baseWidth": 1264, + "baseHeight": 848, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "window-view.json", "color": "#00ff00", - "scale": 1, - "layer": 0, - "parallax": 0.5, - "ignoreScaling": false + "scale": 0.65, + "modelScale": 0.65, + "parallax": 0.4, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 }, { + "name": "room", "type": "Entity", - "name": "Pillar", - "x": 177, - "y": 85, - "width": 96.44819819819818, - "height": 3.6103603603603602, - "baseWidth": 96.44819819819818, - "baseHeight": 3.6103603603603602, - "spriteName": null, + "locked": true, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -1, + "visible": true, + "spatial": {}, + "x": 199, + "y": 297, + "width": 884.8, + "height": 593.5999999999999, + "baseWidth": 1264, + "baseHeight": 848, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "room2", "color": "#888888", - "scale": 1, - "layer": -6, + "scale": 0.7, + "modelScale": 0.7, "parallax": 1, - "ignoreScaling": false + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 }, { + "name": "Chair", "type": "Entity", - "name": "Key", - "x": 73, - "y": 80, - "width": 1.272623975326678, - "height": 1.272623975326678, - "baseWidth": 1.272623975326678, - "baseHeight": 1.272623975326678, - "spriteName": null, - "color": "#ffff00", - "scale": 1, + "locked": true, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], "layer": 0, + "visible": true, + "spatial": {}, + "x": 100, + "y": 249, + "width": 116.89999999999999, + "height": 198.79999999999998, + "baseWidth": 167, + "baseHeight": 284, + "colliderWidth": 78, + "colliderHeight": 18, + "spriteName": "chair.json", + "color": "#00ff00", + "scale": 0.7, + "modelScale": 0.7, "parallax": 1, - "ignoreScaling": false - }, - { - "type": "Player", - "name": "Player", - "x": 150.91451042912607, - "y": 81.31452047584774, - "width": 15.234510253628654, - "height": 25.390850422714422, - "baseWidth": 15.234510253628654, - "baseHeight": 25.390850422714422, - "spriteName": "assets/hero.png", + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "Hero_1", + "type": "Actor", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": 251.49445909180304, + "y": 256.5381551800198, + "width": 69.81706443264875, + "height": 285.0863464333157, + "baseWidth": 96, + "baseHeight": 392, + "colliderWidth": 88, + "colliderHeight": 4, + "spriteName": "miles_ds-idle-right.json", "color": "#00ffff", + "scale": 0.7272610878400911, + "modelScale": 0.74, + "parallax": 1.046112265312777, + "ignoreScaling": false, + "animationSpeed": 30, + "opacity": 1, + "blendMode": "source-over", + "blur": 0, + "isPlayer": true, + "speed": 0.24, + "direction": "left", + "animSets": { + "idle": { + "id": "idle", + "up": "miles_ds-idle-up.json", + "down": "miles_ds-idle-down.json", + "left": null, + "right": "miles_ds-idle-right.json" + }, + "walk": { + "id": "walk", + "up": "miles_ds-walk-up.json", + "down": "miles_ds-walk-down.json", + "left": null, + "right": "miles_ds-walk-right.json" + } + } + }, + { + "name": "Black_1", + "type": "Entity", + "locked": true, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -328, + "y": 307, + "width": 171.2340644206598, + "height": 637.1050459736764, + "baseWidth": 155.6673312915089, + "baseHeight": 579.1864054306149, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#000000", + "scale": 1.1, + "modelScale": 1.1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D_main", + "type": "Entity", + "locked": true, + "disabled": true, + "groupID": "#D", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": 135, + "y": 310, + "width": 614.4, + "height": 484.79999999999995, + "baseWidth": 1024, + "baseHeight": 808, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_main", + "color": "#00ff00", + "scale": 0.6, + "modelScale": 0.6, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D2_body", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": "#D2", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Subtrigger", + "target": "sub_sw_d2" + } + ], + "layer": 3, + "visible": true, + "spatial": {}, + "x": 134, + "y": 311, + "width": 614.4, + "height": 407.4, + "baseWidth": 1024, + "baseHeight": 679, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_d2.json", + "color": "#00ff00", + "scale": 0.6, + "modelScale": 0.6, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D1_body", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": "#D1", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 4, + "visible": true, + "spatial": {}, + "x": 136, + "y": -4, + "width": 614.4, + "height": 177.6, + "baseWidth": 1024, + "baseHeight": 296, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_d1_body.json", + "color": "#00ff00", + "scale": 0.6, + "modelScale": 0.6, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "miles_id", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": "#D1", + "customName": "your ID card", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Item", + "ignoreDistance": true + } + ], + "layer": 5, + "visible": true, + "spatial": { + "parentNodeId": "Drawer1", + "relation": "in" + }, + "x": 93, + "y": 0, + "width": 150.4436263347707, + "height": 93.2343137254902, + "baseWidth": 165.32266630194582, + "baseHeight": 102.45528980823099, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_d1_id.json", + "color": "#AAAAAA", + "scale": 0.91, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D1_stuff", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": "#D1", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 5, + "visible": true, + "spatial": { + "parentNodeId": "Drawer1", + "relation": "in" + }, + "x": 320, + "y": 184, + "width": 979, + "height": 412, + "baseWidth": 979, + "baseHeight": 412, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_d1_items.json", + "color": "#00ff00", "scale": 1, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D_main_top", + "type": "Entity", + "locked": true, + "disabled": true, + "groupID": "#D", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 6, + "visible": true, + "spatial": {}, + "x": 135, + "y": -176, + "width": 614.4, + "height": 129.6, + "baseWidth": 1024, + "baseHeight": 216, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_top.json", + "color": "#00ff00", + "scale": 0.6, + "modelScale": 0.6, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D1_fasade", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": "#D1", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Subtrigger", + "target": "sub_sw_d1" + } + ], + "layer": 6, + "visible": true, + "spatial": {}, + "x": 135, + "y": 66, + "width": 614.4, + "height": 105.6, + "baseWidth": 1024, + "baseHeight": 176, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_d1_facade.json", + "color": "#00ff00", + "scale": 0.6, + "modelScale": 0.6, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "floor-parallax", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "3d-parallax" + } + ], "layer": 0, + "visible": true, + "spatial": {}, + "x": -146.13725490196006, + "y": 289.60784313725503, "parallax": 1, - "ignoreScaling": false + "ignoreScaling": false, + "vertices": [ + { + "x": -205.9411764705876, + "y": 185.68627450980406, + "p": 1 + }, + { + "x": 708, + "y": 186, + "p": 1 + }, + { + "x": 766, + "y": 339, + "p": 1.1 + }, + { + "x": -196, + "y": 339, + "p": 1.1 + } + ], + "color": "#52779aff", + "sortMode": "ignore", + "opacity": 0, + "blendMode": "source-over", + "isGrid": false, + "gridLinesX": 5, + "gridLinesY": 5, + "lineWidth": 1, + "gridColor": "#ffffff", + "filled": true, + "blur": 0 }, { + "name": "Actor_562", "type": "Actor", - "name": "Actor_508", - "x": 131, - "y": 87, - "width": 4.162162162162162, - "height": 14.047297297297298, - "baseWidth": 4.162162162162162, - "baseHeight": 14.047297297297298, + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": 222.90433731748038, + "y": 306.9284515529542, + "width": 1008.8000000000001, + "height": 90.39999999999999, + "baseWidth": 1261, + "baseHeight": 112.99999999999999, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sofa", + "color": "#36d87fff", + "scale": 0.8, + "modelScale": 0.8, + "parallax": 1.079038203629382, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0, + "isPlayer": false, + "speed": 0.1, + "direction": "down", + "animSets": {} + }, + { + "name": "boombox", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -152, + "y": -11, + "width": 112, + "height": 47, + "baseWidth": 123.07692307692307, + "baseHeight": 51.64835164835165, + "colliderWidth": 0, + "colliderHeight": 0, "spriteName": null, - "color": "#0000ff", - "scale": 1, + "color": "#AAAAAA", + "scale": 0.91, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 0, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "test", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Item" + } + ], "layer": 0, + "visible": true, + "spatial": { + "parentNodeId": "Chair", + "relation": "under" + }, + "x": 98, + "y": 243, + "width": 31.114048235454423, + "height": 35.128764136803376, + "baseWidth": 32.10246627605941, + "baseHeight": 36.24471998909933, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#AAAAAA", + "scale": 0.9692105263157895, + "modelScale": 1, "parallax": 1, - "ignoreScaling": false + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 }, { + "name": "Static_649", "type": "Entity", - "name": "Static_385", - "x": 198, - "y": 135, - "width": 15, - "height": 34, - "baseWidth": 15, - "baseHeight": 34, + "locked": false, + "disabled": true, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 15, + "visible": true, + "spatial": { + "parentNodeId": "Drawer3", + "relation": "in" + }, + "x": 138, + "y": 101, + "width": 492, + "height": 58, + "baseWidth": 540.6593406593406, + "baseHeight": 63.73626373626374, + "colliderWidth": 0, + "colliderHeight": 0, "spriteName": null, - "color": "#00ff00", - "scale": 1, + "color": "#b83ed0", + "scale": 0.91, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "Drawer1", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], "layer": 0, - "parallax": 1.5, - "ignoreScaling": false + "visible": true, + "spatial": { + "parentNodeId": "Trig_sub_D", + "relation": "in" + }, + "x": 90.66834500947778, + "y": 120, + "width": 27.3, + "height": 27.3, + "baseWidth": 30, + "baseHeight": 30, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#AAAAAA", + "scale": 0.91, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 0, + "blendMode": "source-over", + "blur": 0 } ], "camera": { - "x": 0, - "y": 0, - "zoom": 1 + "x": 297, + "y": 29, + "zoom": 0.51 }, - "autoCenter": false, - "cameraSpeed": 5 + "autoCenter": true, + "cameraSpeed": 1.5, + "camDeadzoneX": 200, + "camDeadzoneY": -21, + "camMinX": 143, + "camMaxY": 45 } diff --git a/public/scenes/test_room.json b/public/scenes/test_room.json index 703d8bd..bc3afb8 100644 --- a/public/scenes/test_room.json +++ b/public/scenes/test_room.json @@ -30,6 +30,7 @@ ], "layer": -1, "visible": true, + "spatial": {}, "x": 334.7566652973353, "y": 147.45349435034427, "parallax": 1, @@ -80,6 +81,7 @@ "components": [], "layer": -1, "visible": true, + "spatial": {}, "x": 363.1753332849686, "y": -1342.0347499102, "parallax": 1, @@ -138,19 +140,20 @@ ], "layer": 0, "visible": true, - "x": 265.7928920608564, + "spatial": {}, + "x": 265.79289206085633, "y": 449.8636667262623, - "width": 159.4835228879511, - "height": 384.92628055054865, + "width": 159.48352288795112, + "height": 384.9262805505487, "baseWidth": 162, "baseHeight": 391, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "miles_ds-idle-down.json", - "color": "#00ffff", - "scale": 0.9844661906663649, + "color": "#e27fa5", + "scale": 0.984466190666365, "modelScale": 1.03, - "parallax": 0.8536873845960511, + "parallax": 0.8536873845960513, "ignoreScaling": false, "animationSpeed": 30, "opacity": 1, @@ -188,30 +191,31 @@ "components": [], "layer": 0, "visible": true, - "x": 186.57454763425872, - "y": 449.76782116289945, + "spatial": {}, + "x": 186.54416364247743, + "y": 449.7722685807698, "parallax": 1, "ignoreScaling": false, "vertices": [ { - "x": 186.57454763425872, - "y": 449.76782116289945, - "p": 0.8518382623563923 + "x": 186.54416364247743, + "y": 449.7722685807698, + "p": 0.8518262423080945 }, { - "x": 300.548110677063, - "y": 449.77224745488013, - "p": 0.851923657596959 + "x": 297.6163127073407, + "y": 449.77439467798024, + "p": 0.8518934071256167 }, { - "x": 300.7379764115932, - "y": 454.7457940959017, - "p": 0.9478769326493168 + "x": 300.5132416470328, + "y": 453.4957983159374, + "p": 0.9237611059473142 }, { - "x": 193.40132937190342, - "y": 454.4764033186863, - "p": 0.9426796500643867 + "x": 195.7653597390785, + "y": 453.2940530119322, + "p": 0.919868888975613 } ], "color": "#000000", @@ -238,6 +242,7 @@ "components": [], "layer": 0, "visible": true, + "spatial": {}, "x": 91, "y": 392, "width": 706.7755102040817, @@ -269,6 +274,7 @@ "components": [], "layer": 0, "visible": true, + "spatial": {}, "x": 79, "y": 417, "width": 778.4081632653061, @@ -278,7 +284,7 @@ "colliderWidth": 0, "colliderHeight": 0, "spriteName": "scanline_logo", - "color": "#AAAAAA", + "color": "#00ca4c", "scale": 1.3306122448979592, "modelScale": 2.8, "parallax": 0.2, diff --git a/public/text/objects/boombox.json b/public/text/objects/boombox.json index 072159a..9adb742 100644 --- a/public/text/objects/boombox.json +++ b/public/text/objects/boombox.json @@ -1,5 +1,6 @@ { "title": "Boombox", - "description": "A compact tape recorder with a radio.", - "details": "The Sharp GF-7 boombox is connected to the computer. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape." + "description": "The Sharp - small but toothy radio and cassette recorder, connected to the computer.", + "details": "The Sharp GF-7 boombox is connected to the computer using regular audio cables. You used to use it to store programs on cassette tapes, but now you have a floppy disk drive for that. And yet you store your software archives on tapes. Some Commodore programs can also output sound to it. And with the ability to record songs from the radio or capture live audio using the external microphones, the possibilities are endless. Everything works fine, but the magnetic head needs to be adjusted frequently with a screwdriver, and the cassette deck needs to be secured with duct tape.", + "synonyms": ["recorder", "radio", "tape recorder", "Sharp", "GF-7"] } From a40a8c5b12ab6983b8e82978f0107c8a20059a79 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Wed, 18 Mar 2026 23:10:02 +0200 Subject: [PATCH 34/75] Docs: sync parser API and task status --- Parser.md | 8 +++++++- tasks.md | 22 ++++++++++------------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Parser.md b/Parser.md index 3ed4520..eda2d89 100644 --- a/Parser.md +++ b/Parser.md @@ -450,9 +450,13 @@ Parser сначала проверяет: - `lookEntity(entity)` - `examineEntity(entity)` - `takeEntity(entity)` +- `removeInventoryEntity(entity)` - `showInventory()` +- `goToSceneTarget(rawTarget)` - `goToScene(sceneId)` - `goToEntity(entity)` +- `getSeeMessage(target)` +- `describeSpatialRelation(anchorNodeId, relation)` Принцип: - parser — один из клиентов `Game API`, а не его единственный владелец; @@ -482,8 +486,10 @@ Parser сначала проверяет: Например: - `takeEntity(entity)` проверяет дистанцию и возможность взять предмет; - `examineEntity(entity)` проверяет доступность examine; +- `goToSceneTarget(rawTarget)` оставляет `Game` знание о registry сцен и валидности перехода; +- `describeSpatialRelation(anchorNodeId, relation)` формирует player-facing spatial response на основе runtime world model; - `goToEntity(entity)` запускает movement; -- `lookEntity(entity)` возвращает краткое описание. +- `lookEntity(entity)` возвращает краткое описание с учётом spatial parent context, если он есть. То есть: - parser отвечает за язык и выбор цели; diff --git a/tasks.md b/tasks.md index 890824f..f9ebe0d 100644 --- a/tasks.md +++ b/tasks.md @@ -54,7 +54,7 @@ Architecture rules for this initiative: - scope data - unified envelope data - Core decision data -- [ ] Verify that UI, scripts, and game logic continue using the same shared `Game API`. +- [x] Verify that UI, scripts, and game logic continue using the same shared `Game API`. - [ ] Add regression tests / smoke checks for: - `#STAGE1-ON/OFF` - `#STAGE2-ON/OFF` @@ -65,7 +65,7 @@ Architecture rules for this initiative: - post-API escalation - linear plan execution without LLM - manual checklist drafted in `ParserSmoke.md` -- [ ] Formalize runtime spatial hierarchy in `Game` so relation-aware parser queries (`under`, `in`, `behind`, `near`) can execute against real world data instead of returning the current fallback message. +- [ ] Extend runtime spatial hierarchy so remaining relation-aware parser queries like `near` can execute against real world data. ## Spatial Hierarchy Plan @@ -81,14 +81,13 @@ Architecture rules for this initiative: - title - optional description - optional spatial parent link -- [ ] Decide where subscene spatial metadata lives in scene JSON: - - preferably on the `Subscene` component / triggerbox data so migration stays incremental. - [x] Build a scene-level spatial index in runtime: - node lookup by id - children by parent id - children grouped by relation - [ ] Keep this index separate from render hierarchy and separate from visibility/accessibility logic. -- [x] Treat `Subscene` as a virtual spatial node while keeping its authored spatial metadata on the `Subscene` component / triggerbox path. +- [x] Treat `Subscene` as a virtual spatial node. +- [x] Simplify `Subscene` authored data so spatial identity and nesting come from the owning `Triggerbox`, not duplicate fields on the component. - [x] Auto-activate direct spatial children when opening a `Subscene`: - direct `Entity` children - direct `Triggerbox` children @@ -121,11 +120,10 @@ Architecture rules for this initiative: - parent object / node - relation type - [ ] Limit parent candidates to valid nodes in the current scene. -- [x] Add editor UI for `Subscene` to edit: +- [x] Keep `Subscene` editor UI focused on behavior-facing fields only: - title - - node id - - optional parent node - - relation type + - description + - target group id - [x] Add editor UI for `Triggerbox` spatial authoring: - parent object / node - relation type @@ -143,7 +141,7 @@ Architecture rules for this initiative: - [x] Keep existing scenes valid with all spatial fields optional. - [x] Preserve current `activeSubscene` / `subsceneEntities` behavior during migration. -- [ ] Make parser relation grammar continue to work even before a scene defines any spatial metadata. +- [x] Make parser relation grammar continue to work even before a scene defines any spatial metadata. - [ ] Add smoke checks for scenes mixing: - direct object nesting - object inside subscene @@ -152,8 +150,8 @@ Architecture rules for this initiative: ### 5. Documentation -- [ ] Update `Parser.md` so it clearly states spatial hierarchy is owned by `Game`, not parser. -- [ ] Add or update documentation for scene spatial schema and subscene-as-node behavior. +- [x] Update `Parser.md` so it clearly states spatial hierarchy is owned by `Game`, not parser. +- [x] Add or update documentation for scene spatial schema and subscene-as-node behavior. - [ ] Document the editor workflow for assigning parent object and relation type. ## Suggested Order From 6538c6e7b8e07eabeb7d92a2368792a24b9ee7ba Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Wed, 18 Mar 2026 23:34:03 +0200 Subject: [PATCH 35/75] Test: add first autotest iteration --- Autotests.md | 344 ++++++++++++++++++++++ package-lock.json | 376 +++++++++++++++++++++++- package.json | 4 +- tasks.md | 328 ++++++++++----------- tests/fixtures/gameFactory.ts | 130 ++++++++ tests/fixtures/parserFactory.ts | 188 ++++++++++++ tests/fixtures/sceneFactory.ts | 107 +++++++ tests/fixtures/textAssetFactory.ts | 229 +++++++++++++++ tests/integration/parser-game.test.ts | 43 +++ tests/parser/commands.test.ts | 91 ++++++ tests/parser/core.test.ts | 54 ++++ tests/parser/resolution.test.ts | 97 ++++++ tests/scene/spatial-index.test.ts | 53 ++++ tests/scene/subscene-activation.test.ts | 58 ++++ tests/scene/subscene-cleanup.test.ts | 44 +++ vitest.config.ts | 9 + 16 files changed, 1975 insertions(+), 180 deletions(-) create mode 100644 Autotests.md create mode 100644 tests/fixtures/gameFactory.ts create mode 100644 tests/fixtures/parserFactory.ts create mode 100644 tests/fixtures/sceneFactory.ts create mode 100644 tests/fixtures/textAssetFactory.ts create mode 100644 tests/integration/parser-game.test.ts create mode 100644 tests/parser/commands.test.ts create mode 100644 tests/parser/core.test.ts create mode 100644 tests/parser/resolution.test.ts create mode 100644 tests/scene/spatial-index.test.ts create mode 100644 tests/scene/subscene-activation.test.ts create mode 100644 tests/scene/subscene-cleanup.test.ts create mode 100644 vitest.config.ts diff --git a/Autotests.md b/Autotests.md new file mode 100644 index 0000000..3706a5f --- /dev/null +++ b/Autotests.md @@ -0,0 +1,344 @@ +# Autotests + +## Purpose + +This document describes the current automated test setup on the `autotests` branch. + +The first iteration is intentionally narrow: +- deterministic parser behavior; +- parser core contracts; +- scene runtime behavior around spatial hierarchy and subscenes; +- one thin parser + game integration layer. + +This setup is meant to protect the most fragile gameplay contracts without introducing heavy browser or UI end-to-end coverage. + +## Current Stack + +- Test runner: `vitest` +- Environment: `node` +- Command: + +```bash +npm run test +``` + +Type safety check: + +```bash +npm run typecheck +``` + +## Design Principles + +The current autotest system is built around a few constraints: + +- Tests should not depend on large mutable game-content scenes. +- Tests should use small deterministic fixtures. +- Tests should target architecture layers directly: + - parser; + - parser core; + - scene runtime; + - subscene behavior. +- Tests should be readable enough to act as executable architecture documentation. + +Out of scope for this iteration: +- full browser Playwright coverage; +- full UI/canvas assertions; +- LLM-stage testing; +- using live content scenes as the main source of truth. + +## File Layout + +```text +tests/ + fixtures/ + gameFactory.ts + parserFactory.ts + sceneFactory.ts + textAssetFactory.ts + parser/ + commands.test.ts + core.test.ts + resolution.test.ts + scene/ + spatial-index.test.ts + subscene-activation.test.ts + subscene-cleanup.test.ts + integration/ + parser-game.test.ts +vitest.config.ts +``` + +## Fixture System + +The tests use programmatic fixtures instead of real scene files. + +### `tests/fixtures/textAssetFactory.ts` + +Provides a minimal in-memory text layer for tests: +- object titles, descriptions, details, synonyms; +- scene title and description; +- parser service strings; +- parser lexicon; +- parser training data; +- parser command specs. + +Use this when a test needs stable text assets without relying on `public/text/...`. + +### `tests/fixtures/gameFactory.ts` + +Provides a minimal `IGame`-compatible harness: +- captured player-facing messages; +- captured logs; +- captured played sounds; +- minimal `sceneManager`; +- minimal `textAssets`. + +This is the base semantic harness used by scene and parser tests. + +### `tests/fixtures/sceneFactory.ts` + +Builds a tiny `Scene` on top of the test game harness. + +Helpers include: +- `addEntity(...)` +- `addPlayer(...)` +- `addTriggerbox(...)` +- `addWalkbox(...)` + +This is the preferred way to build small deterministic runtime worlds for tests. + +### `tests/fixtures/parserFactory.ts` + +Builds a real `Parser` instance on top of the fixture game and scene. + +It wires the parser to a small semantic gameplay harness for: +- `lookScene` +- `lookEntity` +- `examineEntity` +- `takeEntity` +- `showInventory` +- `goToSceneTarget` +- `goToScene` +- `goToEntity` +- `removeInventoryEntity` +- `describeSpatialRelation` + +It also exposes: + +```ts +await fixture.run('look under chair') +``` + +which returns captured: +- `messages` +- `logs` +- `pendingIntent` + +This is the preferred entry point for parser-side tests. + +## Current Test Coverage + +### Scene Runtime + +#### `tests/scene/spatial-index.test.ts` + +Covers: +- parent/child spatial indexing; +- grouping by relation: + - `in` + - `on` + - `under` + - `behind` +- direct-child lookup staying non-recursive; +- legacy fallback: + - `parentNodeId + relation:null` behaves as `in` + +#### `tests/scene/subscene-activation.test.ts` + +Covers: +- direct entity child activation; +- direct triggerbox child activation; +- nested subscene becoming available; +- grandchildren not auto-activating; +- coexistence of: + - `targetGroupId` + - direct spatial children + +#### `tests/scene/subscene-cleanup.test.ts` + +Covers: +- `Switch` reset on subscene close; +- `sound1` playback path; +- cleanup for spatially included objects, not only group-based ones. + +### Parser + +#### `tests/parser/resolution.test.ts` + +Covers: +- exact resolution; +- synonym match; +- partial match; +- ambiguity clarification; +- deterministic tie-break: + - inventory first; + - nearest scene object when titles are indistinguishable. + +#### `tests/parser/commands.test.ts` + +Covers: +- `teleport` +- `teleport with id` +- wrong item for teleport -> no effect; +- `use id on boombox` +- missing-argument prompts for custom commands. + +#### `tests/parser/core.test.ts` + +Covers: +- pre-API handoff path; +- post-API escalation path; +- linear plan stopping after failure; +- core behavior independent of UI. + +### Thin Integration + +#### `tests/integration/parser-game.test.ts` + +Covers a small end-to-end slice on tiny fixtures: +- `look under chair` +- far-but-visible `examine` + +This layer is intentionally small. + +## How To Run + +Run all tests: + +```bash +npm run test +``` + +Run typecheck: + +```bash +npm run typecheck +``` + +Run a specific test file with Vitest directly: + +```bash +npx vitest run tests/parser/commands.test.ts +``` + +Run tests in watch mode: + +```bash +npx vitest +``` + +## How To Add A New Test + +### Add a parser test + +If the behavior belongs to parser resolution, parser commands, or parser core: +- use `createParserFixture()` +- build the smallest world needed +- run parser input through `fixture.run(...)` +- assert on: + - player-facing messages; + - pending intent; + - scene/inventory side effects. + +Example: + +```ts +const fixture = createParserFixture(); +fixture.addPlayer(); +fixture.addEntity('chair', { title: 'Chair', description: 'A chair.' }); + +const result = await fixture.run('look chair'); + +expect(result.messages.at(-1)).toBe('A chair.'); +``` + +### Add a scene runtime test + +If the behavior belongs to scene/spatial/subscene runtime: +- use `createSceneFixture()` +- build the smallest spatial structure possible +- call runtime helpers or component activation directly +- assert on: + - enabled/disabled state; + - `activeSubscene`; + - `subsceneEntities`; + - switch state; + - played sounds. + +### Add a new parser command fixture + +If a test needs custom command data: +- reuse the default command fixtures already provided; +- or override command assets through: + +```ts +fixture.textAssets.setParserCommands([...]); +``` + +This keeps tests independent from `public/text/system/commands/*.json`. + +## Why Programmatic Fixtures Instead Of Real Scenes + +The current system intentionally avoids large real content scenes because they: +- change frequently during content work; +- contain noise unrelated to the tested contract; +- make failures harder to localize. + +Programmatic fixtures keep failures small and readable. + +Real JSON scene fixtures may still be useful later for: +- serialization tests; +- loader tests; +- migration tests. + +They are not necessary for the first iteration. + +## Current Limitations + +- No browser/UI/canvas assertions yet. +- No Playwright layer yet. +- No direct tests for console preprocessor behavior yet. +- No LLM-stage tests yet. +- Parser NLP stage is not the focus of the current suite. + +## Recommended Next Iteration + +The next useful expansions would be: + +1. Add tests for console-preprocessor behavior: + - `I` + - `X` + - `L` + - `#STAGE1-ON/OFF` + - `#STAGE2-ON/OFF` + +2. Add more parser-core scenarios: + - clarification continuation loops; + - more plan-state transitions; + - more validation branches. + +3. Add tiny serialization/load fixtures if scene loading itself needs coverage. + +4. Add a very small browser smoke layer only if a runtime contract cannot be tested elsewhere. + +## Practical Rule + +When adding a test, prefer this order: + +1. scene/runtime test +2. parser test +3. thin integration test +4. browser/UI test + +If a lower layer can prove the contract, do not jump to a higher one. diff --git a/package-lock.json b/package-lock.json index cdea905..9b10881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,8 @@ "prettier": "^3.3.3", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.0" } }, "node_modules/@babel/code-frame": { @@ -1482,6 +1483,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1527,6 +1535,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1866,6 +1892,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1959,6 +2098,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2066,6 +2215,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2241,6 +2400,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2526,6 +2692,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2567,6 +2743,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3201,6 +3387,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -3340,6 +3536,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -3439,6 +3646,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3770,6 +3984,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3823,6 +4044,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -3922,6 +4157,23 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3971,6 +4223,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4205,6 +4467,101 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4221,6 +4578,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 892776d..50fcdc9 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run", "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.node.json --noEmit", "prepare": "husky", "format": "prettier --write .", @@ -39,7 +40,8 @@ "prettier": "^3.3.3", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.0" }, "lint-staged": { "*.{ts,tsx,js,jsx}": [ diff --git a/tasks.md b/tasks.md index f9ebe0d..2591a25 100644 --- a/tasks.md +++ b/tasks.md @@ -1,178 +1,150 @@ -# Parser Tasks - -## Current Scope - -These tasks cover the parser roadmap described in `Parser.md`, excluding the future LLM cascade. - -## Current Focus - -- [x] Introduce parser custom command assets (`Commands.md`, command TA loading, shared command spec format). -- [x] Expand parser DSL/Core so lower layers can mock richer Stage-2-style plans. -- [x] Implement `TELEPORT WITH` as the first custom command scenario driven by command TA. -- [x] Extend command assets to support multi-argument parsing for flows like `USE X ON Y`. -- [x] Add parser-side relation grammar recognition for queries like `LOOK UNDER TABLE` and `EXAMINE IN DRAWER`. - -## Next Initiative: Spatial Hierarchy In Game - -Goal: -- move spatial world structure into `Game` / scene runtime instead of keeping it as parser-only semantics; -- keep `visibility` and `accessibility` explicitly out of scope for this step; -- let parser consume spatial data as part of world context rather than owning it; -- add editor support so scene authors can assign parent object and relation type. - -Architecture rules for this initiative: -- `spatial` belongs to the world model, not to the parser; -- parser should only read spatial structure through `ParserWorldModelBuilder`; -- visibility/accessibility remain separate concerns and are not part of this task; -- both direct object-to-object nesting and object/subscene nesting must be supported; -- subscene should act as a virtual spatial node as well as a focus/interaction mechanism. - -## Backlog - -- [x] Replace the separate `ParserContextBuilder` / `ParserScopeBuilder` idea with one `ParserWorldModelBuilder` that returns both `context` and `scope`. -- [x] Define explicit scope slices: - - `visible` - - `held` - - `takable` - - `reachable` - - `examinable` - - `subscene` - - `sceneTargets` -- [x] Replace ad-hoc resolution helpers with scope-driven resolution. -- [x] Unify stage outputs so `Stage 1.1` and `Stage 1.2` emit the same Core-facing envelope. -- [x] Refactor `Parser Core` around the unified envelope/protocol. -- [x] Separate pre-API escalation from post-API escalation in `Parser Core`. -- [x] Support linear plan execution in `Parser Core` without requiring LLM. -- [x] Add optional `synonyms` to object TA schema. -- [x] Include `synonyms` in the default object TA template. -- [x] Extend parser target resolution to use: - - `title` - - `synonyms` - - partial matching - - clarification on ambiguity -- [x] Expand `#PEEK` debug output with: - - scope data - - unified envelope data - - Core decision data -- [x] Verify that UI, scripts, and game logic continue using the same shared `Game API`. -- [ ] Add regression tests / smoke checks for: - - `#STAGE1-ON/OFF` - - `#STAGE2-ON/OFF` - - clarification flows - - inventory-aware resolution - - `synonyms` - - pre-API escalation - - post-API escalation - - linear plan execution without LLM - - manual checklist drafted in `ParserSmoke.md` -- [ ] Extend runtime spatial hierarchy so remaining relation-aware parser queries like `near` can execute against real world data. - -## Spatial Hierarchy Plan - -### 1. Runtime / Scene Model - -- [x] Define shared runtime types for spatial placement: - - `parentNodeId` - - `relation` - - relation enum: `in`, `on`, `under`, `behind` -- [x] Add optional spatial metadata to regular scene entities. -- [x] Extend subscene data so a subscene can act as a virtual spatial node: - - stable node id - - title - - optional description - - optional spatial parent link -- [x] Build a scene-level spatial index in runtime: - - node lookup by id - - children by parent id - - children grouped by relation -- [ ] Keep this index separate from render hierarchy and separate from visibility/accessibility logic. -- [x] Treat `Subscene` as a virtual spatial node. -- [x] Simplify `Subscene` authored data so spatial identity and nesting come from the owning `Triggerbox`, not duplicate fields on the component. -- [x] Auto-activate direct spatial children when opening a `Subscene`: - - direct `Entity` children - - direct `Triggerbox` children - - direct nested `Subscene` children -- [x] Keep `Subscene` activation non-recursive: - - opening parent `Subscene A` reveals only direct children - - children of nested `Subscene B` remain inactive until `B` itself is opened - -### 2. Parser Integration - -- [x] Extend `ParserWorldModelBuilder` so parser context includes spatial data projected from runtime. -- [x] Define parser-facing relation projection: - - anchor node id - - relation type - - child node ids -- [x] Replace the current relation-query fallback path with real lookup against runtime spatial data. -- [x] Support first real execution cases: - - `LOOK UNDER X` - - `LOOK IN X` - - `LOOK BEHIND X` -- [ ] Keep `near` out of execution until its runtime semantics are clearly defined. -- [x] Preserve current clarification behavior: - - resolve anchor - - ambiguity handling - - tie-break rules for non-usable ambiguity - -### 3. Editor / UI Authoring - -- [x] Add editor UI for every scene `Entity` to choose: - - parent object / node - - relation type -- [ ] Limit parent candidates to valid nodes in the current scene. -- [x] Keep `Subscene` editor UI focused on behavior-facing fields only: - - title - - description - - target group id -- [x] Add editor UI for `Triggerbox` spatial authoring: - - parent object / node - - relation type -- [ ] Ensure authoring UI does not imply visibility/accessibility behavior that is not implemented yet. -- [x] Add serialization/deserialization support for the new spatial fields. -- [x] Show spatial nesting visually in `HierarchyPanel` for scene entities: - - child entities render below their parent - - nested entities are indented to the right - - flat list order remains stable for roots and fallback cases -- [x] Extend `HierarchyPanel` spatial nesting display to polygon-based scene objects: - - `Triggerbox` - - `Walkbox` - -### 4. Migration / Compatibility - -- [x] Keep existing scenes valid with all spatial fields optional. -- [x] Preserve current `activeSubscene` / `subsceneEntities` behavior during migration. -- [x] Make parser relation grammar continue to work even before a scene defines any spatial metadata. -- [ ] Add smoke checks for scenes mixing: - - direct object nesting - - object inside subscene - - subscene inside object - - nested subscene chains - -### 5. Documentation - -- [x] Update `Parser.md` so it clearly states spatial hierarchy is owned by `Game`, not parser. -- [x] Add or update documentation for scene spatial schema and subscene-as-node behavior. -- [ ] Document the editor workflow for assigning parent object and relation type. - -## Suggested Order - -1. Extract a single world-model builder that produces context and scope together. -2. Unify cascade envelopes. -3. Refactor `Parser Core` around the unified protocol. -4. Add `synonyms` support to TA and target resolution. -5. Improve `#PEEK`. -6. Run regression checks and clean up boundaries with `Game API`. -7. Introduce runtime spatial hierarchy and then reconnect parser relation queries to it. - -## Plan For Step 3 - -- [x] Define a single `CascadeEnvelope` shape that both `Stage 1.1` and `Stage 1.2` emit. -- [x] Replace the current action/handoff JSON split with the unified envelope. -- [x] Make `Parser Core` consume the unified envelope directly instead of inferring behavior from ad-hoc action types. -- [x] Split `Parser Core` flow into explicit phases: - - envelope intake - - pre-API validation/resolution - - API plan execution - - post-API outcome analysis -- [x] Introduce a minimal linear plan execution path in `Core` for non-LLM producers. -- [x] Expose enough debug data in `#PEEK` to inspect envelope and Core decisions while refactoring. +# Autotests Plan + +## Goal + +Introduce the first iteration of automated tests for `Scanline` / `Blue Signal` with focus on deterministic parser, core, and scene-runtime behavior. + +This iteration should: +- cover the most fragile gameplay contracts; +- avoid heavy browser/UI end-to-end coverage; +- use small dedicated fixtures instead of live content scenes; +- be cheap to maintain while the architecture is still evolving. + +Out of scope for this iteration: +- full Playwright coverage; +- LLM-stage testing; +- testing against large real content scenes as the main source of truth. + +## Target Stack + +- [x] Add `vitest` as the test runner. +- [x] Add `npm run test` script. +- [x] Keep the first iteration in a lightweight test environment: + - prefer `node` environment; + - use `jsdom` only if a specific test truly needs it. + +## Test Architecture + +The first iteration should use three layers: + +1. Unit tests for parser and helpers. +2. Runtime tests for scene/spatial/subscene behavior. +3. Thin integration tests for parser + game on tiny fixtures. + +Avoid starting with canvas/UI/browser assertions. + +## Fixtures and Helpers + +- [x] Create `tests/fixtures/sceneFactory.ts` + - helpers for minimal `Scene` setup; + - helpers for entities, triggerboxes, subscenes, switches, and spatial links. + +- [x] Create `tests/fixtures/gameFactory.ts` + - minimal `Game`/`IGame` test harness; + - controllable logging, messages, sounds, and inventory. + +- [x] Create `tests/fixtures/parserFactory.ts` + - build parser with small fixture world; + - helpers for running parser input and reading outcomes. + +- [x] Create `tests/fixtures/textAssetFactory.ts` + - minimal parser/engine text assets for tests; + - keep messages stable and deterministic. + +- [x] Decide fixture style for first iteration: + - start with programmatic fixtures; + - add tiny JSON fixture scenes later only if load/serialization tests need them. + +## First Test Files + +### Parser + +- [x] `tests/parser/resolution.test.ts` + Cover: + - exact title match; + - synonym match; + - partial match; + - ambiguity clarification; + - deterministic tie-break: + - inventory first; + - nearest scene object when needed. + +- [x] `tests/parser/commands.test.ts` + Cover: + - `teleport with id`; + - wrong item -> no effect; + - `use id on boombox`; + - multi-argument parsing for `USE X ON Y`; + - missing-argument prompt cases. + +- [x] `tests/parser/core.test.ts` + Cover: + - unified envelope intake; + - pre-API escalation; + - post-API escalation; + - linear plan execution; + - custom command validation path. + +### Scene / Runtime + +- [x] `tests/scene/spatial-index.test.ts` + Cover: + - direct parent/child lookup; + - relation grouping (`in`, `on`, `under`, `behind`); + - direct-child helper stays non-recursive. + +- [x] `tests/scene/subscene-activation.test.ts` + Cover: + - direct entity child activates; + - direct triggerbox child activates; + - nested subscene becomes available; + - grandchildren do not activate automatically. + +- [x] `tests/scene/subscene-cleanup.test.ts` + Cover: + - switch reset on subscene close; + - `sound1` path fires correctly; + - spatially included switch resets too, not only group-based targets. + +### Thin Integration + +- [x] `tests/integration/parser-game.test.ts` + Cover only a few end-to-end flows on tiny fixtures: + - `look under chair`; + - `teleport with your id card`; + - one far-but-visible `examine` case. + +## Recommended Implementation Order + +1. [x] Add `vitest` infrastructure. +2. [x] Add factories/helpers. +3. [x] Implement spatial runtime tests first: + - `spatial-index.test.ts` + - `subscene-activation.test.ts` + - `subscene-cleanup.test.ts` +4. [x] Implement parser command/resolution tests. +5. [x] Add one thin integration test file. + +## Success Criteria For Iteration 1 + +- [x] `npm run test` works locally. +- [x] Tests do not depend on large mutable content scenes. +- [x] The most fragile parser/runtime contracts are covered. +- [x] Failing tests point to a specific layer: + - parser; + - core; + - scene runtime; + - subscene behavior. + +## Notes + +- Keep UI click behavior out of the first iteration unless a contract cannot be tested elsewhere. +- Prefer deterministic fixtures over browser automation. +- Keep tests readable enough that they double as executable architecture documentation. +- `Autotests.md` is the current developer-facing description of the test system, fixtures, coverage, and usage workflow. +- Current progress: + - `vitest` bootstrap is in place; + - runtime spatial/subscene tests are green; + - parser resolution, commands, and core tests are green; + - one thin integration smoke file is green; + - current status: first autotest iteration is functionally complete. diff --git a/tests/fixtures/gameFactory.ts b/tests/fixtures/gameFactory.ts new file mode 100644 index 0000000..de77c9b --- /dev/null +++ b/tests/fixtures/gameFactory.ts @@ -0,0 +1,130 @@ +import type { IGame } from '../../src/core/IGame'; +import type { Scene } from '../../src/scene/Scene'; +import type { SceneObject } from '../../src/entities/SceneObject'; +import type { SpatialRelationType } from '../../src/scene/spatialTypes'; +import type { Entity } from '../../src/entities/Entity'; +import type { GameActionOutcome } from '../../src/core/GameActionTypes'; +import { createTestTextAssets } from './textAssetFactory'; + +export type TestGameHarness = { + game: IGame; + messages: string[]; + logs: string[]; + sounds: string[]; + notifications: string[]; + textAssets: ReturnType<typeof createTestTextAssets>; +}; + +function notImplementedOutcome(code: string): GameActionOutcome { + return { + status: 'failed', + code, + recoverable: false, + }; +} + +export function createTestGame(): TestGameHarness { + const messages: string[] = []; + const logs: string[] = []; + const sounds: string[] = []; + const notifications: string[] = []; + const textAssets = createTestTextAssets(); + + const game: IGame = { + assets: { + setImageCacheBudget() {}, + markSceneSpriteRefs() {}, + syncSceneCacheState() {}, + renameSceneSpriteRefs() {}, + releaseSceneSpriteRefs() {}, + getImageCacheStats() { + return { budgetBytes: 0, estimatedBytes: 0 }; + }, + estimateSpritesTextureBytes: async () => ({ bytes: 0 }), + } as any, + audio: {} as any, + textAssets: textAssets as any, + sceneManager: { + currentScene: null, + scenes: new Map(), + sceneRegistry: new Map(), + switchTo() {}, + } as any, + editor: { + enabled: false, + selectionManager: { + notifyObjectChanged() {}, + }, + } as any, + inventory: [], + showMessage(text: string) { + messages.push(text); + }, + log(text: string) { + logs.push(text); + messages.push(text); + }, + text(key: string, params?: Record<string, string | number>) { + return textAssets.getServiceText(key, params); + }, + getSeeMessage(_target: SceneObject) { + return null; + }, + lookScene(_scene?: Scene | null) { + return notImplementedOutcome('not_implemented_look_scene'); + }, + lookEntity(_entity: Entity) { + return notImplementedOutcome('not_implemented_look_entity'); + }, + describeSpatialRelation(_anchorNodeId: string, _relation: SpatialRelationType) { + return notImplementedOutcome('not_implemented_describe_spatial_relation'); + }, + examineEntity(_entity: Entity) { + return notImplementedOutcome('not_implemented_examine_entity'); + }, + takeEntity(_entity: Entity) { + return notImplementedOutcome('not_implemented_take_entity'); + }, + removeInventoryEntity(_entity: Entity) { + return notImplementedOutcome('not_implemented_remove_inventory_entity'); + }, + showInventory() { + return notImplementedOutcome('not_implemented_show_inventory'); + }, + goToSceneTarget(_target: string) { + return notImplementedOutcome('not_implemented_go_to_scene_target'); + }, + goToScene(_sceneId: string) { + return notImplementedOutcome('not_implemented_go_to_scene'); + }, + goToEntity(_entity: Entity) { + return notImplementedOutcome('not_implemented_go_to_entity'); + }, + showNotification(text: string) { + notifications.push(text); + }, + playSound(name: string) { + sounds.push(name); + }, + openFileBrowser() {}, + setCommandInput() {}, + getCommandInput() { + return null; + }, + focusCommandInput() {}, + input: {}, + isMouseOverUI: false, + canvas: {} as HTMLCanvasElement, + ctx: null, + bufferCanvas: {} as HTMLCanvasElement, + }; + + return { + game, + messages, + logs, + sounds, + notifications, + textAssets, + }; +} diff --git a/tests/fixtures/parserFactory.ts b/tests/fixtures/parserFactory.ts new file mode 100644 index 0000000..91d9dd9 --- /dev/null +++ b/tests/fixtures/parserFactory.ts @@ -0,0 +1,188 @@ +import type { GameActionOutcome } from '../../src/core/GameActionTypes'; +import { Parser } from '../../src/mechanics/Parser'; +import { ComponentSystem } from '../../src/systems/ComponentSystem'; +import { createSceneFixture, type SceneFixture } from './sceneFactory'; +import { Entity } from '../../src/entities/Entity'; + +export type ParserFixture = SceneFixture & { + parser: Parser; + run(input: string): Promise<{ + messages: string[]; + logs: string[]; + pendingIntent: string | null; + }>; +}; + +function okOutcome(code: string, message?: string, data?: Record<string, unknown>): GameActionOutcome { + return { status: 'ok', code, message, data }; +} + +export function createParserFixture(): ParserFixture { + const fixture = createSceneFixture(); + + fixture.game.console = { + parserStage1Enabled: true, + parserStage2Enabled: false, + parserPeekEnabled: false, + log() {}, + }; + + fixture.game.lookScene = (scene = fixture.game.sceneManager.currentScene) => { + const targetScene = (scene as any) || fixture.scene; + const description = + fixture.textAssets.getResolvedSceneField(targetScene as any, 'description') || + targetScene?.description || + `You are in ${targetScene?.name || 'Unknown Scene'}.`; + return okOutcome('scene_description', description, { + targetType: 'scene', + sceneId: targetScene?.id, + }); + }; + + fixture.game.lookEntity = (entity: Entity) => { + const description = + fixture.textAssets.getResolvedObjectField(entity, 'description') || entity.description; + if (description?.trim()) { + return okOutcome('entity_description', description, { + targetType: 'entity', + entityId: entity.name, + }); + } + return { status: 'escalate', code: 'missing_description', recoverable: true }; + }; + + fixture.game.examineEntity = (entity: Entity) => { + const distanceError = ComponentSystem.getInteractionDistanceError( + entity as any, + fixture.scene.player + ); + if (distanceError && !fixture.game.inventory.includes(entity)) { + return { + status: 'failed', + code: 'too_far_to_examine', + message: distanceError, + recoverable: true, + }; + } + const details = fixture.textAssets.getResolvedObjectField(entity, 'details'); + if (details?.trim()) { + return okOutcome('entity_details', details, { entityId: entity.name }); + } + const description = + fixture.textAssets.getResolvedObjectField(entity, 'description') || entity.description; + if (description?.trim()) { + return okOutcome('entity_description_fallback', description, { entityId: entity.name }); + } + return { status: 'escalate', code: 'missing_details', recoverable: true }; + }; + + fixture.game.takeEntity = (entity: Entity) => { + const error = ComponentSystem.canTakeItem(entity as any, fixture.scene.player); + if (error) { + return { status: 'failed', code: 'cannot_take', message: error, recoverable: true }; + } + fixture.scene.removeEntity(entity); + fixture.game.inventory.push(entity); + const title = fixture.textAssets.getResolvedObjectField(entity, 'title') || entity.name; + return okOutcome('item_taken', fixture.game.text('parser.take_pickup_success', { item: title }), { + entityId: entity.name, + }); + }; + + fixture.game.removeInventoryEntity = (entity: Entity) => { + const index = fixture.game.inventory.indexOf(entity); + if (index === -1) { + return { status: 'failed', code: 'inventory_item_not_found', recoverable: true }; + } + fixture.game.inventory.splice(index, 1); + return okOutcome('inventory_item_removed', undefined, { entityId: entity.name }); + }; + + fixture.game.showInventory = () => { + const items = fixture.game.inventory + .map((entity) => fixture.textAssets.getResolvedObjectField(entity, 'title')) + .filter((title): title is string => !!title); + if (!items.length) { + return okOutcome('inventory_list', fixture.game.text('parser.inventory_empty')); + } + return okOutcome( + 'inventory_list', + fixture.game.text('parser.inventory_items', { items: items.join(', ') }) + ); + }; + + fixture.game.goToSceneTarget = (target: string) => { + const normalized = String(target || '').trim().toUpperCase(); + for (const descriptor of fixture.game.sceneManager.sceneRegistry.values()) { + if ( + descriptor.id.toUpperCase() === normalized || + descriptor.name.toUpperCase() === normalized || + (!!descriptor.title && descriptor.title.toUpperCase() === normalized) + ) { + return fixture.game.goToScene(descriptor.id); + } + } + return { status: 'failed', code: 'destination_not_found', recoverable: true }; + }; + + fixture.game.goToScene = (sceneId: string) => { + const scene = fixture.game.sceneManager.scenes.get(sceneId); + if (!scene) { + return { status: 'failed', code: 'destination_not_found', recoverable: true }; + } + fixture.game.sceneManager.currentScene = scene; + return okOutcome('scene_switched', scene.description, { sceneId }); + }; + + fixture.game.goToEntity = (entity: Entity) => { + fixture.scene.player?.moveTo(entity.x, entity.y); + const title = fixture.textAssets.getResolvedObjectField(entity, 'title') || entity.name; + return okOutcome('player_moving', fixture.game.text('parser.go_to_success', { target: title }), { + entityId: entity.name, + }); + }; + + fixture.game.describeSpatialRelation = (anchorNodeId, relation) => { + const anchorNode = fixture.scene.getSpatialNode(anchorNodeId); + const anchorTitle = anchorNode?.title?.trim(); + if (!anchorTitle) { + return { status: 'escalate', code: 'spatial_node_missing_title', recoverable: true }; + } + const childTitles = fixture.scene + .getDirectSpatialChildren(anchorNodeId, relation) + .map((child) => fixture.textAssets.getResolvedObjectField(child, 'title')) + .filter((title): title is string => !!title); + if (!childTitles.length) { + return okOutcome( + 'relation_empty', + fixture.game.text('parser.relation_empty', { relation, target: anchorTitle }) + ); + } + return okOutcome( + 'relation_contents', + fixture.game.text('parser.relation_contents', { + Relation: relation.charAt(0).toUpperCase() + relation.slice(1), + relation, + target: anchorTitle, + items: childTitles.join(', '), + }) + ); + }; + + const parser = new Parser(fixture.game); + + return { + ...fixture, + parser, + async run(input: string) { + fixture.messages.length = 0; + fixture.logs.length = 0; + await parser.parse(input); + return { + messages: [...fixture.messages], + logs: [...fixture.logs], + pendingIntent: parser.pendingState?.intent || null, + }; + }, + }; +} diff --git a/tests/fixtures/sceneFactory.ts b/tests/fixtures/sceneFactory.ts new file mode 100644 index 0000000..4ef698e --- /dev/null +++ b/tests/fixtures/sceneFactory.ts @@ -0,0 +1,107 @@ +import { Entity } from '../../src/entities/Entity'; +import { Actor } from '../../src/entities/Actor'; +import { Triggerbox } from '../../src/entities/Triggerbox'; +import { Walkbox } from '../../src/entities/Walkbox'; +import { Scene } from '../../src/scene/Scene'; +import type { SpatialPlacement, SpatialRelationType } from '../../src/scene/spatialTypes'; +import { createTestGame, type TestGameHarness } from './gameFactory'; + +type EntityOptions = { + title?: string; + description?: string; + disabled?: boolean; + groupID?: string | null; + components?: any[]; + spatial?: SpatialPlacement; +}; + +type TriggerboxOptions = { + disabled?: boolean; + groupID?: string | null; + components?: any[]; + spatial?: SpatialPlacement; +}; + +export type SceneFixture = TestGameHarness & { + scene: Scene; + addEntity(name: string, options?: EntityOptions): Entity; + addPlayer(name?: string, x?: number, y?: number): Actor; + addTriggerbox(name: string, options?: TriggerboxOptions): Triggerbox; + addWalkbox(name: string, relation?: SpatialRelationType): Walkbox; +}; + +const DEFAULT_POLY = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, +]; + +export function createSceneFixture(sceneId: string = 'test_scene'): SceneFixture { + const harness = createTestGame(); + const scene = new Scene(harness.game, sceneId, 'Test Scene'); + harness.game.sceneManager.currentScene = scene; + harness.game.sceneManager.scenes.set(sceneId, scene); + harness.game.sceneManager.sceneRegistry.set(sceneId, { + id: sceneId, + path: `${sceneId}.json`, + name: scene.name, + title: scene.name, + sourceData: null, + lastIndexed: Date.now(), + }); + harness.textAssets.setScene(scene.id, { + title: scene.name, + description: scene.description, + }); + + return { + ...harness, + scene, + addEntity(name, options = {}) { + const entity = new Entity(harness.game, 0, 0, 10, 10, name); + entity.description = options.description || `Description for ${name}`; + entity.disabled = options.disabled ?? false; + entity.groupID = options.groupID ?? null; + entity.components = options.components || []; + entity.spatial = options.spatial || {}; + scene.addEntity(entity); + harness.textAssets.setObject(name, { + title: options.title || name, + description: entity.description, + }); + return entity; + }, + addPlayer(name = 'Hero', x = 0, y = 0) { + const player = new Actor(harness.game, x, y, 10, 10, name); + player.isPlayer = true; + scene.addEntity(player); + harness.textAssets.setObject(name, { + title: name, + description: `${name} player`, + }); + return player; + }, + addTriggerbox(name, options = {}) { + const triggerbox = new Triggerbox(DEFAULT_POLY, name, ''); + triggerbox.disabled = options.disabled ?? false; + triggerbox.groupID = options.groupID ?? null; + triggerbox.components = options.components || []; + triggerbox.spatial = options.spatial || {}; + scene.triggerboxes.push(triggerbox); + harness.textAssets.setObject(name, { + title: name, + description: `${name} triggerbox`, + }); + return triggerbox; + }, + addWalkbox(name, relation) { + const walkbox = new Walkbox(DEFAULT_POLY, name); + if (relation) { + walkbox.spatial = { parentNodeId: scene.id, relation }; + } + scene.walkbox.push(walkbox); + return walkbox; + }, + }; +} diff --git a/tests/fixtures/textAssetFactory.ts b/tests/fixtures/textAssetFactory.ts new file mode 100644 index 0000000..43c4860 --- /dev/null +++ b/tests/fixtures/textAssetFactory.ts @@ -0,0 +1,229 @@ +import type { Scene } from '../../src/scene/Scene'; +import type { SceneObject } from '../../src/entities/SceneObject'; +import type { + ObjectTextAssetData, + SceneTextAssetData, +} from '../../src/core/TextAssetManager'; +import type { ParserLexiconAsset, ParserTrainingAsset } from '../../src/mechanics/parserLanguage'; +import type { ParserCommandSpec } from '../../src/mechanics/parserTypes'; + +type TextAssetLike = { + getResolvedObjectField(obj: SceneObject, field: string): string | null; + getResolvedObjectListField(obj: SceneObject, field: string): string[]; + getResolvedSceneField(scene: Scene, field: string): string | null; + getServiceText(key: string, params?: Record<string, string | number>): string; + getParserLexicon(): ParserLexiconAsset; + getParserTraining(): ParserTrainingAsset; + getParserCommands(): ParserCommandSpec[]; + readParserTrainingAsset(): Promise<ParserTrainingAsset>; +}; + +export type TestTextAssets = TextAssetLike & { + setObject(id: string, data: ObjectTextAssetData): void; + setScene(id: string, data: SceneTextAssetData): void; + setParserCommands(commands: ParserCommandSpec[]): void; +}; + +const DEFAULT_SERVICE_TEXT: Record<string, string> = { + 'engine.too_far_generic': 'You are too far away.', + 'engine.too_far_from_entity': 'You are too far away from the {target}.', + 'engine.locked_needs': 'Locked. Needs {item}', + 'engine.locked_generic': 'Locked.', + 'parser.look_default_scene': 'You are in {scene}.', + 'parser.look_default_object': 'You see nothing special about the {target}.', + 'parser.look_not_found': "You don't see any {target} here.", + 'parser.look_which_one': 'Which one do you mean: {options}?', + 'parser.examine_prompt': 'Examine what?', + 'parser.examine_which_one': 'Which one do you want to examine: {options}?', + 'parser.take_prompt': 'Take what?', + 'parser.take_which_one': 'Which item do you mean: {options}?', + 'parser.take_pickup_success': 'You picked up the {item}.', + 'parser.take_cannot': 'You cannot take that.', + 'parser.inventory_empty': 'You are not carrying anything.', + 'parser.inventory_items': 'You are carrying: {items}', + 'parser.go_to_prompt': 'Where do you want to go?', + 'parser.go_to_which_one': 'Where exactly do you want to go: {options}?', + 'parser.go_to_not_found': "You can't get to {target} from here.", + 'parser.go_to_success': 'You go to {target}.', + 'parser.command_no_effect': "That doesn't work.", + '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}.', +}; + +const DEFAULT_PARSER_LEXICON: ParserLexiconAsset = { + stage1Aliases: { + look: ['look'], + examine: ['examine', 'inspect', 'check', 'x'], + take: ['take', 'get', 'pickup', 'pick up'], + goTo: ['go', 'walk', 'move'], + showInventory: ['inventory', 'inv'], + }, + normalizationPrefixes: { + look: ['look at', 'look', 'tell me about', 'what is that', 'what is', 'describe'], + examine: ['look closely at', 'take a closer look at', 'examine', 'inspect', 'check'], + take: ['pick up', 'take', 'get', 'grab'], + goTo: ['go to', 'walk to', 'move to', 'go', 'walk', 'move'], + showInventory: [], + }, + politePrefixes: ['please', 'i want to', "i'd like to", 'i would like to'], + articles: ['the', 'a', 'an', 'my'], + lookSceneWords: ['around', 'here', 'scene'], + relationMarkers: { + on: ['on'], + under: ['under', 'beneath'], + in: ['in', 'inside'], + behind: ['behind'], + near: ['near', 'next to', 'by'], + }, +}; + +const DEFAULT_PARSER_TRAINING: ParserTrainingAsset = { + look: ['look chair', 'look at the chair', 'describe the chair'], + examine: ['examine chair', 'inspect the chair', 'check the card'], + take: ['take key', 'pick up key'], + goTo: ['go to office', 'walk office'], + showInventory: ['inventory', 'show inventory', 'what do i have'], +}; + +const DEFAULT_PARSER_COMMANDS: ParserCommandSpec[] = [ + { + id: 'teleport_with', + phrases: ['teleport with', 'teleport'], + arguments: [ + { + name: 'item', + kind: 'entity', + required: true, + scopes: ['held', 'takable'], + messages: { + missing: 'Teleport with what?', + ambiguous: 'Which item do you want to teleport with: {options}?', + notFound: "You don't have anything like that.", + noEffect: "That doesn't work.", + }, + validation: { + allowedTitles: ['your ID card'], + }, + }, + ], + plan: [ + { type: 'resolveArgumentEntity', arg: 'item', saveAs: 'teleport_item' }, + { type: 'ensureHeldEntity', ref: 'teleport_item', noEffectMessageId: 'no_effect' }, + { type: 'goToSceneById', sceneId: 'test1' }, + { type: 'removeInventoryEntity', ref: 'teleport_item' }, + { type: 'showText', messageId: 'success' }, + ], + messages: { + success: 'You vanish in a flash and arrive somewhere else.', + }, + }, + { + id: 'use_on', + phrases: ['use'], + arguments: [ + { + name: 'item', + kind: 'entity', + required: true, + scopes: ['held', 'reachable'], + messages: { + missing: 'Use what on what?', + ambiguous: 'Which item do you mean: {options}?', + notFound: "You don't see anything like that here.", + noEffect: "That doesn't work.", + }, + }, + { + name: 'target', + kind: 'entity', + required: true, + scopes: ['held', 'reachable'], + separatorsBefore: ['on', 'with'], + messages: { + missing: 'Use it on what?', + ambiguous: 'Which target do you mean: {options}?', + notFound: "You don't see anything like that here.", + noEffect: "That doesn't work.", + }, + }, + ], + plan: [ + { type: 'resolveArgumentEntity', arg: 'item', saveAs: 'use_item' }, + { type: 'resolveArgumentEntity', arg: 'target', saveAs: 'use_target' }, + { + type: 'showText', + messageId: 'no_effect_pair', + paramsFromRefs: { + item: 'use_item', + target: 'use_target', + }, + }, + ], + messages: { + no_effect_pair: 'Using the {item} on the {target} does nothing.', + }, + }, +]; + +function interpolate(template: string, params?: Record<string, string | number>): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_match, token: string) => { + const value = params[token]; + return value === undefined || value === null ? `{${token}}` : String(value); + }); +} + +export function createTestTextAssets(): TestTextAssets { + const objectAssets = new Map<string, ObjectTextAssetData>(); + const sceneAssets = new Map<string, SceneTextAssetData>(); + let parserCommands = structuredClone(DEFAULT_PARSER_COMMANDS); + + return { + setObject(id, data) { + objectAssets.set(String(id), data); + }, + setScene(id, data) { + sceneAssets.set(String(id), data); + }, + getResolvedObjectField(obj, field) { + const asset = objectAssets.get(obj.name); + const value = asset?.[field]; + if (typeof value === 'string') return value; + if (field === 'description' && typeof (obj as { description?: unknown }).description === 'string') { + return (obj as { description?: string }).description || null; + } + return null; + }, + getResolvedObjectListField(obj, field) { + const asset = objectAssets.get(obj.name); + const value = asset?.[field]; + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + }, + getResolvedSceneField(scene, field) { + const asset = sceneAssets.get(scene.id); + const value = asset?.[field]; + if (typeof value === 'string') return value; + if (field === 'description' && typeof scene.description === 'string') return scene.description || null; + return null; + }, + getParserLexicon() { + return structuredClone(DEFAULT_PARSER_LEXICON); + }, + getParserTraining() { + return structuredClone(DEFAULT_PARSER_TRAINING); + }, + getParserCommands() { + return structuredClone(parserCommands); + }, + async readParserTrainingAsset() { + return structuredClone(DEFAULT_PARSER_TRAINING); + }, + setParserCommands(commands) { + parserCommands = structuredClone(commands); + }, + getServiceText(key, params) { + return interpolate(DEFAULT_SERVICE_TEXT[key] || key, params); + }, + }; +} diff --git a/tests/integration/parser-game.test.ts b/tests/integration/parser-game.test.ts new file mode 100644 index 0000000..c8fffcb --- /dev/null +++ b/tests/integration/parser-game.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { createParserFixture } from '../fixtures/parserFactory'; + +describe('Parser + game integration smoke', () => { + it('describes direct spatial contents with LOOK UNDER', 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' }, + }); + + const result = await fixture.run('look under chair'); + + expect(result.messages.at(-1)).toBe('Under the Chair you see: Piece of paper.'); + }); + + it('surfaces the distance error for a far but visible EXAMINE target', async () => { + const fixture = createParserFixture(); + fixture.addPlayer('Hero', 0, 0); + const boombox = fixture.addEntity('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + details: 'A detailed boombox description.', + } as any); + boombox.x = 200; + fixture.textAssets.setObject('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + details: 'A detailed boombox description.', + synonyms: ['recorder'], + }); + + const result = await fixture.run('examine boombox'); + + expect(result.messages.at(-1)).toBe('You are too far away from the Boombox.'); + }); +}); diff --git a/tests/parser/commands.test.ts b/tests/parser/commands.test.ts new file mode 100644 index 0000000..2198cfb --- /dev/null +++ b/tests/parser/commands.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { createParserFixture } from '../fixtures/parserFactory'; + +describe('Parser custom commands', () => { + it('prompts when TELEPORT is missing its item', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + + const result = await fixture.run('teleport'); + + expect(result.messages.at(-1)).toBe('Teleport with what?'); + expect(result.pendingIntent).toBe('custom'); + }); + + it('teleports with the allowed ID card and consumes it', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const yourId = fixture.addEntity('miles_id', { + title: 'your ID card', + description: 'Your card.', + components: [{ type: 'Item', ignoreDistance: true }], + }); + 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'); + + expect(result.messages.at(-1)).toBe('You vanish in a flash and arrive somewhere else.'); + expect(fixture.game.inventory).not.toContain(yourId); + }); + + it('rejects TELEPORT with the wrong matching item', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('wrong_id', { + title: 'Someone ID card', + description: 'Wrong card.', + components: [{ type: 'Item', ignoreDistance: true }], + }); + 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'); + + expect(result.messages.at(-1)).toBe("That doesn't work."); + }); + + it('parses USE X ON Y and renders the no-effect pair message', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const idCard = fixture.addEntity('test_id', { + title: 'Someone ID card', + description: 'Card.', + components: [{ type: 'Item', ignoreDistance: true }], + }); + fixture.scene.removeEntity(idCard); + fixture.game.inventory.push(idCard); + fixture.addEntity('boombox', { + title: 'Boombox', + description: 'Recorder.', + }); + + const result = await fixture.run('use id on boombox'); + + expect(result.messages.at(-1)).toBe('Using the Someone ID card on the Boombox does nothing.'); + }); + + it('prompts when USE is missing required arguments', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + + const result = await fixture.run('use'); + + expect(result.messages.at(-1)).toBe('Use what on what?'); + expect(result.pendingIntent).toBe('custom'); + }); +}); diff --git a/tests/parser/core.test.ts b/tests/parser/core.test.ts new file mode 100644 index 0000000..5ac2d67 --- /dev/null +++ b/tests/parser/core.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { createParserFixture } from '../fixtures/parserFactory'; + +describe('Parser core contracts', () => { + it('returns the generic unknown response on pre-API handoff when stage2 is disabled', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + + const result = await fixture.run('sing a song'); + + expect(result.messages.at(-1)).toBe("I don't understand."); + }); + + it('returns the generic unknown response on post-API escalation', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const mystery = fixture.addEntity('mystery_box', { + title: 'Mystery Box', + }); + mystery.description = ''; + fixture.textAssets.setObject('mystery_box', { + title: 'Mystery Box', + }); + + const result = await fixture.run('examine mystery'); + + expect(result.messages.at(-1)).toBe("I don't understand."); + }); + + it('stops a linear plan after a failed validation step', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('wrong_id', { + title: 'Someone ID card', + description: 'Wrong card.', + components: [{ type: 'Item', ignoreDistance: true }], + }); + 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'); + + expect(result.messages.at(-1)).toBe("That doesn't work."); + expect(fixture.game.inventory).toHaveLength(0); + expect(fixture.game.sceneManager.currentScene).toBe(fixture.scene); + }); +}); diff --git a/tests/parser/resolution.test.ts b/tests/parser/resolution.test.ts new file mode 100644 index 0000000..3e08fcb --- /dev/null +++ b/tests/parser/resolution.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { createParserFixture } from '../fixtures/parserFactory'; + +describe('Parser resolution', () => { + it('matches an entity by synonym', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const boombox = fixture.addEntity('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + }); + fixture.textAssets.setObject(boombox.name, { + title: 'Boombox', + description: 'Cassette recorder.', + synonyms: ['recorder', 'radio', 'GF-7'], + }); + + const result = await fixture.run('look recorder'); + + expect(result.messages.at(-1)).toBe('Cassette recorder.'); + }); + + it('matches an entity by partial title', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + }); + + const result = await fixture.run('look boom'); + + expect(result.messages.at(-1)).toBe('Cassette recorder.'); + }); + + it('asks for clarification when multiple distinct targets match', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addEntity('your_id', { + title: 'your ID card', + description: 'Your card.', + }); + fixture.addEntity('other_id', { + title: 'Someone ID card', + description: 'Another card.', + }); + + const result = await fixture.run('look id'); + + expect(result.messages.at(-1)).toBe('Which one do you mean: your ID card, Someone ID card?'); + expect(result.pendingIntent).toBe('look'); + }); + + it('prefers the inventory copy when duplicate titles are indistinguishable', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + const inventoryCoin = fixture.addEntity('coin_inventory', { + title: 'Coin', + description: 'Inventory coin.', + components: [{ type: 'Item' }], + }); + const sceneCoin = fixture.addEntity('coin_scene', { + title: 'Coin', + description: 'Scene coin.', + components: [{ type: 'Item' }], + }); + fixture.scene.removeEntity(inventoryCoin); + fixture.game.inventory.push(inventoryCoin); + + const result = await fixture.run('look coin'); + + expect(result.messages.at(-1)).toBe('Inventory coin.'); + expect(fixture.game.inventory).toContain(inventoryCoin); + expect(fixture.scene.entities).toContain(sceneCoin); + }); + + it('prefers the nearest scene object when duplicate titles are both in scene', async () => { + const fixture = createParserFixture(); + fixture.addPlayer('Hero', 0, 0); + const nearCoin = fixture.addEntity('near_coin', { + title: 'Coin', + description: 'Near coin.', + components: [{ type: 'Item' }], + }); + nearCoin.x = 5; + const farCoin = fixture.addEntity('far_coin', { + title: 'Coin', + description: 'Far coin.', + components: [{ type: 'Item' }], + }); + farCoin.x = 80; + + const result = await fixture.run('look coin'); + + expect(result.messages.at(-1)).toBe('Near coin.'); + }); +}); diff --git a/tests/scene/spatial-index.test.ts b/tests/scene/spatial-index.test.ts new file mode 100644 index 0000000..e5e360b --- /dev/null +++ b/tests/scene/spatial-index.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +describe('Scene spatial index', () => { + it('groups direct children by parent and relation', () => { + const fixture = createSceneFixture(); + fixture.addEntity('Table', { title: 'Table' }); + fixture.addEntity('Key', { + title: 'Key', + spatial: { parentNodeId: 'Table', relation: 'under' }, + }); + fixture.addEntity('Note', { + title: 'Note', + spatial: { parentNodeId: 'Table', relation: 'on' }, + }); + + const index = fixture.scene.getSpatialIndex(); + + expect(index.childrenByParentId.get('Table')?.map((node) => node.id)).toEqual(['Key', 'Note']); + expect(index.childrenByParentAndRelation.get('Table')?.get('under')?.map((node) => node.id)).toEqual(['Key']); + expect(index.childrenByParentAndRelation.get('Table')?.get('on')?.map((node) => node.id)).toEqual(['Note']); + }); + + it('returns only direct children from the helper', () => { + const fixture = createSceneFixture(); + fixture.addEntity('Desk', { title: 'Desk' }); + fixture.addEntity('Drawer', { + title: 'Drawer', + spatial: { parentNodeId: 'Desk', relation: 'in' }, + }); + fixture.addEntity('Paper', { + title: 'Paper', + spatial: { parentNodeId: 'Drawer', relation: 'in' }, + }); + + const directChildren = fixture.scene.getDirectSpatialChildren('Desk'); + + expect(directChildren.map((child) => child.name)).toEqual(['Drawer']); + }); + + it('treats legacy null relation with a parent as "in"', () => { + const fixture = createSceneFixture(); + fixture.addEntity('Cabinet', { title: 'Cabinet' }); + fixture.addEntity('Folder', { + title: 'Folder', + spatial: { parentNodeId: 'Cabinet', relation: null }, + }); + + const index = fixture.scene.getSpatialIndex(); + + expect(index.childrenByParentAndRelation.get('Cabinet')?.get('in')?.map((node) => node.id)).toEqual(['Folder']); + }); +}); diff --git a/tests/scene/subscene-activation.test.ts b/tests/scene/subscene-activation.test.ts new file mode 100644 index 0000000..efc9f01 --- /dev/null +++ b/tests/scene/subscene-activation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { ComponentSystem } from '../../src/systems/ComponentSystem'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +describe('Subscene activation', () => { + it('enables direct spatial children and leaves grandchildren disabled', () => { + const fixture = createSceneFixture(); + const rootSubscene = fixture.addTriggerbox('Trig_A', { + components: [{ type: 'Subscene', targetGroupId: '' }], + }); + const directEntity = fixture.addEntity('Lamp', { + disabled: true, + spatial: { parentNodeId: 'Trig_A', relation: 'in' }, + }); + const nestedSubscene = fixture.addTriggerbox('Trig_B', { + disabled: true, + components: [{ type: 'Subscene', targetGroupId: '' }], + spatial: { parentNodeId: 'Trig_A', relation: 'in' }, + }); + const grandchild = fixture.addEntity('HiddenNote', { + disabled: true, + spatial: { parentNodeId: 'Trig_B', relation: 'in' }, + }); + + const handled = ComponentSystem.handleActivation(rootSubscene, fixture.scene); + + expect(handled).toBe(true); + expect(fixture.scene.activeSubscene).toBe('Trig_A'); + expect(directEntity.disabled).toBe(false); + expect(nestedSubscene.disabled).toBe(false); + expect(grandchild.disabled).toBe(true); + expect([...fixture.scene.subsceneEntities].map((item) => item.name).sort()).toEqual(['Lamp', 'Trig_B']); + }); + + it('still includes group targets together with direct spatial children', () => { + const fixture = createSceneFixture(); + const rootSubscene = fixture.addTriggerbox('Trig_A', { + components: [{ type: 'Subscene', targetGroupId: '#A' }], + }); + const groupEntity = fixture.addEntity('ByGroup', { + disabled: true, + groupID: '#A', + }); + const spatialEntity = fixture.addEntity('BySpatial', { + disabled: true, + spatial: { parentNodeId: 'Trig_A', relation: 'in' }, + }); + + ComponentSystem.handleActivation(rootSubscene, fixture.scene); + + expect(groupEntity.disabled).toBe(false); + expect(spatialEntity.disabled).toBe(false); + expect([...fixture.scene.subsceneEntities].map((item) => item.name).sort()).toEqual([ + 'ByGroup', + 'BySpatial', + ]); + }); +}); diff --git a/tests/scene/subscene-cleanup.test.ts b/tests/scene/subscene-cleanup.test.ts new file mode 100644 index 0000000..44d2f23 --- /dev/null +++ b/tests/scene/subscene-cleanup.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { ComponentSystem } from '../../src/systems/ComponentSystem'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +describe('Subscene cleanup', () => { + it('resets switches included via spatial hierarchy when the subscene closes', () => { + const fixture = createSceneFixture(); + const rootSubscene = fixture.addTriggerbox('Trig_A', { + components: [{ type: 'Subscene', targetGroupId: '' }], + }); + const spatialSwitch = fixture.addEntity('SwitchEntity', { + disabled: true, + spatial: { parentNodeId: 'Trig_A', relation: 'in' }, + components: [{ type: 'Switch', state: 2, sound1: 'switch-close' }], + }); + + ComponentSystem.handleActivation(rootSubscene, fixture.scene); + fixture.scene.activeSubscene = null; + + expect((spatialSwitch.components[0] as { state: number }).state).toBe(1); + expect(fixture.sounds).toEqual(['switch-close']); + expect(spatialSwitch.disabled).toBe(true); + expect(fixture.scene.subsceneEntities.size).toBe(0); + }); + + it('resets switches included via targetGroupId on close as well', () => { + const fixture = createSceneFixture(); + const rootSubscene = fixture.addTriggerbox('Trig_A', { + components: [{ type: 'Subscene', targetGroupId: '#A' }], + }); + const groupSwitch = fixture.addEntity('GroupSwitch', { + disabled: true, + groupID: '#A', + components: [{ type: 'Switch', state: 2, sound1: 'group-close' }], + }); + + ComponentSystem.handleActivation(rootSubscene, fixture.scene); + fixture.scene.activeSubscene = null; + + expect((groupSwitch.components[0] as { state: number }).state).toBe(1); + expect(fixture.sounds).toEqual(['group-close']); + expect(groupSwitch.disabled).toBe(true); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..424ff97 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + reporters: 'default', + }, +}); From 7476c8a225537a868be0988082b4b09fb7eec5e6 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Wed, 18 Mar 2026 23:43:41 +0200 Subject: [PATCH 36/75] Test: add Game semantic API coverage --- Autotests.md | 59 +++++++++++++- tasks.md | 25 ++++++ tests/fixtures/gameSemanticFactory.ts | 59 ++++++++++++++ tests/game/navigation-and-spatial.test.ts | 66 ++++++++++++++++ tests/game/semantic-api.test.ts | 94 +++++++++++++++++++++++ 5 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/gameSemanticFactory.ts create mode 100644 tests/game/navigation-and-spatial.test.ts create mode 100644 tests/game/semantic-api.test.ts diff --git a/Autotests.md b/Autotests.md index 3706a5f..831d99b 100644 --- a/Autotests.md +++ b/Autotests.md @@ -7,6 +7,7 @@ This document describes the current automated test setup on the `autotests` bran The first iteration is intentionally narrow: - deterministic parser behavior; - parser core contracts; +- direct `Game` semantic API behavior; - scene runtime behavior around spatial hierarchy and subscenes; - one thin parser + game integration layer. @@ -52,10 +53,14 @@ Out of scope for this iteration: ```text tests/ fixtures/ + gameSemanticFactory.ts gameFactory.ts parserFactory.ts sceneFactory.ts textAssetFactory.ts + game/ + navigation-and-spatial.test.ts + semantic-api.test.ts parser/ commands.test.ts core.test.ts @@ -96,6 +101,25 @@ Provides a minimal `IGame`-compatible harness: This is the base semantic harness used by scene and parser tests. +### `tests/fixtures/gameSemanticFactory.ts` + +Builds on top of `gameFactory.ts` and exposes the real `Game` semantic API methods through `Game.prototype`, while still avoiding full `Game` construction and UI bootstrap. + +This fixture exists specifically for direct `Game`-layer contract tests. + +Use it when the goal is to test: +- `lookScene` +- `lookEntity` +- `examineEntity` +- `showInventory` +- `removeInventoryEntity` +- `goToSceneTarget` +- `goToScene` +- `goToEntity` +- `describeSpatialRelation` + +without pulling parser behavior into the assertion. + ### `tests/fixtures/sceneFactory.ts` Builds a tiny `Scene` on top of the test game harness. @@ -202,6 +226,29 @@ Covers: - linear plan stopping after failure; - core behavior independent of UI. +### Game + +#### `tests/game/semantic-api.test.ts` + +Covers: +- `lookScene`; +- `lookEntity`; +- `examineEntity`; +- `showInventory`; +- `removeInventoryEntity`. + +This layer verifies `Game` as the shared semantic gameplay API, separate from parser parsing. + +#### `tests/game/navigation-and-spatial.test.ts` + +Covers: +- `goToSceneTarget`; +- `goToScene`; +- `goToEntity`; +- `describeSpatialRelation`. + +This layer is especially useful for validating the shared boundary between parser and world/game semantics. + ### Thin Integration #### `tests/integration/parser-game.test.ts` @@ -311,6 +358,7 @@ They are not necessary for the first iteration. - No direct tests for console preprocessor behavior yet. - No LLM-stage tests yet. - Parser NLP stage is not the focus of the current suite. +- The direct `Game` tests use a semantic fixture layered on `Game.prototype`, not full `Game` construction. ## Recommended Next Iteration @@ -328,9 +376,16 @@ The next useful expansions would be: - more plan-state transitions; - more validation branches. -3. Add tiny serialization/load fixtures if scene loading itself needs coverage. +3. Add tests for console/preprocessor behavior: + - `I` + - `X` + - `L` + - `#STAGE1-ON/OFF` + - `#STAGE2-ON/OFF` + +4. Add tiny serialization/load fixtures if scene loading itself needs coverage. -4. Add a very small browser smoke layer only if a runtime contract cannot be tested elsewhere. +5. Add a very small browser smoke layer only if a runtime contract cannot be tested elsewhere. ## Practical Rule diff --git a/tasks.md b/tasks.md index 2591a25..3b6e299 100644 --- a/tasks.md +++ b/tasks.md @@ -125,6 +125,29 @@ Avoid starting with canvas/UI/browser assertions. 4. [x] Implement parser command/resolution tests. 5. [x] Add one thin integration test file. +## Next Iteration Candidate: Game Semantic API Tests + +- [x] Add `tests/game/semantic-api.test.ts` + Cover: + - `lookScene`; + - `lookEntity`; + - `examineEntity`; + - `showInventory`; + - `removeInventoryEntity`. + +- [x] Add `tests/game/navigation-and-spatial.test.ts` + Cover: + - `goToSceneTarget`; + - `goToScene`; + - `goToEntity`; + - `describeSpatialRelation`. + +- [x] Decide whether the current fixture `gameFactory.ts` is sufficient for direct `Game`-layer tests + or if a dedicated semantic `Game` harness should be introduced. + Result: + - keep `gameFactory.ts` as the minimal base harness; + - add `tests/fixtures/gameSemanticFactory.ts` for direct `Game` API tests through `Game.prototype`. + ## Success Criteria For Iteration 1 - [x] `npm run test` works locally. @@ -148,3 +171,5 @@ Avoid starting with canvas/UI/browser assertions. - parser resolution, commands, and core tests are green; - one thin integration smoke file is green; - current status: first autotest iteration is functionally complete. + - next logical slice: direct tests for `Game` as the shared semantic gameplay API. + - `Game` semantic API tests are now green as well. diff --git a/tests/fixtures/gameSemanticFactory.ts b/tests/fixtures/gameSemanticFactory.ts new file mode 100644 index 0000000..4442578 --- /dev/null +++ b/tests/fixtures/gameSemanticFactory.ts @@ -0,0 +1,59 @@ +import { Game } from '../../src/core/Game'; +import { Scene } from '../../src/scene/Scene'; +import { createSceneFixture, type SceneFixture } from './sceneFactory'; + +export type GameSemanticFixture = SceneFixture & { + addScene(id: string, name?: string, description?: string): Scene; +}; + +export function createGameSemanticFixture(sceneId: string = 'test_scene'): GameSemanticFixture { + const fixture = createSceneFixture(sceneId); + + Object.setPrototypeOf(fixture.game, Game.prototype); + for (const methodName of [ + 'lookScene', + 'lookEntity', + 'examineEntity', + 'showInventory', + 'removeInventoryEntity', + 'goToSceneTarget', + 'goToScene', + 'goToEntity', + 'describeSpatialRelation', + 'getSeeMessage', + ] as const) { + delete (fixture.game as Record<string, unknown>)[methodName]; + } + + fixture.game.sceneManager.switchTo = (id: string) => { + const scene = fixture.game.sceneManager.scenes.get(id); + if (scene) { + fixture.game.sceneManager.currentScene = scene; + if (fixture.game.onSceneChange) { + fixture.game.onSceneChange(scene.name); + } + } + }; + + return { + ...fixture, + addScene(id: string, name = 'Extra Scene', description = `You are in ${name}.`) { + const scene = new Scene(fixture.game, id, name); + scene.description = description; + fixture.game.sceneManager.scenes.set(id, scene); + fixture.game.sceneManager.sceneRegistry.set(id, { + id, + path: `${id}.json`, + name, + title: name, + sourceData: null, + lastIndexed: Date.now(), + }); + fixture.textAssets.setScene(id, { + title: name, + description, + }); + return scene; + }, + }; +} diff --git a/tests/game/navigation-and-spatial.test.ts b/tests/game/navigation-and-spatial.test.ts new file mode 100644 index 0000000..dc2d2ff --- /dev/null +++ b/tests/game/navigation-and-spatial.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { createGameSemanticFixture } from '../fixtures/gameSemanticFactory'; + +describe('Game navigation and spatial API', () => { + it('goToSceneTarget resolves scene by id and title', () => { + const fixture = createGameSemanticFixture('start'); + const target = fixture.addScene('test1', 'New Scene', 'You are in New Scene.'); + + const byId = fixture.game.goToSceneTarget('test1'); + expect(byId.status).toBe('ok'); + expect(fixture.game.sceneManager.currentScene).toBe(target); + + fixture.game.sceneManager.currentScene = fixture.scene; + + const byTitle = fixture.game.goToSceneTarget('New Scene'); + expect(byTitle.status).toBe('ok'); + expect(fixture.game.sceneManager.currentScene).toBe(target); + }); + + it('goToSceneTarget fails for an unknown destination', () => { + const fixture = createGameSemanticFixture(); + + const outcome = fixture.game.goToSceneTarget('nowhere'); + + expect(outcome.status).toBe('failed'); + expect(outcome.code).toBe('destination_not_found'); + }); + + it('goToEntity starts player movement and returns the player-facing title', () => { + const fixture = createGameSemanticFixture(); + const player = fixture.addPlayer('Hero', 0, 0); + const chair = fixture.addEntity('Chair', { + title: 'Chair', + description: 'A wooden chair.', + }); + chair.x = 42; + chair.y = 84; + + const outcome = fixture.game.goToEntity(chair); + + expect(outcome.status).toBe('ok'); + expect(outcome.message).toBe('You go to Chair.'); + expect(player.target).toEqual({ x: 42, y: 84 }); + }); + + it('describeSpatialRelation returns populated and empty relation messages', () => { + const fixture = createGameSemanticFixture(); + fixture.addEntity('Desk', { + title: 'Desk', + description: 'An office desk.', + }); + fixture.addEntity('note', { + title: 'Piece of paper', + description: 'A folded note.', + spatial: { parentNodeId: 'Desk', relation: 'in' }, + }); + + const populated = fixture.game.describeSpatialRelation('Desk', 'in'); + expect(populated.status).toBe('ok'); + expect(populated.message).toBe('In the Desk you see: Piece of paper.'); + + const empty = fixture.game.describeSpatialRelation('Desk', 'under'); + expect(empty.status).toBe('ok'); + expect(empty.message).toBe('You see nothing under the Desk.'); + }); +}); diff --git a/tests/game/semantic-api.test.ts b/tests/game/semantic-api.test.ts new file mode 100644 index 0000000..4a7ffe0 --- /dev/null +++ b/tests/game/semantic-api.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { createGameSemanticFixture } from '../fixtures/gameSemanticFactory'; + +describe('Game semantic API', () => { + it('lookScene returns the scene description', () => { + const fixture = createGameSemanticFixture(); + + const outcome = fixture.game.lookScene(); + + expect(outcome.status).toBe('ok'); + expect(outcome.message).toBe('You are in Test Scene.'); + }); + + it('lookEntity appends spatial parent context when present', () => { + const fixture = createGameSemanticFixture(); + fixture.addEntity('Table', { + title: 'Table', + description: 'A sturdy table.', + }); + const note = fixture.addEntity('note', { + title: 'Piece of paper', + description: 'A folded note.', + spatial: { parentNodeId: 'Table', relation: 'under' }, + }); + + const outcome = fixture.game.lookEntity(note); + + expect(outcome.status).toBe('ok'); + expect(outcome.message).toBe('A folded note. Under the Table you see: Piece of paper.'); + }); + + it('examineEntity prefers details and falls back to description', () => { + const fixture = createGameSemanticFixture(); + fixture.addPlayer(); + const boombox = fixture.addEntity('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + }); + fixture.textAssets.setObject('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + details: 'Detailed boombox text.', + }); + + const detailed = fixture.game.examineEntity(boombox); + + expect(detailed.status).toBe('ok'); + expect(detailed.message).toBe('Detailed boombox text.'); + + fixture.textAssets.setObject('boombox', { + title: 'Boombox', + description: 'Cassette recorder.', + }); + + const fallback = fixture.game.examineEntity(boombox); + + expect(fallback.status).toBe('ok'); + expect(fallback.message).toBe('Cassette recorder.'); + }); + + it('showInventory returns empty and filled inventory messages', () => { + const fixture = createGameSemanticFixture(); + const emptyOutcome = fixture.game.showInventory(); + expect(emptyOutcome.message).toBe('You are not carrying anything.'); + + const idCard = fixture.addEntity('miles_id', { + title: 'your ID card', + description: 'Your ID.', + }); + fixture.scene.removeEntity(idCard); + fixture.game.inventory.push(idCard); + + const filledOutcome = fixture.game.showInventory(); + expect(filledOutcome.message).toBe('You are carrying: your ID card'); + }); + + it('removeInventoryEntity succeeds only for held items', () => { + const fixture = createGameSemanticFixture(); + const idCard = fixture.addEntity('miles_id', { + title: 'your ID card', + description: 'Your ID.', + }); + fixture.scene.removeEntity(idCard); + fixture.game.inventory.push(idCard); + + const removed = fixture.game.removeInventoryEntity(idCard); + expect(removed.status).toBe('ok'); + expect(fixture.game.inventory).toHaveLength(0); + + const missing = fixture.game.removeInventoryEntity(idCard); + expect(missing.status).toBe('failed'); + expect(missing.code).toBe('inventory_item_not_found'); + }); +}); From 79b0423db8cd7fc8f47e382b39fa6e0e279c1ad2 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Thu, 19 Mar 2026 00:00:18 +0200 Subject: [PATCH 37/75] Docs: add project autotests guidance --- AGENTS.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3bbb85b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Project Instructions + +## Autotests Recall Rule + +When working on mechanics/runtime-related code or architecture-sensitive changes in: +- `src/mechanics` +- `src/scene` +- `src/systems` +- `src/core` + +and especially on: +- parser behavior; +- `Game` semantic API behavior; +- spatial hierarchy; +- subscene behavior; + +recall that this project has an autotest system on branch `autotests`. + +Before proceeding with substantial changes in those areas: +- remember that autotests may already cover the contract you are touching; +- consult memory for the current autotest workflow and coverage; +- use `Autotests.md` for the current developer-facing description of: + - when to run autotests; + - how to run them; + - what is currently covered; + - how fixtures and test harnesses are structured. + +## Autotests Maintenance Rule + +When making significant functional changes or adding important new behavior in mechanics/runtime code: +- check whether existing autotests still describe the intended behavior; +- update affected tests if the contract changed; +- add new tests when a new important gameplay/runtime/parser contract is introduced; +- update `Autotests.md` if the test system, fixtures, or coverage model changes in a meaningful way. From 982ca972b665b7dd9e18c6f41f0de7925fbaa7fe Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Fri, 20 Mar 2026 01:54:09 +0200 Subject: [PATCH 38/75] Feature: improve subscene text and triggerbox behavior --- public/scenes/test_room (10).json | 138 +++++++---- public/scenes/test_room.json | 297 ++++++++++++++++++----- public/text/objects/Desk.json | 6 + public/text/objects/Trig_sub_D.json | 6 + public/text/objects/test.json | 5 + public/text/scenes/quad4.json | 4 + public/text/scenes/test_room (10).json | 4 + src/core/Game.ts | 95 +++++--- src/core/IGame.ts | 4 +- src/core/TextAssetManager.ts | 19 +- src/entities/SceneObject.ts | 10 + src/entities/TriggerComponents.ts | 58 ++++- src/entities/Triggerbox.ts | 11 +- src/mechanics/Parser.ts | 111 +++++---- src/mechanics/ParserWorldModelBuilder.ts | 56 +++-- src/mechanics/parserTypes.ts | 9 +- src/systems/ComponentSystem.ts | 19 +- src/tools/SceneEditor.ts | 3 +- tests/fixtures/sceneFactory.ts | 8 +- tests/fixtures/textAssetFactory.ts | 1 + tests/parser/resolution.test.ts | 16 ++ tests/scene/scene-interaction.test.ts | 17 ++ 22 files changed, 671 insertions(+), 226 deletions(-) create mode 100644 public/text/objects/Desk.json create mode 100644 public/text/objects/Trig_sub_D.json create mode 100644 public/text/objects/test.json create mode 100644 public/text/scenes/quad4.json create mode 100644 public/text/scenes/test_room (10).json create mode 100644 tests/scene/scene-interaction.test.ts diff --git a/public/scenes/test_room (10).json b/public/scenes/test_room (10).json index 8256b5f..c27497b 100644 --- a/public/scenes/test_room (10).json +++ b/public/scenes/test_room (10).json @@ -17,7 +17,6 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, "poly": [ { "x": -79, @@ -50,7 +49,6 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, "poly": [ { "x": 82, @@ -117,13 +115,15 @@ "components": [ { "type": "Subscene", - "targetGroupId": "#D", - "name": "" + "targetGroupId": "#D" } ], - "layer": 0, + "layer": 1, "visible": true, - "spatial": {}, + "spatial": { + "parentNodeId": "Desk", + "relation": "in" + }, "poly": [ { "x": 27, @@ -218,7 +218,6 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, "poly": [], "script": "" }, @@ -244,7 +243,6 @@ ], "layer": 1, "visible": true, - "spatial": {}, "poly": [ { "x": -156.29411764705878, @@ -266,44 +264,93 @@ "script": "" }, { - "name": "Drawer3", + "name": "Desk", "type": "Triggerbox", "locked": false, - "disabled": true, + "disabled": false, "groupID": null, "customName": "", "textRedirects": {}, "interactions": {}, - "components": [ - { - "type": "Subscene", - "targetGroupId": "", - "title": "", - "description": "" - } - ], - "layer": 0, + "components": [], + "layer": 1, "visible": true, - "spatial": { - "parentNodeId": "Trig_sub_D", - "relation": "in" - }, "poly": [ { - "x": -106, - "y": 4 + "x": -210, + "y": 211 + }, + { + "x": -192, + "y": 213 + }, + { + "x": -192, + "y": 197 }, { - "x": 389, - "y": 3 + "x": -171, + "y": 189 + }, + { + "x": -159, + "y": 211 }, { - "x": 375, + "x": -136, + "y": 217 + }, + { + "x": -131, + "y": 242 + }, + { + "x": -109, + "y": 243 + }, + { + "x": -110, + "y": 146 + }, + { + "x": -18, + "y": 129 + }, + { + "x": -16, + "y": 201 + }, + { + "x": 26, + "y": 207 + }, + { + "x": 82, + "y": 193 + }, + { + "x": 87, "y": 95 }, { - "x": -107, - "y": 91 + "x": -70, + "y": 78 + }, + { + "x": -236, + "y": 115 + }, + { + "x": -235, + "y": 173 + }, + { + "x": -218, + "y": 182 + }, + { + "x": -212, + "y": 211 } ], "script": "" @@ -329,7 +376,6 @@ "components": [], "layer": -2, "visible": true, - "spatial": {}, "x": 119.67896209456934, "y": 234, "width": 821.6, @@ -361,7 +407,6 @@ "components": [], "layer": -1, "visible": true, - "spatial": {}, "x": 199, "y": 297, "width": 884.8, @@ -393,7 +438,6 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, "x": 100, "y": 249, "width": 116.89999999999999, @@ -425,20 +469,19 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, - "x": 251.49445909180304, - "y": 256.5381551800198, - "width": 69.81706443264875, - "height": 285.0863464333157, + "x": 396.14497334712604, + "y": 211.843824888123, + "width": 66.1903653250774, + "height": 270.2773250773994, "baseWidth": 96, "baseHeight": 392, "colliderWidth": 88, "colliderHeight": 4, "spriteName": "miles_ds-idle-right.json", "color": "#00ffff", - "scale": 0.7272610878400911, + "scale": 0.6894829721362229, "modelScale": 0.74, - "parallax": 1.046112265312777, + "parallax": 1.016927024539739, "ignoreScaling": false, "animationSpeed": 30, "opacity": 1, @@ -476,7 +519,6 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, "x": -328, "y": 307, "width": 171.2340644206598, @@ -508,7 +550,6 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, "x": 135, "y": 310, "width": 614.4, @@ -545,7 +586,6 @@ ], "layer": 3, "visible": true, - "spatial": {}, "x": 134, "y": 311, "width": 614.4, @@ -577,7 +617,6 @@ "components": [], "layer": 4, "visible": true, - "spatial": {}, "x": 136, "y": -4, "width": 614.4, @@ -684,7 +723,6 @@ "components": [], "layer": 6, "visible": true, - "spatial": {}, "x": 135, "y": -176, "width": 614.4, @@ -721,7 +759,6 @@ ], "layer": 6, "visible": true, - "spatial": {}, "x": 135, "y": 66, "width": 614.4, @@ -757,7 +794,6 @@ ], "layer": 0, "visible": true, - "spatial": {}, "x": -146.13725490196006, "y": 289.60784313725503, "parallax": 1, @@ -808,9 +844,8 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, "x": 222.90433731748038, - "y": 306.9284515529542, + "y": 306.9284515529541, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -844,7 +879,6 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, "x": -152, "y": -11, "width": 112, @@ -979,10 +1013,10 @@ "y": 29, "zoom": 0.51 }, - "autoCenter": true, + "autoCenter": false, "cameraSpeed": 1.5, "camDeadzoneX": 200, "camDeadzoneY": -21, "camMinX": 143, "camMaxY": 45 -} +} \ No newline at end of file diff --git a/public/scenes/test_room.json b/public/scenes/test_room.json index bc3afb8..35c6297 100644 --- a/public/scenes/test_room.json +++ b/public/scenes/test_room.json @@ -37,14 +37,24 @@ "ignoreScaling": false, "vertices": [ { - "x": -1431.0063050419365, - "y": 419.98108487419046, - "p": 0.2 + "x": -1449.024219762899, + "y": 432.42805173925444, + "p": 0.2, + "binding": { + "targetName": "Quad_646", + "type": "vertex", + "index": 3 + } }, { - "x": 1588.9936949580635, - "y": 415.98108487419046, - "p": 0.2 + "x": 1527.975780237101, + "y": 428.42805173925444, + "p": 0.2, + "binding": { + "targetName": "Quad_646", + "type": "vertex", + "index": 2 + } }, { "x": 3915.4050968588786, @@ -57,16 +67,16 @@ "p": 1 } ], - "color": "#888888", + "color": "#000219", "sortMode": "ignore", "opacity": 1, "blendMode": "source-over", "isGrid": true, "gridLinesX": 29, "gridLinesY": 7, - "lineWidth": 4, - "gridColor": "#0f6719", - "filled": false, + "lineWidth": 7.4, + "gridColor": "#150f67", + "filled": true, "blur": 0 }, { @@ -88,13 +98,18 @@ "ignoreScaling": false, "vertices": [ { - "x": -1382.5263247292278, - "y": -605.2106827614945, - "p": 0.2 + "x": -1387.0242197628988, + "y": -475.57194826074556, + "p": 0.2, + "binding": { + "targetName": "Quad_646", + "type": "vertex", + "index": 0 + } }, { - "x": 1692.4736752707722, - "y": -565.2106827614945, + "x": 1652, + "y": -488, "p": 0.2 }, { @@ -108,16 +123,16 @@ "p": 1 } ], - "color": "#888888", + "color": "#000219", "sortMode": "ignore", "opacity": 1, "blendMode": "source-over", "isGrid": true, "gridLinesX": 29, "gridLinesY": 7, - "lineWidth": 4, - "gridColor": "#0f6719", - "filled": false, + "lineWidth": 7.4, + "gridColor": "#150f67", + "filled": true, "blur": 0 }, { @@ -141,19 +156,19 @@ "layer": 0, "visible": true, "spatial": {}, - "x": 265.79289206085633, - "y": 449.8636667262623, - "width": 159.48352288795112, - "height": 384.9262805505487, + "x": -127.4690574074319, + "y": 441.3830181593477, + "width": 124.98187149421459, + "height": 301.6537762607278, "baseWidth": 162, "baseHeight": 391, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "miles_ds-idle-down.json", "color": "#e27fa5", - "scale": 0.984466190666365, + "scale": 0.7714930339149049, "modelScale": 1.03, - "parallax": 0.8536873845960513, + "parallax": 0.5571390073048871, "ignoreScaling": false, "animationSpeed": 30, "opacity": 1, @@ -192,30 +207,30 @@ "layer": 0, "visible": true, "spatial": {}, - "x": 186.54416364247743, - "y": 449.7722685807698, + "x": -189.00551991317218, + "y": 441.3284402319271, "parallax": 1, "ignoreScaling": false, "vertices": [ { - "x": 186.54416364247743, - "y": 449.7722685807698, - "p": 0.8518262423080945 + "x": -189.00551991317218, + "y": 441.3284402319271, + "p": 0.5556344177762325 }, { - "x": 297.6163127073407, - "y": 449.77439467798024, - "p": 0.8518934071256167 + "x": -103.9971337942764, + "y": 441.32987652161404, + "p": 0.5556740130215123 }, { - "x": 300.5132416470328, - "y": 453.4957983159374, - "p": 0.9237611059473142 + "x": -117.29283788247513, + "y": 442.9655924229333, + "p": 0.6007669861938634 }, { - "x": 195.7653597390785, - "y": 453.2940530119322, - "p": 0.919868888975613 + "x": -196.6341530469488, + "y": 442.8769914298106, + "p": 0.5983244580001196 } ], "color": "#000000", @@ -243,17 +258,17 @@ "layer": 0, "visible": true, "spatial": {}, - "x": 91, - "y": 392, - "width": 706.7755102040817, - "height": 721.2734693877551, - "baseWidth": 585, - "baseHeight": 597, + "x": -71, + "y": 237, + "width": 439.65753424657544, + "height": 438.3150684931505, + "baseWidth": 392.5513698630138, + "baseHeight": 391.35273972602727, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "scanline_logo", "color": "#AAAAAA", - "scale": 1.2081632653061225, + "scale": 1.1199999999999999, "modelScale": 2.8, "parallax": 0.2, "ignoreScaling": false, @@ -275,24 +290,198 @@ "layer": 0, "visible": true, "spatial": {}, - "x": 79, - "y": 417, - "width": 778.4081632653061, - "height": 794.3755102040816, + "x": -64, + "y": 346, + "width": 655.1999999999999, + "height": 668.64, "baseWidth": 585, "baseHeight": 597, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "scanline_logo", "color": "#00ca4c", - "scale": 1.3306122448979592, + "scale": 1.1199999999999999, "modelScale": 2.8, "parallax": 0.2, "ignoreScaling": false, "animationSpeed": 150, - "opacity": 1, - "blendMode": "lighter", + "opacity": 0.35, + "blendMode": "screen", "blur": 32 + }, + { + "name": "Quad_204", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -2, + "visible": true, + "spatial": {}, + "x": -306.3073195313695, + "y": 194.6463347631958, + "parallax": 1, + "ignoreScaling": false, + "vertices": [ + { + "x": -502.7847187282088, + "y": 399.7558482412977, + "p": 0.2 + }, + { + "x": 670.2152812717912, + "y": 390.7558482412977, + "p": 0.2 + }, + { + "x": 1335, + "y": 425.61919420555853, + "p": 0.2 + }, + { + "x": -1193, + "y": 431.61919420555853, + "p": 0.2 + } + ], + "color": "#ee00ff", + "sortMode": "ignore", + "opacity": 0.87, + "blendMode": "difference", + "isGrid": false, + "gridLinesX": 5, + "gridLinesY": 5, + "lineWidth": 1, + "gridColor": "#ffffff", + "filled": true, + "blur": 6 + }, + { + "name": "Quad", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -2, + "visible": true, + "spatial": {}, + "x": 31.056518423854072, + "y": -469.3074715804131, + "parallax": 1, + "ignoreScaling": false, + "vertices": [ + { + "x": -549.0858409402506, + "y": -481.4891445625435, + "p": 0.2 + }, + { + "x": 623.9141590597494, + "y": -490.4891445625435, + "p": 0.2 + }, + { + "x": 1288.698877787958, + "y": -455.6257985982827, + "p": 0.2 + }, + { + "x": -1239.301122212042, + "y": -449.6257985982827, + "p": 0.2 + } + ], + "color": "#ee00ff", + "sortMode": "ignore", + "opacity": 0.87, + "blendMode": "difference", + "isGrid": false, + "gridLinesX": 5, + "gridLinesY": 5, + "lineWidth": 1, + "gridColor": "#ffffff", + "filled": true, + "blur": 6 + }, + { + "name": "Quad_646", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "spatial": {}, + "x": -1359.1857676091972, + "y": -530.9597165247759, + "parallax": 1, + "ignoreScaling": false, + "vertices": [ + { + "x": -1387.0242197628988, + "y": -475.57194826074556, + "p": 0.2, + "binding": { + "targetName": "Quad_1", + "type": "vertex", + "index": 0 + } + }, + { + "x": 1550.6991926745702, + "y": -487.5857316086915, + "p": 0.2, + "binding": { + "targetName": "Quad_1", + "type": "grid", + "gridU": 0.9666666666666667, + "gridV": 0 + } + }, + { + "x": 1527.975780237101, + "y": 428.42805173925444, + "p": 0.2, + "binding": { + "targetName": "Quad_201", + "type": "vertex", + "index": 1 + } + }, + { + "x": -1449.024219762899, + "y": 432.42805173925444, + "p": 0.2, + "binding": { + "targetName": "Quad_201", + "type": "vertex", + "index": 0 + } + } + ], + "color": "#020440", + "sortMode": "ignore", + "opacity": 0.55, + "blendMode": "source-over", + "isGrid": false, + "gridLinesX": 5, + "gridLinesY": 5, + "lineWidth": 1, + "gridColor": "#ffffff", + "filled": true, + "blur": 20 } ], "camera": { @@ -305,4 +494,4 @@ "camDeadzoneX": 50, "camDeadzoneY": 30, "camMaxY": -370 -} +} \ No newline at end of file diff --git a/public/text/objects/Desk.json b/public/text/objects/Desk.json new file mode 100644 index 0000000..ea4d3b7 --- /dev/null +++ b/public/text/objects/Desk.json @@ -0,0 +1,6 @@ +{ + "title": "Desk", + "description": "A large desk where the computer and everything connected to it reigns supreme. The desk has three drawers.", + "details": "", + "synonyms": ["table", "workplace"] +} diff --git a/public/text/objects/Trig_sub_D.json b/public/text/objects/Trig_sub_D.json new file mode 100644 index 0000000..9772b8a --- /dev/null +++ b/public/text/objects/Trig_sub_D.json @@ -0,0 +1,6 @@ +{ + "title": "Table drawers", + "description": "The table have 3 drawers.", + "details": "", + "synonyms": [] +} diff --git a/public/text/objects/test.json b/public/text/objects/test.json new file mode 100644 index 0000000..5d77306 --- /dev/null +++ b/public/text/objects/test.json @@ -0,0 +1,5 @@ +{ + "title": "Someone ID card", + "description": "You see someone's ID", + "synonyms": [] +} diff --git a/public/text/scenes/quad4.json b/public/text/scenes/quad4.json new file mode 100644 index 0000000..fa67f05 --- /dev/null +++ b/public/text/scenes/quad4.json @@ -0,0 +1,4 @@ +{ + "title": "Test Room", + "description": "You are in Test Room." +} \ No newline at end of file diff --git a/public/text/scenes/test_room (10).json b/public/text/scenes/test_room (10).json new file mode 100644 index 0000000..fa67f05 --- /dev/null +++ b/public/text/scenes/test_room (10).json @@ -0,0 +1,4 @@ +{ + "title": "Test Room", + "description": "You are in Test Room." +} \ No newline at end of file diff --git a/src/core/Game.ts b/src/core/Game.ts index 103bf25..5a03e18 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -69,12 +69,12 @@ export class Game implements IGame { onMessage: ((text: string) => void) | null = null; onRequestFileBrowser: | (( - mode: 'save' | 'load', - dir: string, - onConfirm: (f: string) => void, - extension?: string, - title?: string - ) => void) + mode: 'save' | 'load', + dir: string, + onConfirm: (f: string) => void, + extension?: string, + title?: string + ) => void) | null = null; settings: { @@ -123,14 +123,14 @@ export class Game implements IGame { this.settings = { crt: { enabled: true, - curvature: 0.1, - scanlineCount: 800, - scanlineIntensity: 0.5, - aberration: 1.0, - vignette: 0.3, - phosphor: 0.0, - bezelGlow: false, - bloom: 0.0, + curvature: 0.16, + scanlineCount: 200, + scanlineIntensity: 0.4, + aberration: 0.2, + vignette: 0.9, + phosphor: 1.0, + bezelGlow: true, + bloom: 0.05, }, editor: { uiScale: 1.0, @@ -438,11 +438,6 @@ export class Game implements IGame { return this.textAssets.getServiceText(key, params); } - private getPlayerFacingEntityTitle(entity: Entity): string | null { - const title = this.textAssets.getResolvedObjectField(entity, 'title'); - return title && title.trim() ? title.trim() : null; - } - private getPlayerFacingObjectTitle(target: SceneObject): string | null { const title = this.textAssets.getResolvedObjectField(target as any, 'title'); return title && title.trim() ? title.trim() : null; @@ -501,8 +496,8 @@ export class Game implements IGame { return this.inventory.includes(entity); } - private canExamineEntity(entity: Entity): GameActionOutcome | null { - if (this.isEntityInInventory(entity)) return null; + private canExamineObject(entity: SceneObject): GameActionOutcome | null { + if (entity instanceof Entity && this.isEntityInInventory(entity)) return null; const scene = this.sceneManager.currentScene; if (!scene) { @@ -562,7 +557,7 @@ export class Game implements IGame { }; } - lookEntity(entity: Entity): GameActionOutcome { + lookEntity(entity: SceneObject): GameActionOutcome { const interactionId = entity.interactions && (entity.interactions.look || entity.interactions.LOOK); if (interactionId) { @@ -575,8 +570,10 @@ export class Game implements IGame { }; } - const description = - this.textAssets.getResolvedObjectField(entity, 'description') || entity.description; + const objectDescription = this.textAssets.getResolvedObjectField(entity, 'description'); + const runtimeDescription = + typeof (entity as any).description === 'string' ? (entity as any).description : null; + const description = objectDescription || runtimeDescription; if (description && description.trim()) { const spatialMessage = this.getSpatialParentMessage(entity); return { @@ -587,6 +584,18 @@ export class Game implements IGame { }; } + const targetTitle = this.getPlayerFacingObjectTitle(entity); + if (targetTitle) { + const genericMessage = this.text('parser.look_default_object', { target: targetTitle }); + const spatialMessage = this.getSpatialParentMessage(entity); + return { + status: 'ok', + code: 'entity_generic_description', + message: spatialMessage ? `${genericMessage} ${spatialMessage}` : genericMessage, + data: { targetType: 'entity', entityId: entity.name }, + }; + } + return { status: 'escalate', code: 'missing_description', @@ -595,10 +604,28 @@ export class Game implements IGame { }; } - examineEntity(entity: Entity): GameActionOutcome { - const accessError = this.canExamineEntity(entity); + examineEntity(entity: SceneObject): GameActionOutcome { + const accessError = this.canExamineObject(entity); if (accessError) return accessError; + const subsceneComponent = entity.components?.find((component: any) => component?.type === 'Subscene'); + if (subsceneComponent && this.sceneManager.currentScene) { + this.sceneManager.currentScene.activateObject(entity); + const seeMessage = this.getSeeMessage(entity); + const targetTitle = this.getPlayerFacingObjectTitle(entity); + return { + status: 'ok', + code: 'subscene_activated', + ...(seeMessage + ? { message: seeMessage } + : targetTitle + ? { message: this.text('engine.click_you_see', { title: targetTitle }) } + : {}), + data: { targetType: 'entity', entityId: entity.name }, + effects: ['subscene_opened'], + }; + } + const interactionId = entity.interactions && (entity.interactions.examine || @@ -627,8 +654,10 @@ export class Game implements IGame { }; } - const description = - this.textAssets.getResolvedObjectField(entity, 'description') || entity.description; + const objectDescription = this.textAssets.getResolvedObjectField(entity, 'description'); + const runtimeDescription = + typeof (entity as any).description === 'string' ? (entity as any).description : null; + const description = objectDescription || runtimeDescription; if (description && description.trim()) { return { status: 'ok', @@ -741,7 +770,7 @@ export class Game implements IGame { if (isItem || entity.isTakeable) { scene.removeEntity(entity); this.inventory.push(entity); - const itemTitle = this.getPlayerFacingEntityTitle(entity); + const itemTitle = this.getPlayerFacingObjectTitle(entity); if (!itemTitle) { return { status: 'escalate', @@ -792,7 +821,7 @@ export class Game implements IGame { showInventory(): GameActionOutcome { const inventoryTitles = this.inventory - .map((entity: any) => this.getPlayerFacingEntityTitle(entity)) + .map((entity: any) => this.getPlayerFacingObjectTitle(entity)) .filter((title): title is string => !!title); if (inventoryTitles.length !== this.inventory.length) { @@ -813,8 +842,8 @@ export class Game implements IGame { this.inventory.length === 0 ? this.text('parser.inventory_empty') : this.text('parser.inventory_items', { - items: inventoryTitles.join(', '), - }), + items: inventoryTitles.join(', '), + }), data: { count: this.inventory.length, }, @@ -876,7 +905,7 @@ export class Game implements IGame { goToEntity(entity: Entity): GameActionOutcome { const currentScene = this.sceneManager.currentScene; if (currentScene?.player && 'x' in entity && 'y' in entity) { - const entityTitle = this.getPlayerFacingEntityTitle(entity); + const entityTitle = this.getPlayerFacingObjectTitle(entity); if (!entityTitle) { return { status: 'escalate', diff --git a/src/core/IGame.ts b/src/core/IGame.ts index e4dd839..799d70f 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -22,9 +22,9 @@ export interface IGame { text(key: string, params?: Record<string, string | number>): string; getSeeMessage(target: SceneObject): string | null; lookScene(scene?: Scene | null): GameActionOutcome; - lookEntity(entity: Entity): GameActionOutcome; + lookEntity(entity: SceneObject): GameActionOutcome; describeSpatialRelation(anchorNodeId: string, relation: SpatialRelationType): GameActionOutcome; - examineEntity(entity: Entity): GameActionOutcome; + examineEntity(entity: SceneObject): GameActionOutcome; takeEntity(entity: Entity): GameActionOutcome; removeInventoryEntity(entity: Entity): GameActionOutcome; showInventory(): GameActionOutcome; diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index d65bc8c..3d93e74 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -355,8 +355,23 @@ export class TextAssetManager { } buildDefaultObjectAsset(obj: SceneObject): ObjectTextAssetData { - const fallbackTitle = (obj as any).customName || obj.name || obj.type || 'Object'; - const fallbackDescription = (obj as any).description || 'You see nothing special.'; + const subsceneComponent = Array.isArray((obj as any).components) + ? (obj as any).components.find((component: any) => component?.type === 'Subscene') + : null; + const fallbackTitle = + (obj as any).customName || + (typeof subsceneComponent?.title === 'string' && subsceneComponent.title.trim() + ? subsceneComponent.title.trim() + : '') || + obj.name || + obj.type || + 'Object'; + const fallbackDescription = + (typeof subsceneComponent?.description === 'string' && subsceneComponent.description.trim() + ? subsceneComponent.description + : '') || + (obj as any).description || + 'You see nothing special.'; return { title: fallbackTitle, description: fallbackDescription, diff --git a/src/entities/SceneObject.ts b/src/entities/SceneObject.ts index 882fcf1..23d1062 100644 --- a/src/entities/SceneObject.ts +++ b/src/entities/SceneObject.ts @@ -63,6 +63,16 @@ export class SceneObject { props.forEach((prop) => { const value = (this as any)[prop]; if (value !== undefined) { + if ( + prop === 'spatial' && + value && + typeof value === 'object' && + !Array.isArray(value) && + !value.parentNodeId && + !value.relation + ) { + return; + } // Deep clone objects and arrays to prevent reference sharing if (typeof value === 'object' && value !== null) { json[prop] = JSON.parse(JSON.stringify(value)); diff --git a/src/entities/TriggerComponents.ts b/src/entities/TriggerComponents.ts index e4d0421..5ab0303 100644 --- a/src/entities/TriggerComponents.ts +++ b/src/entities/TriggerComponents.ts @@ -5,11 +5,8 @@ export interface TriggerComponent { export interface SubsceneTrigger extends TriggerComponent { type: 'Subscene'; targetGroupId: string; - name?: string; - nodeId?: string; title?: string; description?: string | null; - spatial?: { parentNodeId?: string | null; relation?: 'in' | 'on' | 'under' | 'behind' | null }; } export interface Subtrigger extends TriggerComponent { @@ -31,3 +28,58 @@ export interface SwitchTrigger extends TriggerComponent { } export type AnyTriggerComponent = SubsceneTrigger | SwitchTrigger | Subtrigger; + +export function normalizeTriggerComponent(component: any): AnyTriggerComponent | null { + if (!component || typeof component !== 'object' || typeof component.type !== 'string') { + return null; + } + + if (component.type === 'Subscene') { + const title = + typeof component.title === 'string' && component.title.trim() + ? component.title.trim() + : typeof component.name === 'string' && component.name.trim() + ? component.name.trim() + : undefined; + const description = + typeof component.description === 'string' && component.description.trim() + ? component.description + : undefined; + + return { + type: 'Subscene', + targetGroupId: typeof component.targetGroupId === 'string' ? component.targetGroupId : '', + ...(title ? { title } : {}), + ...(description ? { description } : {}), + }; + } + + if (component.type === 'Subtrigger') { + return { + type: 'Subtrigger', + target: typeof component.target === 'string' ? component.target : '', + }; + } + + if (component.type === 'Switch') { + return { + type: 'Switch', + groupId1: typeof component.groupId1 === 'string' ? component.groupId1 : '', + groupId2: typeof component.groupId2 === 'string' ? component.groupId2 : '', + state: component.state === 2 ? 2 : 1, + ...(typeof component.idKey === 'string' ? { idKey: component.idKey } : {}), + ...(typeof component.sound1 === 'string' ? { sound1: component.sound1 } : {}), + ...(typeof component.sound2 === 'string' ? { sound2: component.sound2 } : {}), + ...(typeof component.name === 'string' ? { name: component.name } : {}), + }; + } + + return component as AnyTriggerComponent; +} + +export function normalizeTriggerComponents(components: any[] | null | undefined): AnyTriggerComponent[] { + if (!Array.isArray(components) || components.length === 0) return []; + return components + .map((component) => normalizeTriggerComponent(component)) + .filter((component): component is AnyTriggerComponent => !!component); +} diff --git a/src/entities/Triggerbox.ts b/src/entities/Triggerbox.ts index 1166025..e12823d 100644 --- a/src/entities/Triggerbox.ts +++ b/src/entities/Triggerbox.ts @@ -1,5 +1,5 @@ import { PolygonObject } from './PolygonObject'; -import type { AnyTriggerComponent } from './TriggerComponents'; +import { normalizeTriggerComponents, type AnyTriggerComponent } from './TriggerComponents'; export class Triggerbox extends PolygonObject { script: string; @@ -17,6 +17,13 @@ export class Triggerbox extends PolygonObject { } toJSON(): any { - return super.toJSON(); + const json = super.toJSON(); + json.components = normalizeTriggerComponents(json.components); + return json; + } + + override load(data: any): void { + super.load(data); + this.components = normalizeTriggerComponents(this.components); } } diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 8f840a0..8ac09bc 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -10,6 +10,7 @@ import { } from './parserLanguage'; import { ParserWorldModelBuilder } from './ParserWorldModelBuilder'; import { Entity } from '../entities/Entity'; +import { SceneObject } from '../entities/SceneObject'; import { ComponentSystem } from '../systems/ComponentSystem'; import type { ParserCascadeEnvelope, @@ -542,39 +543,39 @@ export class Parser { } } - private getPlayerFacingEntityTitle(entity: Entity): string | null { - const title = this.game.textAssets.getResolvedObjectField(entity, 'title'); + private getPlayerFacingObjectTitle(sceneObject: SceneObject): string | null { + const title = this.game.textAssets.getResolvedObjectField(sceneObject as any, 'title'); return title && title.trim() ? title.trim() : null; } - private getEntityLookupTokens(entity: Entity): string[] { - const title = this.getPlayerFacingEntityTitle(entity); - const synonyms = this.game.textAssets.getResolvedObjectListField(entity as any, 'synonyms'); + private getObjectLookupTokens(sceneObject: SceneObject): string[] { + const title = this.getPlayerFacingObjectTitle(sceneObject); + const synonyms = this.game.textAssets.getResolvedObjectListField(sceneObject as any, 'synonyms'); return Array.from( new Set([title, ...synonyms].filter((item): item is string => !!item && !!item.trim())) ).map((item) => item.toUpperCase()); } - private getResolutionOptionTitles(entities: Entity[]): string[] | null { - const titles = entities - .map((entity) => this.getPlayerFacingEntityTitle(entity)) + private getResolutionOptionTitles(sceneObjects: SceneObject[]): string[] | null { + const titles = sceneObjects + .map((sceneObject) => this.getPlayerFacingObjectTitle(sceneObject)) .filter((title): title is string => !!title); - if (titles.length !== entities.length) return null; + if (titles.length !== sceneObjects.length) return null; return Array.from(new Set(titles)); } - private areResolutionOptionsDistinct(entities: Entity[]): boolean { - const titles = this.getResolutionOptionTitles(entities); + private areResolutionOptionsDistinct(sceneObjects: SceneObject[]): boolean { + const titles = this.getResolutionOptionTitles(sceneObjects); if (!titles) return false; - return titles.length === entities.length; + return titles.length === sceneObjects.length; } - private getEntitySelectionPriority(entity: Entity): { + private getSceneObjectSelectionPriority(sceneObject: SceneObject): { bucket: number; order: number; distance: number; } { - const inventoryIndex = this.game.inventory.indexOf(entity); + const inventoryIndex = this.game.inventory.indexOf(sceneObject as any); if (inventoryIndex >= 0) { return { bucket: 0, @@ -586,8 +587,9 @@ export class Parser { const scene = this.game.sceneManager.currentScene; const player = scene?.player; if (player) { - const dx = (entity.x || 0) - (player.x || 0); - const dy = (entity.y || 0) - (player.y || 0); + const location = this.getSceneObjectReferencePoint(sceneObject); + const dx = location.x - (player.x || 0); + const dy = location.y - (player.y || 0); return { bucket: 1, order: Number.MAX_SAFE_INTEGER, @@ -602,11 +604,32 @@ export class Parser { }; } - private choosePreferredEntity(entities: Entity[]): Entity | null { - if (!entities.length) return null; - return [...entities].sort((left, right) => { - const a = this.getEntitySelectionPriority(left); - const b = this.getEntitySelectionPriority(right); + private getSceneObjectReferencePoint(sceneObject: SceneObject): { x: number; y: number } { + const polygon = (sceneObject as any).poly; + if (Array.isArray(polygon) && polygon.length) { + const sum = polygon.reduce( + (acc: { x: number; y: number }, point: { x: number; y: number }) => ({ + x: acc.x + (point?.x || 0), + y: acc.y + (point?.y || 0), + }), + { x: 0, y: 0 } + ); + return { + x: sum.x / polygon.length, + y: sum.y / polygon.length, + }; + } + return { + x: Number((sceneObject as any).x) || 0, + y: Number((sceneObject as any).y) || 0, + }; + } + + private choosePreferredObject<T extends SceneObject>(sceneObjects: T[]): T | null { + if (!sceneObjects.length) return null; + return [...sceneObjects].sort((left, right) => { + const a = this.getSceneObjectSelectionPriority(left); + const b = this.getSceneObjectSelectionPriority(right); if (a.bucket !== b.bucket) return a.bucket - b.bucket; if (a.order !== b.order) return a.order - b.order; @@ -615,11 +638,11 @@ export class Parser { })[0]; } - private getScopeCandidates(sliceNames: Array<keyof ParserScope>): Entity[] { + private getScopeCandidates(sliceNames: Array<keyof ParserScope>): SceneObject[] { const scope = this.activeScope || this.worldModelBuilder.build('', this.pendingState).scope; - const candidates: Entity[] = []; + const candidates: SceneObject[] = []; for (const sliceName of sliceNames) { - candidates.push(...scope[sliceName]); + candidates.push(...(scope[sliceName] as SceneObject[])); } return Array.from(new Set(candidates)); } @@ -700,10 +723,10 @@ export class Parser { private resolveEntityTargetInCandidates( rawTarget: string, - candidates: Entity[], + candidates: SceneObject[], clarificationKey: string ): - | { status: 'found'; entity: Entity } + | { status: 'found'; entity: SceneObject } | { status: 'not_found' } | { status: 'ambiguous'; message: string; options: string[] } | { status: 'escalate'; code: string } { @@ -712,13 +735,13 @@ export class Parser { .toUpperCase(); if (!normalizedTarget) return { status: 'not_found' }; - const exactMatches = candidates.filter((entity: Entity) => - this.getEntityLookupTokens(entity).includes(normalizedTarget) + const exactMatches = candidates.filter((sceneObject: SceneObject) => + this.getObjectLookupTokens(sceneObject).includes(normalizedTarget) ); if (exactMatches.length === 1) return { status: 'found', entity: exactMatches[0] }; if (exactMatches.length > 1) { if (!this.areResolutionOptionsDistinct(exactMatches)) { - const preferred = this.choosePreferredEntity(exactMatches); + const preferred = this.choosePreferredObject(exactMatches); if (preferred) return { status: 'found', entity: preferred }; } const optionTitles = this.getResolutionOptionTitles(exactMatches); @@ -730,14 +753,14 @@ export class Parser { }; } - const partialMatches = candidates.filter((entity: Entity) => { - const lookupTokens = this.getEntityLookupTokens(entity); + const partialMatches = candidates.filter((sceneObject: SceneObject) => { + const lookupTokens = this.getObjectLookupTokens(sceneObject); return lookupTokens.some((token) => token.includes(normalizedTarget)); }); if (partialMatches.length === 1) return { status: 'found', entity: partialMatches[0] }; if (partialMatches.length > 1) { if (!this.areResolutionOptionsDistinct(partialMatches)) { - const preferred = this.choosePreferredEntity(partialMatches); + const preferred = this.choosePreferredObject(partialMatches); if (preferred) return { status: 'found', entity: preferred }; } const optionTitles = this.getResolutionOptionTitles(partialMatches); @@ -754,14 +777,14 @@ export class Parser { private resolveEntityTargetWithMessages( rawTarget: string | null, - candidates: Entity[], + candidates: SceneObject[], messages?: { missing?: string; ambiguous?: string; notFound?: string; } ): - | { status: 'found'; entity: Entity } + | { status: 'found'; entity: SceneObject } | { status: 'not_found'; message: string } | { status: 'needs_clarification'; message: string; options: string[] } | { status: 'escalate'; code: string } { @@ -822,7 +845,7 @@ export class Parser { recoverable: true, }; } - return this.game.lookEntity(resolved.entity); + return this.game.lookEntity(resolved.entity as any); } private resolveExamineTarget(rawTarget: string | null): GameActionOutcome { @@ -865,7 +888,7 @@ export class Parser { }; } if (broadResolved?.status === 'found') { - return this.game.examineEntity(broadResolved.entity); + return this.game.examineEntity(broadResolved.entity as any); } return { status: 'failed', @@ -884,7 +907,7 @@ export class Parser { recoverable: true, }; } - return this.game.examineEntity(resolved.entity); + return this.game.examineEntity(resolved.entity as any); } private resolveRelationTarget( @@ -1030,7 +1053,7 @@ export class Parser { recoverable: true, }; } - return this.game.takeEntity(resolved.entity); + return this.game.takeEntity(resolved.entity as Entity); } private resolveGoToTarget(rawTarget: string | null): GameActionOutcome { @@ -1050,7 +1073,7 @@ export class Parser { const resolved = this.resolveEntityTargetInCandidates( rawTarget, - this.getScopeCandidates(['visible']), + this.getScopeCandidates(['visible']).filter((candidate): candidate is Entity => candidate instanceof Entity), 'parser.go_to_which_one' ); if (resolved.status === 'escalate') { @@ -1066,7 +1089,7 @@ export class Parser { }; } if (resolved.status === 'found') { - return this.game.goToEntity(resolved.entity); + return this.game.goToEntity(resolved.entity as any); } return { status: 'failed', @@ -1083,7 +1106,7 @@ export class Parser { ): GameActionOutcome { const resolution = this.resolveEntityTargetWithMessages( action.query, - this.getScopeCandidates(action.scopes), + this.getScopeCandidates(action.scopes).filter((candidate): candidate is Entity => candidate instanceof Entity), action.messages ); @@ -1124,7 +1147,7 @@ export class Parser { }; } - if (!this.isEntityValidForCommandArgument(resolution.entity, action.validation)) { + if (!this.isEntityValidForCommandArgument(resolution.entity as Entity, action.validation)) { return { status: 'failed', code: 'custom_command_invalid_argument', @@ -1515,7 +1538,7 @@ export class Parser { private getPlanStateDisplayValue(value: unknown): string | null { if (value instanceof Entity) { - return this.getPlayerFacingEntityTitle(value) || null; + return this.getPlayerFacingObjectTitle(value) || null; } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value); @@ -1563,7 +1586,7 @@ export class Parser { if (!validation) return true; const normalizedEntityId = entity.name.trim().toUpperCase(); - const normalizedTitle = (this.getPlayerFacingEntityTitle(entity) || '').trim().toUpperCase(); + const normalizedTitle = (this.getPlayerFacingObjectTitle(entity) || '').trim().toUpperCase(); const normalizedSynonyms = this.game.textAssets .getResolvedObjectListField(entity as any, 'synonyms') .map((item: string) => item.trim().toUpperCase()) diff --git a/src/mechanics/ParserWorldModelBuilder.ts b/src/mechanics/ParserWorldModelBuilder.ts index e76e104..a9578a7 100644 --- a/src/mechanics/ParserWorldModelBuilder.ts +++ b/src/mechanics/ParserWorldModelBuilder.ts @@ -1,5 +1,6 @@ import type { Game } from '../core/Game'; -import type { Entity } from '../entities/Entity'; +import { Entity } from '../entities/Entity'; +import type { SceneObject } from '../entities/SceneObject'; import type { Scene } from '../scene/Scene'; import { ComponentSystem } from '../systems/ComponentSystem'; import type { @@ -66,19 +67,22 @@ export class ParserWorldModelBuilder { } private buildEntityContexts(scene: Scene): ParserEntityContext[] { - return (scene.entities || []) - .map((entity: any) => { - const title = this.game.textAssets.getResolvedObjectField(entity, 'title')?.trim(); + const sceneObjects: SceneObject[] = [...(scene.entities || []), ...(scene.triggerboxes || [])]; + return sceneObjects + .map((sceneObject) => { + const title = this.getPlayerFacingObjectTitle(sceneObject); if (!title) return null; - const synonyms = this.game.textAssets.getResolvedObjectListField(entity, 'synonyms'); - const interactions = Object.keys(entity.interactions || {}); + const synonyms = this.game.textAssets.getResolvedObjectListField(sceneObject as any, 'synonyms'); + const interactions = Object.keys(sceneObject.interactions || {}); return this.compactRecord<ParserEntityContext>({ - id: entity.name, - type: entity.type, + id: sceneObject.name, + type: sceneObject.type, title, synonyms, - description: this.game.textAssets.getResolvedObjectField(entity, 'description') || undefined, - details: this.game.textAssets.getResolvedObjectField(entity, 'details') || undefined, + description: + this.game.textAssets.getResolvedObjectField(sceneObject as any, 'description') || undefined, + details: + this.game.textAssets.getResolvedObjectField(sceneObject as any, 'details') || undefined, interactions, }); }) @@ -156,29 +160,25 @@ export class ParserWorldModelBuilder { private buildScope(): ParserScope { const scene = this.game.sceneManager.currentScene; - const visible = scene - ? (scene.entities || []).filter( - (entity: Entity) => !entity.disabled && !!this.getPlayerFacingEntityTitle(entity) - ) - : []; + const visible = scene ? this.getTextVisibleSceneObjects(scene) : []; const held = (this.game.inventory || []).filter( - (entity: Entity) => !!this.getPlayerFacingEntityTitle(entity) + (entity: Entity) => !!this.getPlayerFacingObjectTitle(entity) ); - const takable = visible.filter((entity: Entity) => { + const takable = visible.filter((sceneObject): sceneObject is Entity => sceneObject instanceof Entity).filter((entity: Entity) => { const isItem = entity.components && entity.components.find((component: any) => component.type === 'Item'); return !!isItem || !!entity.isTakeable; }); const subscene = scene?.activeSubscene - ? visible.filter((entity: Entity) => scene.subsceneEntities.has(entity as any)) + ? visible.filter((sceneObject: SceneObject) => scene.subsceneEntities.has(sceneObject as any)) : []; const reachable = scene ? visible.filter( - (entity: Entity) => - !ComponentSystem.getInteractionDistanceError(entity as any, scene.player) + (sceneObject: SceneObject) => + !ComponentSystem.getInteractionDistanceError(sceneObject as any, scene.player) ) : []; - const examinable = this.uniqueEntities([...held, ...subscene, ...reachable]); + const examinable = this.uniqueObjects([...held, ...subscene, ...reachable]); return { visible, held, @@ -189,8 +189,14 @@ export class ParserWorldModelBuilder { }; } - private getPlayerFacingEntityTitle(entity: Entity): string | null { - const title = this.game.textAssets.getResolvedObjectField(entity, 'title'); + private getTextVisibleSceneObjects(scene: Scene): SceneObject[] { + return [...(scene.entities || []), ...(scene.triggerboxes || [])].filter( + (sceneObject: SceneObject) => !sceneObject.disabled && !!this.getPlayerFacingObjectTitle(sceneObject) + ); + } + + private getPlayerFacingObjectTitle(sceneObject: SceneObject): string | null { + const title = this.game.textAssets.getResolvedObjectField(sceneObject as any, 'title'); return title && title.trim() ? title.trim() : null; } @@ -214,7 +220,7 @@ export class ParserWorldModelBuilder { return result as T; } - private uniqueEntities(entities: Entity[]): Entity[] { - return Array.from(new Set(entities)); + private uniqueObjects<T extends SceneObject>(sceneObjects: T[]): T[] { + return Array.from(new Set(sceneObjects)); } } diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index 80341fd..2f1a60f 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -1,5 +1,6 @@ import type { GameActionOutcome } from '../core/GameActionTypes'; import type { Entity } from '../entities/Entity'; +import type { SceneObject } from '../entities/SceneObject'; export type ParserEntityContext = { id: string; @@ -121,12 +122,12 @@ export type ParserContext = { }; export type ParserScope = { - visible: Entity[]; + visible: SceneObject[]; held: Entity[]; takable: Entity[]; - reachable: Entity[]; - examinable: Entity[]; - subscene: Entity[]; + reachable: SceneObject[]; + examinable: SceneObject[]; + subscene: SceneObject[]; }; export type ParserWorldModel = { diff --git a/src/systems/ComponentSystem.ts b/src/systems/ComponentSystem.ts index 630e51a..f42efb9 100644 --- a/src/systems/ComponentSystem.ts +++ b/src/systems/ComponentSystem.ts @@ -141,8 +141,23 @@ export class ComponentSystem { const game = (entity as any).game as IGame | undefined; if (!player || options?.ignoreDistance) return null; - const e = entity as unknown as { x: number; y: number }; - const dist = Math.hypot(player.x - e.x, player.y - e.y); + let targetX = 0; + let targetY = 0; + + if ( + Array.isArray((entity as any).poly) && + (entity as any).poly.length > 0 + ) { + const poly = (entity as any).poly as Array<{ x: number; y: number }>; + targetX = poly.reduce((sum, point) => sum + point.x, 0) / poly.length; + targetY = poly.reduce((sum, point) => sum + point.y, 0) / poly.length; + } else { + const e = entity as unknown as { x?: number; y?: number }; + targetX = e.x || 0; + targetY = e.y || 0; + } + + const dist = Math.hypot(player.x - targetX, player.y - targetY); const allowedDist = (player.width || 30) * 4; if (dist > allowedDist) { diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index d772e82..6a2c686 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -4,6 +4,7 @@ import { SceneObject } from '../entities/SceneObject'; import { Walkbox } from '../entities/Walkbox'; import { Triggerbox } from '../entities/Triggerbox'; import { QuadObject } from '../entities/QuadObject'; +import { normalizeTriggerComponents } from '../entities/TriggerComponents'; import { Scene } from '../scene/Scene'; import { useEditorStore } from '../store/editorStore'; @@ -670,7 +671,7 @@ export class SceneEditor { } newObj = new Triggerbox(poly, data.name, data.script || ''); if (data.groupID) newObj.groupID = data.groupID; - if (data.components) newObj.components = JSON.parse(JSON.stringify(data.components)); + if (data.components) newObj.components = normalizeTriggerComponents(data.components); if (data.locked) newObj.locked = data.locked; if (data.disabled) newObj.disabled = data.disabled; if (data.customName) newObj.customName = data.customName; diff --git a/tests/fixtures/sceneFactory.ts b/tests/fixtures/sceneFactory.ts index 4ef698e..a28bd9b 100644 --- a/tests/fixtures/sceneFactory.ts +++ b/tests/fixtures/sceneFactory.ts @@ -16,6 +16,9 @@ type EntityOptions = { }; type TriggerboxOptions = { + title?: string; + description?: string; + details?: string; disabled?: boolean; groupID?: string | null; components?: any[]; @@ -90,8 +93,9 @@ export function createSceneFixture(sceneId: string = 'test_scene'): SceneFixture triggerbox.spatial = options.spatial || {}; scene.triggerboxes.push(triggerbox); harness.textAssets.setObject(name, { - title: name, - description: `${name} triggerbox`, + title: options.title || name, + description: options.description || `${name} triggerbox`, + details: options.details, }); return triggerbox; }, diff --git a/tests/fixtures/textAssetFactory.ts b/tests/fixtures/textAssetFactory.ts index 43c4860..589e5be 100644 --- a/tests/fixtures/textAssetFactory.ts +++ b/tests/fixtures/textAssetFactory.ts @@ -25,6 +25,7 @@ export type TestTextAssets = TextAssetLike & { }; const DEFAULT_SERVICE_TEXT: Record<string, string> = { + 'engine.click_you_see': 'You see {title}', 'engine.too_far_generic': 'You are too far away.', 'engine.too_far_from_entity': 'You are too far away from the {target}.', 'engine.locked_needs': 'Locked. Needs {item}', diff --git a/tests/parser/resolution.test.ts b/tests/parser/resolution.test.ts index 3e08fcb..cb67bc7 100644 --- a/tests/parser/resolution.test.ts +++ b/tests/parser/resolution.test.ts @@ -94,4 +94,20 @@ describe('Parser resolution', () => { expect(result.messages.at(-1)).toBe('Near coin.'); }); + + it('matches a triggerbox by title in the text layer', async () => { + const fixture = createParserFixture(); + fixture.addPlayer(); + fixture.addTriggerbox('tb_desk_drawer', { + title: 'Desk Drawer', + description: 'A shallow desk drawer.', + details: 'The upper desk drawer is open and mostly empty.', + }); + + const lookResult = await fixture.run('look desk drawer'); + expect(lookResult.messages.at(-1)).toBe('A shallow desk drawer.'); + + const examineResult = await fixture.run('examine desk drawer'); + expect(examineResult.messages.at(-1)).toBe('The upper desk drawer is open and mostly empty.'); + }); }); diff --git a/tests/scene/scene-interaction.test.ts b/tests/scene/scene-interaction.test.ts new file mode 100644 index 0000000..fa3be15 --- /dev/null +++ b/tests/scene/scene-interaction.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { handleSceneClick } from '../../src/scene/SceneInteraction'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +describe('Scene interaction text layer', () => { + it('shows the triggerbox title on click when it has TA', () => { + const fixture = createSceneFixture(); + fixture.addTriggerbox('tb_drawer', { + title: 'Desk Drawer', + description: 'A shallow desk drawer.', + }); + + handleSceneClick(fixture.scene, 215, 155); + + expect(fixture.messages.at(-1)).toBe('You see Desk Drawer'); + }); +}); From 57f340e6fe26df17985d0cbcea6044b7f2c9a3ee Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Fri, 20 Mar 2026 02:11:09 +0200 Subject: [PATCH 39/75] Fix: show expanded console shortcuts --- src/components/UIOverlay.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index f2d2aaf..493df6d 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -120,10 +120,10 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { const preprocessed = game.console.preprocessGameplayInput(val); // 1. Log Command to Buffer - game.console.log(val, 'command'); + game.console.log(preprocessed, 'command'); // 2. Add to History - game.console.addHistory(val); + game.console.addHistory(preprocessed); // 3. Send to gameplay parser void game.parser.parse(preprocessed); From 0eaa651e22eb05b65085c3df69e197ac95f7fb44 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Fri, 20 Mar 2026 16:19:18 +0200 Subject: [PATCH 40/75] Fix: resolve relation anchors via visible targets --- src/core/Game.ts | 12 ++++++++++-- src/mechanics/Parser.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/core/Game.ts b/src/core/Game.ts index 5a03e18..23a1b3b 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -687,8 +687,16 @@ export class Game implements IGame { } const anchorNode = scene.getSpatialNode(anchorNodeId); - const anchorTitle = anchorNode?.title?.trim() || null; - if (!anchorNode || !anchorTitle) { + const anchorObject = + scene.entities.find((entity) => entity.name === anchorNodeId) || + scene.triggerboxes.find((triggerbox) => triggerbox.name === anchorNodeId) || + scene.walkbox.find((walkbox) => walkbox.name === anchorNodeId) || + null; + const anchorTitle = + anchorNode?.title?.trim() || + (anchorObject ? this.getPlayerFacingObjectTitle(anchorObject)?.trim() : null) || + null; + if (!anchorTitle) { return { status: 'escalate', code: 'spatial_node_missing_title', diff --git a/src/mechanics/Parser.ts b/src/mechanics/Parser.ts index 8ac09bc..c61c1b8 100644 --- a/src/mechanics/Parser.ts +++ b/src/mechanics/Parser.ts @@ -718,6 +718,34 @@ export class Parser { }; } + const broadResolved = this.resolveEntityTargetInCandidates( + rawTarget, + this.getScopeCandidates(['visible', 'held']), + clarificationKey + ); + if (broadResolved.status === 'found') { + return { + status: 'found', + node: { + id: broadResolved.entity.name, + title: this.getPlayerFacingObjectTitle(broadResolved.entity) || undefined, + }, + }; + } + if (broadResolved.status === 'ambiguous') { + return { + status: 'ambiguous', + message: broadResolved.message, + options: broadResolved.options, + }; + } + if (broadResolved.status === 'escalate') { + return { + status: 'escalate', + code: broadResolved.code, + }; + } + return { status: 'not_found' }; } From 470c94df8631ce0a5637702060ec1676c9dc44b3 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Fri, 20 Mar 2026 18:06:52 +0200 Subject: [PATCH 41/75] Fix: respect top-layer click hit selection --- src/scene/SceneInteraction.ts | 167 +++++++++++++++++++++------------- 1 file changed, 102 insertions(+), 65 deletions(-) diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index 05bebc2..dd6cb4a 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -2,7 +2,6 @@ import type { Scene } from './Scene'; import { SceneObject } from '../entities/SceneObject'; import { Triggerbox } from '../entities/Triggerbox'; import { ComponentSystem } from '../systems/ComponentSystem'; -import { Geometry } from '../utils/Geometry'; function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { const screenW = 420; @@ -32,48 +31,7 @@ function toWorldForParallax( } function findVisibleHitObject(scene: Scene, screenX: number, screenY: number): SceneObject | null { - const screenW = 420; - const screenH = 300; - const halfW = screenW / 2; - const halfH = screenH / 2; - const camX = scene.camera.x; - const camY = scene.camera.y; - const zoom = scene.camera.zoom; - - const entities = scene.entities || []; - for (let i = entities.length - 1; i >= 0; i--) { - const entity = entities[i]; - if (entity.disabled || !entity.visible) continue; - - const p = entity.parallax !== undefined ? entity.parallax : 1.0; - const vOx = (entity as any).visualOffset ? (entity as any).visualOffset.x : 0; - const vOy = (entity as any).visualOffset ? (entity as any).visualOffset.y : 0; - const worldX = (screenX - halfW) / zoom + camX * p - vOx; - const worldY = (screenY - halfH) / zoom + camY * p - vOy; - - if (entity.hitTest(worldX, worldY)) return entity; - } - - const worldPos = { - x: (screenX - halfW) / zoom + camX, - y: (screenY - halfH) / zoom + camY, - }; - - if (scene.triggerboxes) { - for (const tb of scene.triggerboxes) { - if (tb.disabled || !tb.visible) continue; - if (Geometry.isPointInPolygon(worldPos, tb.poly)) return tb; - } - } - - if (scene.walkbox) { - for (const wb of scene.walkbox) { - if (wb.disabled || !wb.visible) continue; - if (Geometry.isPointInPolygon(worldPos, wb.poly)) return wb; - } - } - - return null; + return findTopHitInCandidates(scene, getSortedClickableCandidates(scene), screenX, screenY); } function isHitAtScreenPoint( @@ -107,6 +65,12 @@ function isHitAtScreenPoint( return obj.hitTest(worldPos.x, worldPos.y); } +function getClickableTypePriority(obj: SceneObject): number { + if (obj.type === 'Walkbox') return 30; + if (obj.type === 'Triggerbox') return 10; + return 0; +} + function sortClickableCandidates(candidates: SceneObject[]): SceneObject[] { const sorted = [...candidates]; sorted.sort((a, b) => { @@ -114,6 +78,10 @@ function sortClickableCandidates(candidates: SceneObject[]): SceneObject[] { const layerB = b.layer || 0; if (layerA !== layerB) return layerB - layerA; + const typePriorityA = getClickableTypePriority(a); + const typePriorityB = getClickableTypePriority(b); + if (typePriorityA !== typePriorityB) return typePriorityA - typePriorityB; + const hasXYA = 'x' in (a as any) && 'y' in (a as any); const hasXYB = 'x' in (b as any) && 'y' in (b as any); if (hasXYA && !hasXYB) return -1; @@ -126,7 +94,7 @@ function sortClickableCandidates(candidates: SceneObject[]): SceneObject[] { function getSortedClickableCandidates(scene: Scene): SceneObject[] { return sortClickableCandidates([ - ...scene.entities.filter((e) => !e.disabled && e.visible), + ...scene.entities.filter((e) => !e.disabled && e.visible && !(e as any).isPlayer), ...(scene.triggerboxes?.filter((t) => !t.disabled && t.visible) || []), ...(scene.walkbox?.filter((w) => !w.disabled && w.visible) || []), ]); @@ -146,23 +114,6 @@ function findTopHitInCandidates( return null; } -function findTopHitInWorldCandidates( - candidates: SceneObject[], - worldX: number, - worldY: number -): SceneObject | null { - for (const candidate of sortClickableCandidates(candidates)) { - if (candidate.hitTest(worldX, worldY)) { - return candidate; - } - } - return null; -} - -function findTopHitObject(scene: Scene, screenX: number, screenY: number): SceneObject | null { - return findTopHitInCandidates(scene, getSortedClickableCandidates(scene), screenX, screenY); -} - function resolveSubtriggerTarget(scene: Scene, obj: SceneObject): SceneObject { const subtrigger = obj.components?.find((c: any) => c?.type === 'Subtrigger') as | { target?: string } @@ -175,6 +126,87 @@ function resolveSubtriggerTarget(scene: Scene, obj: SceneObject): SceneObject { return target || obj; } +function canActivateOnClick(obj: SceneObject): boolean { + if (obj instanceof Triggerbox && obj.script) return true; + if (obj.interactions && Object.keys(obj.interactions).length > 0) return true; + if (!obj.components || obj.components.length === 0) return false; + + return obj.components.some((component: any) => + ['Subtrigger', 'Subscene', 'Switch'].includes(component?.type) + ); +} + +function hasClickOutput(scene: Scene, obj: SceneObject): boolean { + const titleOwner = resolveSubtriggerTarget(scene, obj); + const seeMessage = scene.game.getSeeMessage(titleOwner); + if (seeMessage) return true; + + const title = scene.game.textAssets.getResolvedObjectField(titleOwner, 'title'); + return !!(title && title.trim()); +} + +function hasMeaningfulClickResult(scene: Scene, obj: SceneObject): boolean { + return hasClickOutput(scene, obj) || canActivateOnClick(obj); +} + +function findTopLayerHitCandidatesAtScreenPoint( + scene: Scene, + candidates: SceneObject[], + screenX: number, + screenY: number +): SceneObject[] { + const hits: SceneObject[] = []; + let topLayer: number | null = null; + + for (const candidate of sortClickableCandidates(candidates)) { + if (!isHitAtScreenPoint(scene, candidate, screenX, screenY)) continue; + const candidateLayer = candidate.layer || 0; + if (topLayer === null) { + topLayer = candidateLayer; + } + if (candidateLayer !== topLayer) break; + hits.push(candidate); + } + + return hits; +} + +function findTopLayerHitCandidatesAtWorldPoint( + candidates: SceneObject[], + worldX: number, + worldY: number +): SceneObject[] { + const hits: SceneObject[] = []; + let topLayer: number | null = null; + + for (const candidate of sortClickableCandidates(candidates)) { + if (!candidate.hitTest(worldX, worldY)) continue; + const candidateLayer = candidate.layer || 0; + if (topLayer === null) { + topLayer = candidateLayer; + } + if (candidateLayer !== topLayer) break; + hits.push(candidate); + } + + return hits; +} + +function findBestMeaningfulHit( + scene: Scene, + candidates: SceneObject[], + screenX: number, + screenY: number +): SceneObject | null { + const topLayerHits = findTopLayerHitCandidatesAtScreenPoint(scene, candidates, screenX, screenY); + for (const candidate of topLayerHits) { + if (hasMeaningfulClickResult(scene, candidate)) { + return candidate; + } + } + return null; +} + export function activateSceneObject(scene: Scene, obj: SceneObject, depth: number = 0): boolean { if (depth > 5) { console.warn('[Scene] Recursion limit reached.'); @@ -197,11 +229,16 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { const world = toWorld(scene, x, y); if (scene.activeSubscene) { - const subsceneHit = findTopHitInWorldCandidates( - Array.from(scene.subsceneEntities).filter((obj) => !obj.disabled && obj.visible), + const subsceneCandidates = Array.from(scene.subsceneEntities).filter( + (obj) => !obj.disabled && obj.visible + ); + const subsceneHitCandidates = findTopLayerHitCandidatesAtWorldPoint( + subsceneCandidates, world.x, world.y ); + const subsceneHit = + subsceneHitCandidates.find((obj) => hasMeaningfulClickResult(scene, obj)) || null; if (subsceneHit) { const titleOwner = resolveSubtriggerTarget(scene, subsceneHit); @@ -220,7 +257,7 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { return; } - const hitObj = findTopHitObject(scene, x, y); + const hitObj = findBestMeaningfulHit(scene, getSortedClickableCandidates(scene), x, y); if (hitObj) { const titleOwner = resolveSubtriggerTarget(scene, hitObj); @@ -243,7 +280,7 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { } } - const visibleHitObj = findTopHitObject(scene, x, y) || findVisibleHitObject(scene, x, y); + const visibleHitObj = findVisibleHitObject(scene, x, y); if (visibleHitObj) { const titleOwner = resolveSubtriggerTarget(scene, visibleHitObj); const seeMessage = scene.game.getSeeMessage(titleOwner); From 36428b8a31a27e418351aa00cc3d6e6c5b5e13bb Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Fri, 20 Mar 2026 18:54:37 +0200 Subject: [PATCH 42/75] Fix: keep subscene open on passive child clicks --- src/scene/SceneInteraction.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index dd6cb4a..192a600 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -253,6 +253,10 @@ export function handleSceneClick(scene: Scene, x: number, y: number): void { return; } + if (subsceneHitCandidates.length > 0) { + return; + } + scene.activeSubscene = null; return; } From cc982fd6d468650d03275b8592e667a2b0393a18 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 00:18:44 +0200 Subject: [PATCH 43/75] Feature: add contextual hover cursors --- src/core/Game.ts | 21 ++++++----- src/index.css | 14 +++++++ src/scene/Scene.ts | 33 ++++------------- src/scene/SceneInteraction.ts | 69 +++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 35 deletions(-) diff --git a/src/core/Game.ts b/src/core/Game.ts index 23a1b3b..3d1b603 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -246,19 +246,22 @@ export class Game implements IGame { this.editor.update(deltaTime); } - // Cursor Logic: Change to 'eye' if hovering over Subscene object in Game Mode + // Cursor Logic: Change to contextual cursor if hovering over interactive object in Game Mode if (!this.editor.enabled && this.sceneManager.currentScene) { - const hovered = this.sceneManager.currentScene.checkHover( + const hoverCursor = this.sceneManager.currentScene.checkHover( this.input.mouse.x, this.input.mouse.y ); - if (hovered) { + this.canvas.classList.remove('cursor-eye', 'cursor-hand', 'cursor-back'); + if (hoverCursor === 'eye') { this.canvas.classList.add('cursor-eye'); - } else { - this.canvas.classList.remove('cursor-eye'); + } else if (hoverCursor === 'hand') { + this.canvas.classList.add('cursor-hand'); + } else if (hoverCursor === 'back') { + this.canvas.classList.add('cursor-back'); } } else { - this.canvas.classList.remove('cursor-eye'); + this.canvas.classList.remove('cursor-eye', 'cursor-hand', 'cursor-back'); } } @@ -575,11 +578,10 @@ export class Game implements IGame { typeof (entity as any).description === 'string' ? (entity as any).description : null; const description = objectDescription || runtimeDescription; if (description && description.trim()) { - const spatialMessage = this.getSpatialParentMessage(entity); return { status: 'ok', code: 'entity_description', - message: spatialMessage ? `${description.trim()} ${spatialMessage}` : description, + message: description, data: { targetType: 'entity', entityId: entity.name }, }; } @@ -587,11 +589,10 @@ export class Game implements IGame { const targetTitle = this.getPlayerFacingObjectTitle(entity); if (targetTitle) { const genericMessage = this.text('parser.look_default_object', { target: targetTitle }); - const spatialMessage = this.getSpatialParentMessage(entity); return { status: 'ok', code: 'entity_generic_description', - message: spatialMessage ? `${genericMessage} ${spatialMessage}` : genericMessage, + message: genericMessage, data: { targetType: 'entity', entityId: entity.name }, }; } diff --git a/src/index.css b/src/index.css index 6236516..d30b7fd 100644 --- a/src/index.css +++ b/src/index.css @@ -508,3 +508,17 @@ input[type='file'] { 16 16, pointer; } + +.cursor-hand { + cursor: + url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2232%22 height=%2232%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22white%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22><path d=%22M18 11V6a2 2 0 0 0-4 0v5%22/><path d=%22M14 10V4a2 2 0 0 0-4 0v8%22/><path d=%22M10 10.5V6a2 2 0 0 0-4 0v8%22/><path d=%22M18 8a2 2 0 1 1 4 0v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4v-5a2 2 0 1 1 4 0%22/></svg>') + 16 16, + pointer; +} + +.cursor-back { + cursor: + url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2232%22 height=%2232%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22white%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22><path d=%22M19 12H5%22/><path d=%22M12 19l-7-7 7-7%22/></svg>') + 16 16, + pointer; +} diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index f3df7ae..a7b0c1c 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -11,7 +11,12 @@ import type { IGame } from '../core/IGame'; import { toVisualPosition } from '../utils/Parallax'; import { updateSceneCamera } from './SceneCamera'; import { resolveSceneTargets, cleanupClosingSubscene } from './SceneSubscene'; -import { handleSceneClick, activateSceneObject } from './SceneInteraction'; +import { + handleSceneClick, + activateSceneObject, + getHoverCursorAtScreenPoint, + type HoverCursor, +} from './SceneInteraction'; import { useEditorStore } from '../store/editorStore'; import type { SpatialIndex, @@ -574,30 +579,8 @@ export class Scene { return null; } - checkHover(x: number, y: number): boolean { - // Transform Screen Coordinates to World Coordinates - const screenW = 420; - const screenH = 300; - const halfW = screenW / 2; - const halfH = screenH / 2; - const worldX = (x - halfW) / this.camera.zoom + this.camera.x; - const worldY = (y - halfH) / this.camera.zoom + this.camera.y; - - const obj = this.getHitObject(worldX, worldY); - - if (obj && obj.components) { - const sub = obj.components.find((c) => c.type === 'Subscene') as any; - if (sub) { - // If this trigger opens the CURRENTLY active subscene, ignore it (cursor shouldn't change) - const currentSubsceneId = (obj.name || sub.targetGroupId || '').trim(); - if (this.activeSubscene && currentSubsceneId && currentSubsceneId === this.activeSubscene) { - return false; - } - return true; - } - } - - return false; + checkHover(x: number, y: number): HoverCursor | null { + return getHoverCursorAtScreenPoint(this, x, y); } onClick(x: number, y: number): void { diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index 192a600..defbbea 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -3,6 +3,8 @@ import { SceneObject } from '../entities/SceneObject'; import { Triggerbox } from '../entities/Triggerbox'; import { ComponentSystem } from '../systems/ComponentSystem'; +export type HoverCursor = 'eye' | 'hand' | 'back'; + function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { const screenW = 420; const screenH = 300; @@ -149,6 +151,34 @@ function hasMeaningfulClickResult(scene: Scene, obj: SceneObject): boolean { return hasClickOutput(scene, obj) || canActivateOnClick(obj); } +function getHoverCursorForObject(scene: Scene, obj: SceneObject): HoverCursor | null { + if (obj.components) { + const sub = obj.components.find((c) => c.type === 'Subscene') as any; + if (sub) { + const currentSubsceneId = (obj.name || sub.targetGroupId || '').trim(); + if (scene.activeSubscene && currentSubsceneId && currentSubsceneId === scene.activeSubscene) { + return null; + } + return 'eye'; + } + + const hasHandTriggerComponent = obj.components.some((c) => + ['Subtrigger', 'Switch'].includes(c?.type) + ); + if (hasHandTriggerComponent) { + return 'hand'; + } + } + + const isScriptTrigger = obj instanceof Triggerbox && obj.script && obj.script.length > 0; + const hasInteractions = !!(obj.interactions && Object.keys(obj.interactions).length > 0); + if (isScriptTrigger || hasInteractions) { + return 'hand'; + } + + return null; +} + function findTopLayerHitCandidatesAtScreenPoint( scene: Scene, candidates: SceneObject[], @@ -207,6 +237,45 @@ function findBestMeaningfulHit( return null; } +export function getHoverCursorAtScreenPoint( + scene: Scene, + screenX: number, + screenY: number +): HoverCursor | null { + if (scene.activeSubscene) { + const world = toWorld(scene, screenX, screenY); + const subsceneCandidates = Array.from(scene.subsceneEntities).filter( + (obj) => !obj.disabled && obj.visible + ); + const topLayerHits = findTopLayerHitCandidatesAtWorldPoint( + subsceneCandidates, + world.x, + world.y + ); + for (const obj of topLayerHits) { + const hoverCursor = getHoverCursorForObject(scene, obj); + if (hoverCursor) { + return hoverCursor; + } + } + return topLayerHits.length > 0 ? null : 'back'; + } + + const topLayerHits = findTopLayerHitCandidatesAtScreenPoint( + scene, + getSortedClickableCandidates(scene), + screenX, + screenY + ); + for (const obj of topLayerHits) { + const hoverCursor = getHoverCursorForObject(scene, obj); + if (hoverCursor) { + return hoverCursor; + } + } + return null; +} + export function activateSceneObject(scene: Scene, obj: SceneObject, depth: number = 0): boolean { if (depth > 5) { console.warn('[Scene] Recursion limit reached.'); From 18aa3537eaaf93c4d7104d239a441db785010db5 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 01:05:49 +0200 Subject: [PATCH 44/75] Style: soften editor panel borders --- src/editor.css | 7 +++---- src/index.css | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/editor.css b/src/editor.css index 130d0af..8400347 100644 --- a/src/editor.css +++ b/src/editor.css @@ -51,7 +51,7 @@ .editor-sidebar { width: 260px; background: rgba(10, 10, 10, 0.95); - border: 1px solid #333; + border: 1px solid rgba(10, 10, 10, 0.95); color: #ddd; display: flex; flex-direction: column; @@ -62,12 +62,11 @@ } .editor-sidebar.left { - border-right: 1px solid var(--ui-main-color); - /* Retro Green accent */ + border-right: 1px solid rgba(10, 10, 10, 0.95); } .editor-sidebar.right { - border-left: 1px solid var(--ui-main-color); + border-left: 1px solid rgba(10, 10, 10, 0.95); } .editor-center-spacer { diff --git a/src/index.css b/src/index.css index d30b7fd..07457aa 100644 --- a/src/index.css +++ b/src/index.css @@ -188,7 +188,7 @@ canvas { width: 17em; /* approx 200px at 12px font */ background: rgba(0, 0, 0, 0.8); - border: 2px solid var(--ui-main-color); + border: 2px solid rgba(0, 0, 0, 0.8); color: var(--ui-main-color); display: flex; flex-direction: column; @@ -201,7 +201,7 @@ canvas { width: 25em; /* approx 300px at 12px font */ background: rgba(0, 0, 0, 0.8); - border: 2px solid var(--ui-main-color); + border: 2px solid rgba(0, 0, 0, 0.8); color: var(--ui-main-color); padding: 10px; overflow-y: auto; From 3e9f364d2e61eda5740744b5d3a498bd565d420a Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 02:14:28 +0200 Subject: [PATCH 45/75] Style: refine editor typography and dropdown sizing --- src/components/editor/PropertiesPanel.tsx | 7 ++--- src/editor.css | 31 +++++++++++++++++++++-- src/index.css | 27 +++++++++++++++++--- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index b177bdf..55c5d8b 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -1330,6 +1330,7 @@ export const PropertiesPanel: React.FC = () => { <span>SCRIPT EVENTS</span> <Select value="" + className="compact-action-select" placeholder="+ ADD" onChange={(value) => { const verb = value; @@ -1352,7 +1353,7 @@ export const PropertiesPanel: React.FC = () => { { value: 'talk', label: 'Talk' }, { value: 'pickup', label: 'Pickup' }, ]} - style={{ width: '80px', fontSize: '0.85em' }} + style={{ width: '8em' }} /> </div> @@ -2067,7 +2068,7 @@ export const PropertiesPanel: React.FC = () => { incrementObjectVersion(); // No need to reset value as Select component handles it or we pass empty value }} - style={{ width: '100px', fontSize: '0.8em' }} + style={{ width: '100%' }} value="" /> </div> @@ -2376,7 +2377,7 @@ export const PropertiesPanel: React.FC = () => { { value: 'Add', label: 'Add (Walk Inside)' }, { value: 'Subtract', label: 'Subtract (Hole)' }, ]} - style={{ width: '120px' }} + style={{ width: '100%' }} /> </div> </div> diff --git a/src/editor.css b/src/editor.css index 8400347..d3a9da6 100644 --- a/src/editor.css +++ b/src/editor.css @@ -84,6 +84,10 @@ display: flex; justify-content: space-between; align-items: center; + font-family: var(--ui-display-font); + font-weight: 600; + text-transform: uppercase; + letter-spacing: -0.03em; } .editor-content { @@ -98,11 +102,14 @@ border: 1px solid #555; color: #eee; padding: 4px 8px; - font-family: inherit; + font-family: var(--ui-display-font); + font-weight: 500; font-size: inherit; /* WAS: 12px */ cursor: pointer; margin-right: 5px; + text-transform: uppercase; + letter-spacing: -0.03em; } .e-btn:hover { @@ -174,7 +181,8 @@ padding: 0 10px; gap: 15px; pointer-events: auto; - font-family: monospace; + font-family: var(--ui-display-font); + font-size: 0.92em; width: 100%; box-sizing: border-box; z-index: 200; @@ -190,6 +198,9 @@ /* WAS: 12px */ text-transform: uppercase; padding: 2px 6px; + font-family: var(--ui-display-font); + font-weight: 500; + letter-spacing: -0.03em; } /* Hotkey Accent Color */ @@ -227,6 +238,22 @@ color: var(--ui-text-dim, #666); } +.editor-sidebar.right .custom-select-container { + min-width: 8.5em; + font-size: 1.05em; + min-height: 22px; +} + +.editor-sidebar.right .custom-select-container.compact-action-select { + min-width: 0; +} + +.editor-sidebar.right .custom-select-trigger { + min-height: 22px; + box-sizing: border-box; + padding: 1px 4px !important; +} + /* ... existing code ... */ /* Entity List Items */ diff --git a/src/index.css b/src/index.css index 07457aa..9e4a38e 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600&display=swap'); + body { background-color: #000; color: #fff; @@ -41,6 +43,7 @@ body { --ui-bg-color: #000000; --ui-panel-header-bg: #222222; --ui-label-color: #888888; + --ui-display-font: 'Space Grotesk', 'Segoe UI', sans-serif; /* Selection & Active States */ --ui-selection-bg: #4ca149; @@ -287,6 +290,10 @@ canvas { align-items: center; border-bottom: 1px solid var(--ui-main-color); margin-bottom: 10px; + font-family: var(--ui-display-font); + font-weight: 600; + text-transform: uppercase; + letter-spacing: -0.03em; } .editor-section { @@ -298,6 +305,10 @@ canvas { .editor-section h4 { margin: 0 0 5px 0; color: #fff; + font-family: var(--ui-display-font); + font-weight: 600; + text-transform: uppercase; + letter-spacing: -0.03em; } button { @@ -306,7 +317,10 @@ button { color: var(--ui-btn-text); cursor: pointer; padding: 2px 5px; - font-family: monospace; + font-family: var(--ui-display-font); + font-weight: 500; + text-transform: uppercase; + letter-spacing: -0.03em; } button:hover { @@ -364,10 +378,13 @@ textarea, color: var(--ui-dropdown-text); box-sizing: border-box; box-sizing: border-box; - font-family: sans-serif; + font-family: var(--ui-display-font); + font-weight: 500; + text-transform: uppercase; + letter-spacing: -0.03em; } -.custom-select-container:focus, +.custom-select-container:focus-visible, .custom-select-container:hover { border-color: var(--ui-input-focus-border); box-shadow: 0 0 5px rgba(121, 239, 164, 0.3); @@ -378,6 +395,10 @@ textarea, border: 1px solid var(--ui-main-color); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5); font-size: 0.9em; + font-family: var(--ui-display-font); + font-weight: 500; + text-transform: uppercase; + letter-spacing: -0.03em; } .custom-option:hover, From 12edb98d8c945206ab3aa9f8c44e19cdcd5a7853 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 02:36:51 +0200 Subject: [PATCH 46/75] Style: align editor and file browser controls --- src/components/FileBrowser.tsx | 19 +++++-------------- src/editor.css | 20 +++++++++++++++----- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/components/FileBrowser.tsx b/src/components/FileBrowser.tsx index d54627b..072e170 100644 --- a/src/components/FileBrowser.tsx +++ b/src/components/FileBrowser.tsx @@ -311,6 +311,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({ display: 'flex', flexDirection: 'column', padding: '10px', + fontSize: '12px', }} > <div @@ -421,25 +422,15 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({ <div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}> <button onClick={onCancel} - style={{ - padding: '5px 15px', - cursor: 'pointer', - background: '#333', - color: '#fff', - border: '1px solid #666', - }} + className="e-btn" + style={{ padding: '5px 15px' }} > Cancel </button> <button onClick={handleConfirm} - style={{ - padding: '5px 15px', - cursor: 'pointer', - background: 'var(--ui-selection-bg)', - color: 'var(--ui-selection-text)', - border: '1px solid var(--ui-main-color)', - }} + className="e-btn e-btn-enter" + style={{ padding: '5px 15px' }} > {mode === 'save' ? 'Save' : 'Load'} </button> diff --git a/src/editor.css b/src/editor.css index d3a9da6..080a66d 100644 --- a/src/editor.css +++ b/src/editor.css @@ -61,6 +61,11 @@ flex-shrink: 0; } +#editor-panel.editor-sidebar, +#se-panel.editor-sidebar { + padding: 0; +} + .editor-sidebar.left { border-right: 1px solid rgba(10, 10, 10, 0.95); } @@ -98,9 +103,9 @@ /* Controls */ .e-btn { - background: #111; - border: 1px solid #555; - color: #eee; + background: transparent; + border: 1px solid var(--ui-input-border); + color: var(--ui-fkey-text); padding: 4px 8px; font-family: var(--ui-display-font); font-weight: 500; @@ -113,9 +118,9 @@ } .e-btn:hover { - background: #333; + background: var(--ui-main-color); border-color: var(--ui-main-color); - color: #fff; + color: #000; } .e-btn-red { @@ -132,6 +137,11 @@ color: #ffa; } +.e-btn-enter { + border-color: var(--ui-input-focus-border); + box-shadow: 0 0 5px rgba(121, 239, 164, 0.2); +} + .e-btn-small { padding: 2px 4px; font-size: 0.85em; From 03477aae2d7c23f763db86508d97fb145a31ac36 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 03:55:56 +0200 Subject: [PATCH 47/75] Fix: restore editor section accent colors --- src/index.css | 196 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 192 insertions(+), 4 deletions(-) diff --git a/src/index.css b/src/index.css index 9e4a38e..bb21d9b 100644 --- a/src/index.css +++ b/src/index.css @@ -403,20 +403,20 @@ textarea, .custom-option:hover, .custom-option.focused { - background: var(--ui-dropdown-selection-bg); - color: #fff; + background: var(--ui-main-color); + color: #000; } .custom-option.selected { /* background: var(--ui-dropdown-selection-bg); REMOVED to avoid confusion */ - color: #fff; + color: #000; font-weight: bold; } .custom-option.selected::before { content: '✓'; margin-right: 5px; - color: var(--ui-main-color); + color: #000; } .file-upload { @@ -543,3 +543,191 @@ input[type='file'] { 16 16, pointer; } + +.ui-text-muted { + color: #888; +} + +.ui-text-dim { + color: #666; +} + +.ui-text-accent-blue { + color: #aaf !important; +} + +.ui-text-accent-red { + color: #faa !important; +} + +.ui-text-accent-yellow { + color: #ffaa00 !important; +} + +.ui-text-accent-green { + color: #79efa4 !important; +} + +.ui-text-accent-cyan { + color: #00ffff !important; +} + +.ui-text-light { + color: #ccc; +} + +.ui-text-bright { + color: #ddd; +} + +.ui-text-small { + font-size: 0.8em; +} + +.ui-text-tiny { + font-size: 0.75em; +} + +.ui-text-micro { + font-size: 10px; +} + +.ui-text-italic { + font-style: italic; +} + +.ui-font-bold { + font-weight: bold; +} + +.ui-inline-flex-center { + display: flex; + align-items: center; +} + +.ui-divider-blue { + border-top: 1px solid #aaf; +} + +.ui-divider-red { + border-top: 1px solid #faa; +} + +.ui-divider-yellow { + border-top: 1px solid #ffaa00; +} + +.ui-divider-neutral { + border-top: 1px solid #333; +} + +.ui-panel-card { + background: #222; +} + +.ui-panel-card-warm { + background: #332; + border: 1px solid #553; +} + +.ui-button-neutral { + background: #444; + border: 1px solid #666; + color: #fff; +} + +.file-browser-modal { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.8); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + font-family: monospace; + color: var(--ui-main-color); +} + +.file-browser-window { + width: 500px; + max-width: 95vw; + height: 600px; + max-height: 90vh; + background-color: #000; + border: 2px solid var(--ui-main-color); + display: flex; + flex-direction: column; + padding: 10px; + font-size: 12px; +} + +.file-browser-header { + border-bottom: 1px solid var(--ui-main-color); + margin-bottom: 10px; + padding-bottom: 5px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.file-browser-title-row { + display: flex; + align-items: baseline; + gap: 10px; +} + +.file-browser-path { + font-size: 12px; + color: #888; +} + +.file-browser-list { + flex: 1; + overflow-y: auto; + border: 1px solid #333; + margin-bottom: 10px; + outline: none; + position: relative; +} + +.file-browser-error { + color: #f66; +} + +.file-browser-empty { + color: #666; + padding: 5px; +} + +.file-browser-item { + padding: 5px; + cursor: pointer; + background-color: transparent; + color: var(--ui-main-color); + border: 1px solid transparent; +} + +.file-browser-item.is-dir { + color: #ffff00; +} + +.file-browser-item.is-selected { + background-color: var(--ui-selection-bg); + color: var(--ui-selection-text); + border-color: var(--ui-selection-bg); +} + +.file-browser-form-row { + display: flex; + margin-bottom: 10px; +} + +.file-browser-label { + width: 60px; +} + +.file-browser-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} From 7228d0a351a66cb5046a0a00e10bd6f40926f2a9 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 03:59:09 +0200 Subject: [PATCH 48/75] Content update --- public/scenes/logo.json | 513 ++++++++++ public/scenes/test_room (10).json | 139 ++- public/scenes/test_room.json | 1204 ++++++++++++++++++------ public/text/objects/CityView.json | 6 + public/text/objects/Desk.json | 2 +- public/text/objects/DeskDrawer-1.json | 6 + public/text/objects/Drawer1.json | 6 + public/text/objects/Trig_sub_D.json | 4 +- public/text/objects/room.json | 6 + public/text/objects/window1.json | 6 + public/text/scenes/logo.json | 4 + public/text/scenes/test_room.json | 4 +- public/text/system/parser-lexicon.json | 2 +- public/text/system/parser.json | 1 + 14 files changed, 1581 insertions(+), 322 deletions(-) create mode 100644 public/scenes/logo.json create mode 100644 public/text/objects/CityView.json create mode 100644 public/text/objects/DeskDrawer-1.json create mode 100644 public/text/objects/Drawer1.json create mode 100644 public/text/objects/room.json create mode 100644 public/text/objects/window1.json create mode 100644 public/text/scenes/logo.json diff --git a/public/scenes/logo.json b/public/scenes/logo.json new file mode 100644 index 0000000..f6efb28 --- /dev/null +++ b/public/scenes/logo.json @@ -0,0 +1,513 @@ +{ + "id": "logo", + "name": "New Scene", + "description": "You are in New Scene.", + "textRedirects": {}, + "filename": "logo", + "walkbox": [], + "triggerboxes": [], + "scaling": { + "enabled": true, + "min": 0.4, + "max": 1, + "horizon": 78, + "front": 421 + }, + "entities": [ + { + "name": "Quad_201", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": "#g,#a", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "3d-parallax" + } + ], + "layer": -1, + "visible": true, + "x": 334.7566652973353, + "y": 147.45349435034427, + "parallax": 1, + "ignoreScaling": false, + "vertices": [ + { + "x": -1449, + "y": 432, + "p": 0.2, + "binding": { + "targetName": "Quad_204", + "type": "vertex", + "index": 3 + } + }, + { + "x": 1528, + "y": 428, + "p": 0.2, + "binding": { + "targetName": "Quad_204", + "type": "vertex", + "index": 2 + } + }, + { + "x": 3915.4050968588786, + "y": 457.44748933663504, + "p": 1 + }, + { + "x": -3349.5949031411214, + "y": 457.44748933663504, + "p": 1 + } + ], + "color": "#000219", + "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", + "isGrid": true, + "gridLinesX": 29, + "gridLinesY": 7, + "lineWidth": 7.4, + "gridColor": "#136382", + "filled": true, + "blur": 0 + }, + { + "name": "Quad_1", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": "#a", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -1, + "visible": true, + "x": 363.1753332849686, + "y": -1342.0347499102, + "parallax": 1, + "ignoreScaling": false, + "vertices": [ + { + "x": -1387, + "y": -476, + "p": 0.2, + "binding": { + "targetName": "Quad", + "type": "vertex", + "index": 3 + } + }, + { + "x": 1652, + "y": -488, + "p": 0.2, + "binding": { + "targetName": "Quad", + "type": "vertex", + "index": 2 + } + }, + { + "x": 3933.8237648465147, + "y": -1042.0407549239112, + "p": 1 + }, + { + "x": -3331.1762351534853, + "y": -1042.0407549239112, + "p": 1 + } + ], + "color": "#000219", + "sortMode": "ignore", + "opacity": 1, + "blendMode": "source-over", + "isGrid": true, + "gridLinesX": 29, + "gridLinesY": 7, + "lineWidth": 7.4, + "gridColor": "#136382", + "filled": true, + "blur": 0 + }, + { + "name": "miles_ds", + "type": "Actor", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Shadow", + "shadowQuadId": "shadow", + "offsetX": -80, + "offsetY": -0.79232867459731, + "triggerId": "#g" + } + ], + "layer": 0, + "visible": true, + "x": -11.507373210367716, + "y": 437.34328111928414, + "width": 66.7529345238604, + "height": 272.57448263909663, + "baseWidth": 96, + "baseHeight": 392, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "miles_ds-idle-right.json", + "color": "#e27fa5", + "scale": 0.6953430679568792, + "modelScale": 1.03, + "parallax": 0.4538289363137056, + "ignoreScaling": false, + "animationSpeed": 30, + "opacity": 1, + "blendMode": "source-over", + "blur": 0, + "isPlayer": true, + "speed": 0.35, + "direction": "left", + "animSets": { + "idle": { + "id": "idle", + "up": "miles_ds-idle-up.json", + "down": "miles_ds-idle-down.json", + "left": null, + "right": "miles_ds-idle-right.json" + }, + "walk": { + "id": "walk", + "up": "miles_ds-walk-up.json", + "down": "miles_ds-walk-down.json", + "left": null, + "right": "miles_ds-walk-right.json" + } + } + }, + { + "name": "shadow", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": -67.17651736805739, + "y": 437.33802751880216, + "parallax": 1, + "ignoreScaling": false, + "vertices": [ + { + "x": -67.17651736805739, + "y": 437.33802751880216, + "p": 0.4523541075273739 + }, + { + "x": 5.443172833624047, + "y": 437.2945106625292, + "p": 0.452503989220326 + }, + { + "x": -5.703139435760795, + "y": 438.52482078346884, + "p": 0.48592782666534984 + }, + { + "x": -73.48973431343629, + "y": 438.4581791995139, + "p": 0.48411737462461735 + } + ], + "color": "#000000", + "sortMode": "ignore", + "opacity": 1, + "blendMode": "multiply", + "isGrid": false, + "gridLinesX": 5, + "gridLinesY": 5, + "lineWidth": 1, + "gridColor": "#ffffff", + "filled": true, + "blur": 4 + }, + { + "name": "logo_1", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": -71, + "y": 237, + "width": 439.65753424657544, + "height": 438.3150684931505, + "baseWidth": 392.5513698630138, + "baseHeight": 391.35273972602727, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "scanline_logo", + "color": "#AAAAAA", + "scale": 1.1199999999999999, + "modelScale": 2.8, + "parallax": 0.2, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 0.6, + "blendMode": "lighter", + "blur": 0 + }, + { + "name": "logo_2", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": -64, + "y": 346, + "width": 655.1999999999999, + "height": 668.64, + "baseWidth": 585, + "baseHeight": 597, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "scanline_logo", + "color": "#00ca4c", + "scale": 1.1199999999999999, + "modelScale": 2.8, + "parallax": 0.2, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 0.35, + "blendMode": "screen", + "blur": 32 + }, + { + "name": "Quad_204", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -2, + "visible": true, + "x": -306.3073195313695, + "y": 194.6463347631958, + "parallax": 1, + "ignoreScaling": false, + "vertices": [ + { + "x": -1132, + "y": 408, + "p": 0.2 + }, + { + "x": 1152, + "y": 388, + "p": 0.2 + }, + { + "x": 1528, + "y": 428, + "p": 0.2, + "binding": { + "targetName": "Quad_201", + "type": "vertex", + "index": 1 + } + }, + { + "x": -1449, + "y": 432, + "p": 0.2, + "binding": { + "targetName": "Quad_201", + "type": "vertex", + "index": 0 + } + } + ], + "color": "#ee00ff", + "sortMode": "ignore", + "opacity": 1, + "blendMode": "difference", + "isGrid": false, + "gridLinesX": 5, + "gridLinesY": 5, + "lineWidth": 1, + "gridColor": "#ffffff", + "filled": true, + "blur": 8 + }, + { + "name": "Quad", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -2, + "visible": true, + "x": 31.056518423854072, + "y": -469.3074715804131, + "parallax": 1, + "ignoreScaling": false, + "vertices": [ + { + "x": -914, + "y": -424, + "p": 0.2 + }, + { + "x": 1249, + "y": -424, + "p": 0.2 + }, + { + "x": 1652, + "y": -488, + "p": 0.2, + "binding": { + "targetName": "Quad_1", + "type": "vertex", + "index": 1 + } + }, + { + "x": -1387, + "y": -476, + "p": 0.2, + "binding": { + "targetName": "Quad_1", + "type": "vertex", + "index": 0 + } + } + ], + "color": "#ee00ff", + "sortMode": "ignore", + "opacity": 1, + "blendMode": "difference", + "isGrid": false, + "gridLinesX": 5, + "gridLinesY": 5, + "lineWidth": 1, + "gridColor": "#ffffff", + "filled": true, + "blur": 8 + }, + { + "name": "Quad_646", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": -1359.1857676091972, + "y": -530.9597165247759, + "parallax": 1, + "ignoreScaling": false, + "vertices": [ + { + "x": -1387, + "y": -476, + "p": 0.2, + "binding": { + "targetName": "Quad_1", + "type": "vertex", + "index": 0 + } + }, + { + "x": 1550.7, + "y": -487.6, + "p": 0.2, + "binding": { + "targetName": "Quad_1", + "type": "grid", + "gridU": 0.9666666666666667, + "gridV": 0 + } + }, + { + "x": 1528, + "y": 428, + "p": 0.2, + "binding": { + "targetName": "Quad_201", + "type": "vertex", + "index": 1 + } + }, + { + "x": -1449, + "y": 432, + "p": 0.2, + "binding": { + "targetName": "Quad_201", + "type": "vertex", + "index": 0 + } + } + ], + "color": "#020440", + "sortMode": "ignore", + "opacity": 0.55, + "blendMode": "source-over", + "isGrid": false, + "gridLinesX": 5, + "gridLinesY": 5, + "lineWidth": 1, + "gridColor": "#ffffff", + "filled": true, + "blur": 20 + } + ], + "camera": { + "x": 165.57094204403754, + "y": -263.4392820164094, + "zoom": 0.1500946352969992 + }, + "autoCenter": true, + "cameraSpeed": 5, + "camDeadzoneX": 50, + "camDeadzoneY": 30, + "camMaxY": -370 +} diff --git a/public/scenes/test_room (10).json b/public/scenes/test_room (10).json index c27497b..6151972 100644 --- a/public/scenes/test_room (10).json +++ b/public/scenes/test_room (10).json @@ -118,12 +118,8 @@ "targetGroupId": "#D" } ], - "layer": 1, + "layer": 0, "visible": true, - "spatial": { - "parentNodeId": "Desk", - "relation": "in" - }, "poly": [ { "x": 27, @@ -165,7 +161,7 @@ "type": "Triggerbox", "locked": false, "disabled": true, - "groupID": "", + "groupID": null, "customName": "", "textRedirects": {}, "interactions": {}, @@ -180,7 +176,7 @@ "sound2": "drawer_close.wav" } ], - "layer": 1, + "layer": 0, "visible": true, "spatial": { "parentNodeId": "Trig_sub_D", @@ -241,8 +237,12 @@ "sound2": "drawer_close.wav" } ], - "layer": 1, + "layer": 0, "visible": true, + "spatial": { + "parentNodeId": "Trig_sub_D", + "relation": "in" + }, "poly": [ { "x": -156.29411764705878, @@ -273,7 +273,7 @@ "textRedirects": {}, "interactions": {}, "components": [], - "layer": 1, + "layer": 0, "visible": true, "poly": [ { @@ -376,6 +376,10 @@ "components": [], "layer": -2, "visible": true, + "spatial": { + "parentNodeId": "window1", + "relation": "in" + }, "x": 119.67896209456934, "y": 234, "width": 821.6, @@ -466,23 +470,31 @@ "customName": "", "textRedirects": {}, "interactions": {}, - "components": [], + "components": [ + { + "type": "Shadow", + "shadowQuadId": "shadow", + "offsetX": -70, + "offsetY": -25, + "triggerId": "room" + } + ], "layer": 0, "visible": true, - "x": 396.14497334712604, - "y": 211.843824888123, - "width": 66.1903653250774, - "height": 270.2773250773994, + "x": 232.86186143649797, + "y": 223.85074125381902, + "width": 71.03999999999999, + "height": 290.08, "baseWidth": 96, "baseHeight": 392, "colliderWidth": 88, "colliderHeight": 4, "spriteName": "miles_ds-idle-right.json", "color": "#00ffff", - "scale": 0.6894829721362229, + "scale": 0.74, "modelScale": 0.74, - "parallax": 1.016927024539739, - "ignoreScaling": false, + "parallax": 1.024795357575403, + "ignoreScaling": true, "animationSpeed": 30, "opacity": 1, "blendMode": "source-over", @@ -844,8 +856,8 @@ "components": [], "layer": 0, "visible": true, - "x": 222.90433731748038, - "y": 306.9284515529541, + "x": 222.9043373174802, + "y": 306.9284515529539, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -856,7 +868,7 @@ "color": "#36d87fff", "scale": 0.8, "modelScale": 0.8, - "parallax": 1.079038203629382, + "parallax": 1.0790382036293817, "ignoreScaling": false, "animationSpeed": 150, "opacity": 1, @@ -976,7 +988,7 @@ "name": "Drawer1", "type": "Entity", "locked": false, - "disabled": true, + "disabled": false, "groupID": null, "customName": "", "textRedirects": {}, @@ -985,7 +997,7 @@ "layer": 0, "visible": true, "spatial": { - "parentNodeId": "Trig_sub_D", + "parentNodeId": "Desk", "relation": "in" }, "x": 90.66834500947778, @@ -1006,6 +1018,87 @@ "opacity": 0, "blendMode": "source-over", "blur": 0 + }, + { + "name": "window1", + "type": "Entity", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -2, + "visible": true, + "x": 56, + "y": -192, + "width": 27.3, + "height": 27.3, + "baseWidth": 30, + "baseHeight": 30, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#AAAAAA", + "scale": 0.91, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "shadow", + "type": "Quad", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": 177.40625891347847, + "y": 204.99379689971948, + "parallax": 1, + "ignoreScaling": false, + "vertices": [ + { + "x": 177.40625891347847, + "y": 204.99379689971948, + "p": 1.0124869315719702 + }, + { + "x": 273.3546785024226, + "y": 203.45494893614816, + "p": 1.0114617012261051 + }, + { + "x": 262.5537137492114, + "y": 249.54350071101484, + "p": 1.0415316998111208 + }, + { + "x": 212.71215117336098, + "y": 247.59187519264492, + "p": 1.0402645533908195 + } + ], + "color": "#000975", + "sortMode": "ignore", + "opacity": 0.6, + "blendMode": "multiply", + "isGrid": false, + "gridLinesX": 5, + "gridLinesY": 5, + "lineWidth": 1, + "gridColor": "#ffffff", + "filled": true, + "blur": 7 } ], "camera": { @@ -1019,4 +1112,4 @@ "camDeadzoneY": -21, "camMinX": 143, "camMaxY": 45 -} \ No newline at end of file +} diff --git a/public/scenes/test_room.json b/public/scenes/test_room.json index 35c6297..cf76365 100644 --- a/public/scenes/test_room.json +++ b/public/scenes/test_room.json @@ -1,142 +1,468 @@ { "id": "test_room", - "name": "New Scene", - "description": "You are in New Scene.", + "name": "Test Room", + "description": "You are in Test Room.", "textRedirects": {}, "filename": "test_room", - "walkbox": [], - "triggerboxes": [], - "scaling": { - "enabled": true, - "min": 0.4, - "max": 1, - "horizon": 78, - "front": 421 - }, - "entities": [ + "walkbox": [ { - "name": "Quad_201", - "type": "Quad", + "name": "Walk_997", + "type": "Walkbox", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "poly": [ + { + "x": -79, + "y": 346 + }, + { + "x": -81, + "y": 272 + }, + { + "x": 153, + "y": 272 + }, + { + "x": 153, + "y": 346 + } + ], + "mode": "Add" + }, + { + "name": "Walk_176", + "type": "Walkbox", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "poly": [ + { + "x": 82, + "y": 192 + }, + { + "x": 407, + "y": 195 + }, + { + "x": 408, + "y": 203 + }, + { + "x": 470, + "y": 207 + }, + { + "x": 596, + "y": 220 + }, + { + "x": 624, + "y": 211 + }, + { + "x": 680, + "y": 211 + }, + { + "x": 680, + "y": 297 + }, + { + "x": -234, + "y": 291 + }, + { + "x": -210, + "y": 247 + }, + { + "x": -111, + "y": 249 + }, + { + "x": 77, + "y": 194 + } + ], + "mode": "Add" + } + ], + "triggerboxes": [ + { + "name": "Trig_sub_D", + "type": "Triggerbox", "locked": false, "disabled": false, - "groupID": "#g,#a", + "groupID": null, "customName": "", "textRedirects": {}, "interactions": {}, "components": [ { - "type": "3d-parallax" + "type": "Subscene", + "targetGroupId": "#D" } ], - "layer": -1, + "layer": 0, "visible": true, - "spatial": {}, - "x": 334.7566652973353, - "y": 147.45349435034427, - "parallax": 1, - "ignoreScaling": false, - "vertices": [ + "poly": [ { - "x": -1449.024219762899, - "y": 432.42805173925444, - "p": 0.2, - "binding": { - "targetName": "Quad_646", - "type": "vertex", - "index": 3 - } + "x": 27, + "y": 210 }, { - "x": 1527.975780237101, - "y": 428.42805173925444, - "p": 0.2, - "binding": { - "targetName": "Quad_646", - "type": "vertex", - "index": 2 - } + "x": 28, + "y": 104 }, { - "x": 3915.4050968588786, - "y": 457.44748933663504, - "p": 1 + "x": 84, + "y": 94 }, { - "x": -3349.5949031411214, - "y": 457.44748933663504, - "p": 1 + "x": 83, + "y": 136 + }, + { + "x": 54, + "y": 145 + }, + { + "x": 50, + "y": 160 + }, + { + "x": 76, + "y": 170 + }, + { + "x": 79, + "y": 194 } ], - "color": "#000219", - "sortMode": "ignore", - "opacity": 1, - "blendMode": "source-over", - "isGrid": true, - "gridLinesX": 29, - "gridLinesY": 7, - "lineWidth": 7.4, - "gridColor": "#150f67", - "filled": true, - "blur": 0 + "script": "" }, { - "name": "Quad_1", - "type": "Quad", + "name": "sub_sw_d1", + "type": "Triggerbox", + "locked": false, + "disabled": true, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Switch", + "groupId1": "nil", + "groupId2": "#D1", + "state": 1, + "idKey": "", + "sound1": "drawer_open.wav", + "sound2": "drawer_close.wav" + } + ], + "layer": 0, + "visible": true, + "spatial": { + "parentNodeId": "Trig_sub_D", + "relation": "in" + }, + "poly": [ + { + "x": -156.29411764705887, + "y": -171.3529411764706 + }, + { + "x": 425, + "y": -171 + }, + { + "x": 414, + "y": -95 + }, + { + "x": -143, + "y": -95 + } + ], + "script": "" + }, + { + "name": "Trig_834", + "type": "Triggerbox", "locked": false, "disabled": false, - "groupID": "#a", + "groupID": null, "customName": "", "textRedirects": {}, "interactions": {}, "components": [], - "layer": -1, + "layer": 0, "visible": true, - "spatial": {}, - "x": 363.1753332849686, - "y": -1342.0347499102, - "parallax": 1, - "ignoreScaling": false, - "vertices": [ + "poly": [], + "script": "" + }, + { + "name": "sub_sw_d2", + "type": "Triggerbox", + "locked": false, + "disabled": true, + "groupID": "#D ", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ { - "x": -1387.0242197628988, - "y": -475.57194826074556, - "p": 0.2, - "binding": { - "targetName": "Quad_646", - "type": "vertex", - "index": 0 - } + "type": "Switch", + "groupId1": "nil", + "groupId2": "#D2", + "state": 1, + "idKey": "", + "sound1": "drawer_open.wav", + "sound2": "drawer_close.wav" + } + ], + "layer": 0, + "visible": true, + "spatial": { + "parentNodeId": "Trig_sub_D", + "relation": "in" + }, + "poly": [ + { + "x": -156.29411764705878, + "y": -87.03921568627447 }, { - "x": 1652, - "y": -488, - "p": 0.2 + "x": 424.9999999999999, + "y": -86.68627450980387 }, { - "x": 3933.8237648465147, - "y": -1042.0407549239112, - "p": 1 + "x": 413.9999999999999, + "y": -10.68627450980393 }, { - "x": -3331.1762351534853, - "y": -1042.0407549239112, - "p": 1 + "x": -142.99999999999994, + "y": -10.68627450980393 } ], - "color": "#000219", - "sortMode": "ignore", + "script": "" + }, + { + "name": "Desk", + "type": "Triggerbox", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "poly": [ + { + "x": -210, + "y": 211 + }, + { + "x": -192, + "y": 213 + }, + { + "x": -192, + "y": 197 + }, + { + "x": -171, + "y": 189 + }, + { + "x": -159, + "y": 211 + }, + { + "x": -136, + "y": 217 + }, + { + "x": -131, + "y": 242 + }, + { + "x": -109, + "y": 243 + }, + { + "x": -110, + "y": 146 + }, + { + "x": -18, + "y": 129 + }, + { + "x": -16, + "y": 201 + }, + { + "x": 26, + "y": 207 + }, + { + "x": 82, + "y": 193 + }, + { + "x": 87, + "y": 95 + }, + { + "x": -70, + "y": 78 + }, + { + "x": -236, + "y": 115 + }, + { + "x": -235, + "y": 173 + }, + { + "x": -218, + "y": 182 + }, + { + "x": -212, + "y": 211 + } + ], + "script": "" + } + ], + "scaling": { + "enabled": true, + "min": 0.91, + "max": 1, + "horizon": 193, + "front": 269 + }, + "entities": [ + { + "name": "CityView", + "type": "Entity", + "locked": true, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -2, + "visible": true, + "spatial": { + "parentNodeId": "window1", + "relation": "in" + }, + "x": 119.67896209456934, + "y": 234, + "width": 821.6, + "height": 551.2, + "baseWidth": 1264, + "baseHeight": 848, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "window-view.json", + "color": "#00ff00", + "scale": 0.65, + "modelScale": 0.65, + "parallax": 0.4, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "room", + "type": "Entity", + "locked": true, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": -1, + "visible": true, + "x": 199, + "y": 297, + "width": 884.8, + "height": 593.5999999999999, + "baseWidth": 1264, + "baseHeight": 848, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "room2", + "color": "#888888", + "scale": 0.7, + "modelScale": 0.7, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, "opacity": 1, "blendMode": "source-over", - "isGrid": true, - "gridLinesX": 29, - "gridLinesY": 7, - "lineWidth": 7.4, - "gridColor": "#150f67", - "filled": true, "blur": 0 }, { - "name": "miles_ds", + "name": "Chair", + "type": "Entity", + "locked": true, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": 100, + "y": 249, + "width": 116.89999999999999, + "height": 198.79999999999998, + "baseWidth": 167, + "baseHeight": 284, + "colliderWidth": 78, + "colliderHeight": 18, + "spriteName": "chair.json", + "color": "#00ff00", + "scale": 0.7, + "modelScale": 0.7, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "Hero_1", "type": "Actor", "locked": false, "disabled": false, @@ -148,34 +474,33 @@ { "type": "Shadow", "shadowQuadId": "shadow", - "offsetX": -80, - "offsetY": -0.79232867459731, - "triggerId": "#g" + "offsetX": -70, + "offsetY": -25, + "triggerId": "room" } ], "layer": 0, "visible": true, - "spatial": {}, - "x": -127.4690574074319, - "y": 441.3830181593477, - "width": 124.98187149421459, - "height": 301.6537762607278, + "x": 306.351468358703, + "y": 227.84447142883158, + "width": 119.88, + "height": 289.34, "baseWidth": 162, "baseHeight": 391, - "colliderWidth": 0, - "colliderHeight": 0, + "colliderWidth": 88, + "colliderHeight": 4, "spriteName": "miles_ds-idle-down.json", - "color": "#e27fa5", - "scale": 0.7714930339149049, - "modelScale": 1.03, - "parallax": 0.5571390073048871, - "ignoreScaling": false, + "color": "#00ffff", + "scale": 0.74, + "modelScale": 0.74, + "parallax": 1.0273839007520702, + "ignoreScaling": true, "animationSpeed": 30, "opacity": 1, "blendMode": "source-over", "blur": 0, "isPlayer": true, - "speed": 0.35, + "speed": 0.24, "direction": "down", "animSets": { "idle": { @@ -195,7 +520,278 @@ } }, { - "name": "shadow", + "name": "Black_1", + "type": "Entity", + "locked": true, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": -328, + "y": 307, + "width": 171.2340644206598, + "height": 637.1050459736764, + "baseWidth": 155.6673312915089, + "baseHeight": 579.1864054306149, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#000000", + "scale": 1.1, + "modelScale": 1.1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D_main", + "type": "Entity", + "locked": true, + "disabled": true, + "groupID": "#D", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": 135, + "y": 310, + "width": 614.4, + "height": 484.79999999999995, + "baseWidth": 1024, + "baseHeight": 808, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_main", + "color": "#00ff00", + "scale": 0.6, + "modelScale": 0.6, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D2_body", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": "#D2", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Subtrigger", + "target": "sub_sw_d2" + } + ], + "layer": 3, + "visible": true, + "x": 134, + "y": 311, + "width": 614.4, + "height": 407.4, + "baseWidth": 1024, + "baseHeight": 679, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_d2.json", + "color": "#00ff00", + "scale": 0.6, + "modelScale": 0.6, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D1_body", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": "#D1", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 4, + "visible": true, + "x": 136, + "y": -4, + "width": 614.4, + "height": 177.6, + "baseWidth": 1024, + "baseHeight": 296, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_d1_body.json", + "color": "#00ff00", + "scale": 0.6, + "modelScale": 0.6, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "miles_id", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": "#D1", + "customName": "your ID card", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Item", + "ignoreDistance": true + } + ], + "layer": 5, + "visible": true, + "spatial": { + "parentNodeId": "Drawer1", + "relation": "in" + }, + "x": 93, + "y": 0, + "width": 150.4436263347707, + "height": 93.2343137254902, + "baseWidth": 165.32266630194582, + "baseHeight": 102.45528980823099, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_d1_id.json", + "color": "#AAAAAA", + "scale": 0.91, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D1_stuff", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": "#D1", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 5, + "visible": true, + "spatial": { + "parentNodeId": "Drawer1", + "relation": "in" + }, + "x": 320, + "y": 184, + "width": 979, + "height": 412, + "baseWidth": 979, + "baseHeight": 412, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_d1_items.json", + "color": "#00ff00", + "scale": 1, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D_main_top", + "type": "Entity", + "locked": true, + "disabled": true, + "groupID": "#D", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 6, + "visible": true, + "x": 135, + "y": -176, + "width": 614.4, + "height": 129.6, + "baseWidth": 1024, + "baseHeight": 216, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_top.json", + "color": "#00ff00", + "scale": 0.6, + "modelScale": 0.6, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "sub_D1_fasade", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": "#D1", + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Subtrigger", + "target": "sub_sw_d1" + } + ], + "layer": 6, + "visible": true, + "x": 135, + "y": 66, + "width": 614.4, + "height": 105.6, + "baseWidth": 1024, + "baseHeight": 176, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sub_drawers_d1_facade.json", + "color": "#00ff00", + "scale": 0.6, + "modelScale": 0.6, + "parallax": 1, + "ignoreScaling": true, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "floor-parallax", "type": "Quad", "locked": false, "disabled": false, @@ -203,50 +799,88 @@ "customName": "", "textRedirects": {}, "interactions": {}, - "components": [], + "components": [ + { + "type": "3d-parallax" + } + ], "layer": 0, "visible": true, - "spatial": {}, - "x": -189.00551991317218, - "y": 441.3284402319271, + "x": -146.13725490196006, + "y": 289.60784313725503, "parallax": 1, "ignoreScaling": false, "vertices": [ { - "x": -189.00551991317218, - "y": 441.3284402319271, - "p": 0.5556344177762325 + "x": -205.9411764705876, + "y": 185.68627450980406, + "p": 1 }, { - "x": -103.9971337942764, - "y": 441.32987652161404, - "p": 0.5556740130215123 + "x": 708, + "y": 186, + "p": 1 }, { - "x": -117.29283788247513, - "y": 442.9655924229333, - "p": 0.6007669861938634 + "x": 766, + "y": 339, + "p": 1.1 }, { - "x": -196.6341530469488, - "y": 442.8769914298106, - "p": 0.5983244580001196 + "x": -196, + "y": 339, + "p": 1.1 } ], - "color": "#000000", + "color": "#52779aff", "sortMode": "ignore", - "opacity": 1, - "blendMode": "multiply", + "opacity": 0, + "blendMode": "source-over", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", "filled": true, - "blur": 4 + "blur": 0 + }, + { + "name": "Actor_562", + "type": "Actor", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 0, + "visible": true, + "x": 222.9043373174802, + "y": 306.9284515529539, + "width": 1008.8000000000001, + "height": 90.39999999999999, + "baseWidth": 1261, + "baseHeight": 112.99999999999999, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "sofa", + "color": "#36d87fff", + "scale": 0.8, + "modelScale": 0.8, + "parallax": 1.0790382036293817, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0, + "isPlayer": false, + "speed": 0.1, + "direction": "down", + "animSets": {} }, { - "name": "logo_1", + "name": "boombox", "type": "Entity", "locked": false, "disabled": false, @@ -257,28 +891,27 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, - "x": -71, - "y": 237, - "width": 439.65753424657544, - "height": 438.3150684931505, - "baseWidth": 392.5513698630138, - "baseHeight": 391.35273972602727, + "x": -152, + "y": -11, + "width": 112, + "height": 47, + "baseWidth": 123.07692307692307, + "baseHeight": 51.64835164835165, "colliderWidth": 0, "colliderHeight": 0, - "spriteName": "scanline_logo", + "spriteName": null, "color": "#AAAAAA", - "scale": 1.1199999999999999, - "modelScale": 2.8, - "parallax": 0.2, + "scale": 0.91, + "modelScale": 1, + "parallax": 1, "ignoreScaling": false, "animationSpeed": 150, - "opacity": 0.6, - "blendMode": "lighter", + "opacity": 0, + "blendMode": "source-over", "blur": 0 }, { - "name": "logo_2", + "name": "test", "type": "Entity", "locked": false, "disabled": false, @@ -286,32 +919,74 @@ "customName": "", "textRedirects": {}, "interactions": {}, - "components": [], + "components": [ + { + "type": "Item" + } + ], "layer": 0, "visible": true, - "spatial": {}, - "x": -64, - "y": 346, - "width": 655.1999999999999, - "height": 668.64, - "baseWidth": 585, - "baseHeight": 597, + "spatial": { + "parentNodeId": "Chair", + "relation": "under" + }, + "x": 98, + "y": 243, + "width": 31.114048235454423, + "height": 35.128764136803376, + "baseWidth": 32.10246627605941, + "baseHeight": 36.24471998909933, "colliderWidth": 0, "colliderHeight": 0, - "spriteName": "scanline_logo", - "color": "#00ca4c", - "scale": 1.1199999999999999, - "modelScale": 2.8, - "parallax": 0.2, + "spriteName": null, + "color": "#AAAAAA", + "scale": 0.9692105263157895, + "modelScale": 1, + "parallax": 1, "ignoreScaling": false, "animationSpeed": 150, - "opacity": 0.35, - "blendMode": "screen", - "blur": 32 + "opacity": 1, + "blendMode": "source-over", + "blur": 0 }, { - "name": "Quad_204", - "type": "Quad", + "name": "Static_649", + "type": "Entity", + "locked": false, + "disabled": true, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [], + "layer": 15, + "visible": true, + "spatial": { + "parentNodeId": "Drawer3", + "relation": "in" + }, + "x": 138, + "y": 101, + "width": 492, + "height": 58, + "baseWidth": 540.6593406593406, + "baseHeight": 63.73626373626374, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#b83ed0", + "scale": 0.91, + "modelScale": 1, + "parallax": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 + }, + { + "name": "Drawer1", + "type": "Entity", "locked": false, "disabled": false, "groupID": null, @@ -319,50 +994,34 @@ "textRedirects": {}, "interactions": {}, "components": [], - "layer": -2, + "layer": 0, "visible": true, - "spatial": {}, - "x": -306.3073195313695, - "y": 194.6463347631958, + "spatial": { + "parentNodeId": "Desk", + "relation": "in" + }, + "x": 90.66834500947778, + "y": 120, + "width": 27.3, + "height": 27.3, + "baseWidth": 30, + "baseHeight": 30, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#AAAAAA", + "scale": 0.91, + "modelScale": 1, "parallax": 1, "ignoreScaling": false, - "vertices": [ - { - "x": -502.7847187282088, - "y": 399.7558482412977, - "p": 0.2 - }, - { - "x": 670.2152812717912, - "y": 390.7558482412977, - "p": 0.2 - }, - { - "x": 1335, - "y": 425.61919420555853, - "p": 0.2 - }, - { - "x": -1193, - "y": 431.61919420555853, - "p": 0.2 - } - ], - "color": "#ee00ff", - "sortMode": "ignore", - "opacity": 0.87, - "blendMode": "difference", - "isGrid": false, - "gridLinesX": 5, - "gridLinesY": 5, - "lineWidth": 1, - "gridColor": "#ffffff", - "filled": true, - "blur": 6 + "animationSpeed": 150, + "opacity": 0, + "blendMode": "source-over", + "blur": 0 }, { - "name": "Quad", - "type": "Quad", + "name": "window1", + "type": "Entity", "locked": false, "disabled": false, "groupID": null, @@ -372,47 +1031,27 @@ "components": [], "layer": -2, "visible": true, - "spatial": {}, - "x": 31.056518423854072, - "y": -469.3074715804131, + "x": 56, + "y": -192, + "width": 27.3, + "height": 27.3, + "baseWidth": 30, + "baseHeight": 30, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": null, + "color": "#AAAAAA", + "scale": 0.91, + "modelScale": 1, "parallax": 1, "ignoreScaling": false, - "vertices": [ - { - "x": -549.0858409402506, - "y": -481.4891445625435, - "p": 0.2 - }, - { - "x": 623.9141590597494, - "y": -490.4891445625435, - "p": 0.2 - }, - { - "x": 1288.698877787958, - "y": -455.6257985982827, - "p": 0.2 - }, - { - "x": -1239.301122212042, - "y": -449.6257985982827, - "p": 0.2 - } - ], - "color": "#ee00ff", - "sortMode": "ignore", - "opacity": 0.87, - "blendMode": "difference", - "isGrid": false, - "gridLinesX": 5, - "gridLinesY": 5, - "lineWidth": 1, - "gridColor": "#ffffff", - "filled": true, - "blur": 6 + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0 }, { - "name": "Quad_646", + "name": "shadow", "type": "Quad", "locked": false, "disabled": false, @@ -423,75 +1062,54 @@ "components": [], "layer": 0, "visible": true, - "spatial": {}, - "x": -1359.1857676091972, - "y": -530.9597165247759, + "x": 251.56334401080537, + "y": 208.4319364013629, "parallax": 1, "ignoreScaling": false, "vertices": [ { - "x": -1387.0242197628988, - "y": -475.57194826074556, - "p": 0.2, - "binding": { - "targetName": "Quad_1", - "type": "vertex", - "index": 0 - } - }, - { - "x": 1550.6991926745702, - "y": -487.5857316086915, - "p": 0.2, - "binding": { - "targetName": "Quad_1", - "type": "grid", - "gridU": 0.9666666666666667, - "gridV": 0 - } - }, - { - "x": 1527.975780237101, - "y": 428.42805173925444, - "p": 0.2, - "binding": { - "targetName": "Quad_201", - "type": "vertex", - "index": 1 - } - }, - { - "x": -1449.024219762899, - "y": 432.42805173925444, - "p": 0.2, - "binding": { - "targetName": "Quad_201", - "type": "vertex", - "index": 0 - } + "x": 251.56334401080537, + "y": 208.4319364013629, + "p": 1.014733918039131 + }, + { + "x": 347.5117635997495, + "y": 206.89308843779156, + "p": 1.013708687693266 + }, + { + "x": 336.71084835449676, + "y": 252.9816450467687, + "p": 1.0437788529717444 + }, + { + "x": 286.86923627068785, + "y": 251.03001469428833, + "p": 1.0425115398579803 } ], - "color": "#020440", + "color": "#000975", "sortMode": "ignore", - "opacity": 0.55, - "blendMode": "source-over", + "opacity": 0.6, + "blendMode": "multiply", "isGrid": false, "gridLinesX": 5, "gridLinesY": 5, "lineWidth": 1, "gridColor": "#ffffff", "filled": true, - "blur": 20 + "blur": 7 } ], "camera": { - "x": 165.57094204403754, - "y": -263.4392820164094, - "zoom": 0.1500946352969992 + "x": 297, + "y": 29, + "zoom": 0.51 }, - "autoCenter": true, - "cameraSpeed": 5, - "camDeadzoneX": 50, - "camDeadzoneY": 30, - "camMaxY": -370 -} \ No newline at end of file + "autoCenter": false, + "cameraSpeed": 1.5, + "camDeadzoneX": 200, + "camDeadzoneY": -21, + "camMinX": 143, + "camMaxY": 45 +} diff --git a/public/text/objects/CityView.json b/public/text/objects/CityView.json new file mode 100644 index 0000000..aa81c71 --- /dev/null +++ b/public/text/objects/CityView.json @@ -0,0 +1,6 @@ +{ + "title": "City view", + "description": "You look out over San Vesper as it comes awake by slow degrees. In the distance, the towers of Civic Crown catch the first pale gold of dawn, their edges softened by bay fog and the city's lingering smog. \rFrom here, the skyline feels strangely remote: clean lines, cold light, a promise that belongs to someone else. Morning spills through the bare window glass without obstruction, and for a moment the city looks almost gentle, though never quite harmless.", + "details": "", + "synonyms": ["view", "landscape", "city"] +} diff --git a/public/text/objects/Desk.json b/public/text/objects/Desk.json index ea4d3b7..807d07c 100644 --- a/public/text/objects/Desk.json +++ b/public/text/objects/Desk.json @@ -1,6 +1,6 @@ { "title": "Desk", - "description": "A large desk where the computer and everything connected to it reigns supreme. The desk has three drawers.", + "description": "A large desk where the computer and everything connected to it reigns supreme. It is crowded in the way only a real working desk can be. The desk has three drawers.", "details": "", "synonyms": ["table", "workplace"] } diff --git a/public/text/objects/DeskDrawer-1.json b/public/text/objects/DeskDrawer-1.json new file mode 100644 index 0000000..b559281 --- /dev/null +++ b/public/text/objects/DeskDrawer-1.json @@ -0,0 +1,6 @@ +{ + "title": "upper drawer", + "description": "Yoy see upper drawer of the desk", + "details": "", + "synonyms": ["drawer 1", "first drawer"] +} diff --git a/public/text/objects/Drawer1.json b/public/text/objects/Drawer1.json new file mode 100644 index 0000000..31bfa38 --- /dev/null +++ b/public/text/objects/Drawer1.json @@ -0,0 +1,6 @@ +{ + "title": "upper drawer", + "description": "", + "details": "", + "synonyms": [] +} diff --git a/public/text/objects/Trig_sub_D.json b/public/text/objects/Trig_sub_D.json index 9772b8a..59c2c02 100644 --- a/public/text/objects/Trig_sub_D.json +++ b/public/text/objects/Trig_sub_D.json @@ -1,6 +1,6 @@ { - "title": "Table drawers", - "description": "The table have 3 drawers.", + "title": "Desk drawers", + "description": "The desk have 3 drawers.", "details": "", "synonyms": [] } diff --git a/public/text/objects/room.json b/public/text/objects/room.json new file mode 100644 index 0000000..da6fbc4 --- /dev/null +++ b/public/text/objects/room.json @@ -0,0 +1,6 @@ +{ + "title": "room", + "description": "You see nothing special.", + "details": "", + "synonyms": [] +} diff --git a/public/text/objects/window1.json b/public/text/objects/window1.json new file mode 100644 index 0000000..4f2f056 --- /dev/null +++ b/public/text/objects/window1.json @@ -0,0 +1,6 @@ +{ + "title": "Window", + "description": "The windows in this building are large, as was fashionable in the mid-century. They are rarely opened due to smog, noise, and the heavy steel frames that often jam.", + "details": "", + "synonyms": [] +} diff --git a/public/text/scenes/logo.json b/public/text/scenes/logo.json new file mode 100644 index 0000000..faa8b8b --- /dev/null +++ b/public/text/scenes/logo.json @@ -0,0 +1,4 @@ +{ + "title": "New Scene", + "description": "You are in New Scene." +} diff --git a/public/text/scenes/test_room.json b/public/text/scenes/test_room.json index faa8b8b..8d9cc7f 100644 --- a/public/text/scenes/test_room.json +++ b/public/text/scenes/test_room.json @@ -1,4 +1,4 @@ { - "title": "New Scene", - "description": "You are in New Scene." + "title": "Mile's Home", + "description": "You look toward the working corner of the apartment. The desk stands against the wall perpendicular to the window, with a shelf of books above it that makes the space feel close, personal, and lived in. It is cozy, but not tidy. There is the usual kind of programmer's clutter everywhere: cables coiled and tangled across the floor, Post-it notes and scraps of paper with old reminders, crumpled printouts of BASIC and assembler code, five-inch floppy disks, and the small debris of long hours spent thinking at a machine. Across the room, against the wall opposite the window, sits the sofa, set so you could watch the television or turn and look out at the city. The whole room feels warm, familiar, and comfortably disordered, like a place you genuinely likes to work in." } diff --git a/public/text/system/parser-lexicon.json b/public/text/system/parser-lexicon.json index 7b759df..609985a 100644 --- a/public/text/system/parser-lexicon.json +++ b/public/text/system/parser-lexicon.json @@ -50,7 +50,7 @@ "relationMarkers": { "on": ["on"], "under": ["under", "beneath"], - "in": ["in", "inside"], + "in": ["in", "inside", "into"], "behind": ["behind"], "near": ["near", "next to", "by"] } diff --git a/public/text/system/parser.json b/public/text/system/parser.json index c61a802..0613008 100644 --- a/public/text/system/parser.json +++ b/public/text/system/parser.json @@ -10,6 +10,7 @@ "examine_relation_prompt": "Examine what area?", "relation_empty": "You see nothing {relation} the {target}.", "relation_contents": "{Relation} the {target} you see: {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?", "take_which_one": "Which item do you mean: {options}?", From 0e7ab8ced29c53753ef91fa18ad8093ae6d0aae9 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 04:00:45 +0200 Subject: [PATCH 49/75] Feature: improve editor overlays and UI polish --- src/components/FileBrowser.tsx | 102 +++--------------- src/components/GameCanvas.tsx | 26 ++++- src/components/editor/PropertiesPanel.tsx | 120 +++++++--------------- src/core/Game.ts | 36 ++++++- src/core/TextAssetManager.ts | 7 +- src/tools/SceneEditor.ts | 20 ++-- 6 files changed, 124 insertions(+), 187 deletions(-) diff --git a/src/components/FileBrowser.tsx b/src/components/FileBrowser.tsx index 072e170..dd3157e 100644 --- a/src/components/FileBrowser.tsx +++ b/src/components/FileBrowser.tsx @@ -281,53 +281,12 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({ }, [onCancel]); return ( - <div - className="modal-overlay" - style={{ - position: 'fixed', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0,0,0,0.8)', - zIndex: 10000, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontFamily: 'monospace', - color: 'var(--ui-main-color)', - }} - > - <div - ref={modalRef} - className="file-browser" - style={{ - width: '500px', - maxWidth: '95vw', - height: '600px', - maxHeight: '90vh', - backgroundColor: '#000', - border: '2px solid var(--ui-main-color)', - display: 'flex', - flexDirection: 'column', - padding: '10px', - fontSize: '12px', - }} - > - <div - className="browser-header" - style={{ - borderBottom: '1px solid var(--ui-main-color)', - marginBottom: '10px', - paddingBottom: '5px', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - }} - > - <div style={{ display: 'flex', alignItems: 'baseline', gap: '10px' }}> + <div className="file-browser-modal"> + <div ref={modalRef} className="file-browser-window"> + <div className="file-browser-header"> + <div className="file-browser-title-row"> <h3 style={{ margin: 0 }}>{title || (mode === 'save' ? 'Save File' : 'Load File')}</h3> - <span style={{ fontSize: '12px', color: '#888' }}>{currentPath}</span> + <span className="file-browser-path">{currentPath}</span> </div> <button onClick={() => { @@ -345,22 +304,9 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({ </button> </div> - <div - className="file-list" - ref={listRef} - tabIndex={0} - onKeyDown={handleListKeyDown} - style={{ - flex: 1, - overflowY: 'auto', - border: '1px solid #333', - marginBottom: '10px', - outline: 'none', - position: 'relative', - }} - > + <div className="file-browser-list" ref={listRef} tabIndex={0} onKeyDown={handleListKeyDown}> {isLoading && <div>Loading...</div>} - {error && <div style={{ color: 'red' }}>Error: {error}</div>} + {error && <div className="file-browser-error">Error: {error}</div>} {!isLoading && !error && @@ -381,32 +327,20 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({ if (item.isUp) handleUp(); else handleDoubleClick(item); }} - style={{ - padding: '5px', - cursor: 'pointer', - backgroundColor: isSelected ? 'var(--ui-selection-bg)' : 'transparent', - color: item.isDir - ? '#ffff00' - : isSelected - ? 'var(--ui-selection-text)' - : 'var(--ui-main-color)', - border: isSelected - ? '1px solid var(--ui-selection-bg)' - : '1px solid transparent', - }} + className={`file-browser-item ${item.isDir ? 'is-dir' : ''} ${isSelected ? 'is-selected' : ''}`} > {item.isDir ? '📁' : '📄'} {item.name} </div> ); })} {!isLoading && displayItems.length === 0 && ( - <div style={{ color: '#666', padding: '5px' }}>Directory is empty</div> + <div className="file-browser-empty">Directory is empty</div> )} </div> <div className="browser-footer"> - <div style={{ display: 'flex', marginBottom: '10px' }}> - <label style={{ width: '60px' }}>Name:</label> + <div className="file-browser-form-row"> + <label className="file-browser-label">Name:</label> <input type="text" value={filename} @@ -419,19 +353,11 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({ autoFocus /> </div> - <div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}> - <button - onClick={onCancel} - className="e-btn" - style={{ padding: '5px 15px' }} - > + <div className="file-browser-actions"> + <button onClick={onCancel} className="e-btn" style={{ padding: '5px 15px' }}> Cancel </button> - <button - onClick={handleConfirm} - className="e-btn e-btn-enter" - style={{ padding: '5px 15px' }} - > + <button onClick={handleConfirm} className="e-btn e-btn-enter" style={{ padding: '5px 15px' }}> {mode === 'save' ? 'Save' : 'Load'} </button> </div> diff --git a/src/components/GameCanvas.tsx b/src/components/GameCanvas.tsx index 0263c7c..5ade7a6 100644 --- a/src/components/GameCanvas.tsx +++ b/src/components/GameCanvas.tsx @@ -8,15 +8,16 @@ interface GameCanvasProps { export const GameCanvas: React.FC<GameCanvasProps> = ({ onGameInit }) => { const canvasRef = useRef<HTMLCanvasElement>(null); const uiCanvasRef = useRef<HTMLCanvasElement>(null); + const editorOverlayCanvasRef = useRef<HTMLCanvasElement>(null); const gameRef = useRef<Game | null>(null); const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { - if (canvasRef.current && uiCanvasRef.current && !gameRef.current) { + if (canvasRef.current && uiCanvasRef.current && editorOverlayCanvasRef.current && !gameRef.current) { // Initialize Game with BOTH canvases // canvasRef -> WebGL (CRT) // uiCanvasRef -> 2D (UI/Input) - const game = new Game(canvasRef.current, uiCanvasRef.current); + const game = new Game(canvasRef.current, uiCanvasRef.current, editorOverlayCanvasRef.current); gameRef.current = game; // Start Game Loop @@ -44,6 +45,11 @@ export const GameCanvas: React.FC<GameCanvasProps> = ({ onGameInit }) => { canvasRef.current.width = clientWidth * dpr; canvasRef.current.height = clientHeight * dpr; + if (editorOverlayCanvasRef.current) { + editorOverlayCanvasRef.current.width = clientWidth * dpr; + editorOverlayCanvasRef.current.height = clientHeight * dpr; + } + // Notify game of resize gameRef.current.resize(canvasRef.current.width, canvasRef.current.height); } @@ -127,6 +133,22 @@ export const GameCanvas: React.FC<GameCanvasProps> = ({ onGameInit }) => { pointerEvents: 'auto', }} /> + + <canvas + ref={editorOverlayCanvasRef} + id="editor-overlay-canvas" + style={{ + width: '100%', + height: '100%', + display: 'block', + position: 'absolute', + top: 0, + left: 0, + zIndex: 3, + backgroundColor: 'transparent', + pointerEvents: 'none', + }} + /> </div> ); }; diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 55c5d8b..261193c 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -246,7 +246,7 @@ export const PropertiesPanel: React.FC = () => { <div className="editor-header"> <span>{selectedObjectId === 'SETTINGS' ? 'SETTINGS (Loading...)' : 'PROPERTIES'}</span> </div> - <div className="editor-content" style={{ color: '#888', fontStyle: 'italic' }}> + <div className="editor-content ui-text-muted ui-text-italic"> {selectedObjectId === 'SETTINGS' ? 'Loading Settings...' : 'No Selection'} </div> </div> @@ -302,7 +302,7 @@ export const PropertiesPanel: React.FC = () => { <div className="editor-content"> <div className="e-row"> <label className="e-label">Group #ID</label> - <div className="e-label" style={{ color: '#888', fontSize: '0.8em' }}> + <div className="e-label ui-text-muted ui-text-small"> (<Enter> = append, <Ctrl+Enter> = remove) </div> <input @@ -450,7 +450,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" step="0.1" - className="e-input" + className="e-input ui-text-muted" placeholder="mixed" value={sharedParallax === '' ? '' : sharedParallax} onChange={(e) => { @@ -926,7 +926,7 @@ export const PropertiesPanel: React.FC = () => { readOnly tabIndex={-1} onFocus={(e) => e.currentTarget.blur()} - style={{ pointerEvents: 'none', color: '#888' }} + style={{ pointerEvents: 'none' }} /> {textAssetPath && ( <> @@ -943,7 +943,7 @@ export const PropertiesPanel: React.FC = () => { </button> )} </div> - <div className="e-label" style={{ color: '#888', fontSize: '0.8em' }}> + <div className="e-label ui-text-muted ui-text-small"> {textAssetPath} </div> </> @@ -1299,10 +1299,7 @@ export const PropertiesPanel: React.FC = () => { </label> </div> <div className="e-row"> - <label - className="e-label" - style={{ display: 'flex', alignItems: 'center', color: '#faa' }} - > + <label className="e-label ui-inline-flex-center ui-text-accent-red"> <input type="checkbox" style={{ marginRight: '5px' }} @@ -1314,18 +1311,10 @@ export const PropertiesPanel: React.FC = () => { </div> {/* Interactions */} - <div - className="e-row" - style={{ marginTop: '10px', borderTop: '1px solid #444', paddingTop: '5px' }} - > + <div className="e-row ui-divider-blue" style={{ marginTop: '10px', paddingTop: '5px' }}> <div - className="e-label" - style={{ - color: '#aaf', - fontWeight: 'bold', - display: 'flex', - justifyContent: 'space-between', - }} + className="e-label ui-text-accent-blue ui-font-bold" + style={{ display: 'flex', justifyContent: 'space-between' }} > <span>SCRIPT EVENTS</span> <Select @@ -1363,7 +1352,7 @@ export const PropertiesPanel: React.FC = () => { key={verb} style={{ display: 'flex', alignItems: 'center', marginTop: '2px' }} > - <div style={{ width: '40px', fontSize: '0.85em', color: '#ccc' }}> + <div className="ui-text-light" style={{ width: '40px', fontSize: '0.85em' }}> {verb.toUpperCase()} </div> <input @@ -1502,10 +1491,7 @@ export const PropertiesPanel: React.FC = () => { </label> </div> <div className="e-row"> - <label - className="e-label" - style={{ display: 'flex', alignItems: 'center', color: '#faa' }} - > + <label className="e-label ui-inline-flex-center ui-text-accent-red"> <input type="checkbox" style={{ marginRight: '5px' }} @@ -1747,7 +1733,7 @@ export const PropertiesPanel: React.FC = () => { border: isSelected ? '1px solid yellow' : '1px solid transparent', }} > - <div style={{ fontSize: '0.75em', color: '#888', marginBottom: '2px' }}> + <div className="ui-text-muted ui-text-tiny" style={{ marginBottom: '2px' }}> Vertex {i}{' '} {i === 0 ? '(TL)' @@ -1955,10 +1941,7 @@ export const PropertiesPanel: React.FC = () => { </label> </div> <div className="e-row"> - <label - className="e-label" - style={{ display: 'flex', alignItems: 'center', color: '#faa' }} - > + <label className="e-label ui-inline-flex-center ui-text-accent-red"> <input type="checkbox" style={{ marginRight: '5px' }} @@ -1978,19 +1961,10 @@ export const PropertiesPanel: React.FC = () => { selectedObjectType === 'Actor' || selectedObjectType === 'Static' || selectedObjectType === 'Quad') && ( - <div - className="e-row" - style={{ borderTop: '1px solid #444', paddingTop: '5px', marginTop: '5px' }} - > + <div className="e-row ui-divider-red" style={{ paddingTop: '5px', marginTop: '5px' }}> <div - className="e-label" - style={{ - color: '#faa', - fontWeight: 'bold', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - }} + className="e-label ui-text-accent-red ui-font-bold" + style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }} > <span>COMPONENTS</span> <div> @@ -2093,7 +2067,7 @@ export const PropertiesPanel: React.FC = () => { marginBottom: '5px', }} > - <span style={{ fontWeight: 'bold', color: '#fb8' }}>{comp.type}</span> + <span className="ui-font-bold" style={{ color: '#fb8' }}>{comp.type}</span> <button className="e-btn e-btn-red" style={{ padding: '0 5px' }} @@ -2240,13 +2214,8 @@ export const PropertiesPanel: React.FC = () => { </div> <div className="e-row"> <label - className="e-label" - style={{ - display: 'flex', - alignItems: 'center', - color: '#aaf', - fontSize: '10px', - }} + className="e-label ui-text-accent-blue ui-inline-flex-center" + style={{ fontSize: '10px' }} > <input type="checkbox" @@ -2640,13 +2609,11 @@ export const PropertiesPanel: React.FC = () => { {selectedObjectType === 'Quad' && ( <div - className="e-label" + className="e-label ui-text-dim" style={{ marginTop: '10px', fontSize: '10px', - color: '#666', fontStyle: 'italic', - borderTop: '1px solid #444', paddingTop: '5px', }} > @@ -2658,18 +2625,15 @@ export const PropertiesPanel: React.FC = () => { {selectedObjectType === 'Actor' && ( <> - <div className="e-row" style={{ borderTop: '1px solid #444', paddingTop: '5px' }}> - <div className="e-label" style={{ color: '#aaf', fontWeight: 'bold' }}> + <div className="e-row ui-divider-blue" style={{ paddingTop: '5px' }}> + <div className="e-label ui-text-accent-blue ui-font-bold"> ACTOR PROPERTIES </div> </div> {/* Is Player */} <div className="e-row"> - <label - className="e-label" - style={{ display: 'flex', alignItems: 'center', color: '#aaf' }} - > + <label className="e-label ui-inline-flex-center ui-text-accent-blue"> <input type="checkbox" style={{ marginRight: '5px' }} @@ -2731,13 +2695,8 @@ export const PropertiesPanel: React.FC = () => { {/* Animation Sets */} <div className="e-row" style={{ marginTop: '10px' }}> <div - className="e-label" - style={{ - color: '#aaf', - fontWeight: 'bold', - display: 'flex', - justifyContent: 'space-between', - }} + className="e-label ui-text-accent-blue ui-font-bold" + style={{ display: 'flex', justifyContent: 'space-between' }} > <span>ANIMATION SETS</span> <button @@ -2856,7 +2815,7 @@ export const PropertiesPanel: React.FC = () => { alignItems: 'center', }} > - <div style={{ width: '30px', fontSize: '10px', color: '#888' }}> + <div className="ui-text-muted ui-text-micro" style={{ width: '30px' }}> {dir.toUpperCase()} </div> <input @@ -2901,8 +2860,8 @@ export const PropertiesPanel: React.FC = () => { <> {/* Camera properties */} {(obj.camera || obj.defaultCamera) && ( - <div className="e-row" style={{ borderTop: '1px solid #444', paddingTop: '5px' }}> - <div className="e-label" style={{ color: '#aaf', fontWeight: 'bold' }}> + <div className="e-row ui-divider-blue" style={{ paddingTop: '5px' }}> + <div className="e-label ui-text-accent-blue ui-font-bold"> CAMERA </div> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }}> @@ -3003,7 +2962,7 @@ export const PropertiesPanel: React.FC = () => { </div> <> <div className="e-row" style={{ marginTop: '5px' }}> - <div className="e-label" style={{ color: '#aaf' }}> + <div className="e-label ui-text-accent-blue"> Camera Bounds (Min/Max) </div> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}> @@ -3079,8 +3038,8 @@ export const PropertiesPanel: React.FC = () => { {/* Default Camera (Start Position) */} {obj.defaultCamera && ( - <div className="e-row" style={{ borderTop: '1px solid #444', paddingTop: '5px' }}> - <div className="e-label" style={{ color: '#aaf', fontWeight: 'bold' }}> + <div className="e-row ui-divider-blue" style={{ paddingTop: '5px' }}> + <div className="e-label ui-text-accent-blue ui-font-bold"> DEFAULT CAMERA </div> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }}> @@ -3143,8 +3102,8 @@ export const PropertiesPanel: React.FC = () => { {/* Scaling Settings */} {game.sceneManager.currentScene && ( - <div className="e-row" style={{ borderTop: '1px solid #444', paddingTop: '5px' }}> - <div className="e-label" style={{ color: '#ffaa00', fontWeight: 'bold' }}> + <div className="e-row ui-divider-yellow" style={{ paddingTop: '5px' }}> + <div className="e-label ui-text-accent-yellow ui-font-bold"> SCALING </div> {(() => { @@ -3237,8 +3196,8 @@ export const PropertiesPanel: React.FC = () => { <> <div className="e-row"> <label - className="e-label" - style={{ color: '#79EFA4', fontWeight: 'bold', marginBottom: '10px' }} + className="e-label ui-text-accent-green ui-font-bold" + style={{ marginBottom: '10px' }} > EDITOR SETTINGS </label> @@ -3273,8 +3232,8 @@ export const PropertiesPanel: React.FC = () => { <div className="e-row" style={{ marginTop: '10px' }}> <label - className="e-label" - style={{ color: '#79EFA4', fontWeight: 'bold', marginBottom: '10px' }} + className="e-label ui-text-accent-green ui-font-bold" + style={{ marginBottom: '10px' }} > CRT EFFECT SETTINGS </label> @@ -3459,10 +3418,7 @@ export const PropertiesPanel: React.FC = () => { </> )} - <div - className="e-row" - style={{ marginTop: '20px', borderTop: '1px solid #333', paddingTop: '10px' }} - > + <div className="e-row ui-divider-neutral" style={{ marginTop: '20px', paddingTop: '10px' }}> <button className="e-btn" style={{ width: '100%', padding: '8px' }} diff --git a/src/core/Game.ts b/src/core/Game.ts index 3d1b603..771cd97 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -25,12 +25,14 @@ export class Game implements IGame { public static instance: Game; canvas: HTMLCanvasElement; // UI Canvas + editorOverlayCanvas: HTMLCanvasElement | null; rendererCanvas: HTMLCanvasElement; // High-Res Display (WebGL) bufferCanvas: HTMLCanvasElement; // 420x300 Buffer (Internal) ctx: CanvasRenderingContext2D | null; rendererCtx: CanvasRenderingContext2D | null; // For simple 2D upscale if CRT disabled uiCtx: CanvasRenderingContext2D | null; + editorOverlayCtx: CanvasRenderingContext2D | null; crtFilter: CRTFilter | null; lastTime: number; @@ -101,13 +103,16 @@ export class Game implements IGame { constructor( rendererCanvas: HTMLCanvasElement, // The main visual canvas (WebGL) - uiCanvas: HTMLCanvasElement // The UI overlay canvas (2D) + uiCanvas: HTMLCanvasElement, // The UI overlay canvas (2D) + editorOverlayCanvas?: HTMLCanvasElement // High-res editor overlay canvas ) { Game.instance = this; this.rendererCanvas = rendererCanvas; this.canvas = uiCanvas; + this.editorOverlayCanvas = editorOverlayCanvas || null; this.uiCtx = this.canvas.getContext('2d'); + this.editorOverlayCtx = this.editorOverlayCanvas?.getContext('2d') || null; // Create an offscreen buffer for the game to draw onto this.bufferCanvas = document.createElement('canvas'); @@ -150,6 +155,7 @@ export class Game implements IGame { // Disable smoothing for pixel art look if (this.ctx) this.ctx.imageSmoothingEnabled = false; if (this.uiCtx) this.uiCtx.imageSmoothingEnabled = false; + if (this.editorOverlayCtx) this.editorOverlayCtx.imageSmoothingEnabled = true; // (Previously corrupted lines removed) this.input = new Input(this); @@ -324,17 +330,41 @@ export class Game implements IGame { } } - // 3. Render UI/Editor to UI Canvas (Overlay) + // 3. Render UI/Editor overlays if (this.uiCtx) { this.uiCtx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + if (this.editorOverlayCtx && this.editorOverlayCanvas) { + this.editorOverlayCtx.setTransform(1, 0, 0, 1, 0, 0); + this.editorOverlayCtx.clearRect( + 0, + 0, + this.editorOverlayCanvas.width, + this.editorOverlayCanvas.height + ); + } + if (this.uiCtx) { // Sprite Editor Overlay (Takes over screen if active) if (this.spriteEditor.active) { this.spriteEditor.render(this.uiCtx); - } else { + } else if (!this.editorOverlayCtx) { this.editor.render(this.uiCtx); } } + + if ( + this.editorOverlayCtx && + this.editorOverlayCanvas && + !this.spriteEditor.active && + this.editor.enabled + ) { + const scaleX = this.editorOverlayCanvas.width / this.canvas.width; + const scaleY = this.editorOverlayCanvas.height / this.canvas.height; + this.editorOverlayCtx.setTransform(scaleX, 0, 0, scaleY, 0, 0); + this.editor.render(this.editorOverlayCtx); + this.editorOverlayCtx.setTransform(1, 0, 0, 1, 0, 0); + } } consoleInput: HTMLInputElement | null = null; // Command input provided by UI layer diff --git a/src/core/TextAssetManager.ts b/src/core/TextAssetManager.ts index 3d93e74..56d2bd8 100644 --- a/src/core/TextAssetManager.ts +++ b/src/core/TextAssetManager.ts @@ -29,6 +29,7 @@ const DEFAULT_SERVICE_ASSETS: Record<string, TextAssetData> = { examine_relation_prompt: 'Examine what area?', relation_empty: 'You see nothing {relation} the {target}.', relation_contents: '{Relation} the {target} you see: {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?', take_which_one: 'Which item do you mean: {options}?', @@ -115,7 +116,7 @@ const DEFAULT_PARSER_LEXICON: ParserLexiconAsset = { relationMarkers: { on: ['on'], under: ['under', 'beneath'], - in: ['in', 'inside'], + in: ['in', 'inside', 'into'], behind: ['behind'], near: ['near', 'next to', 'by'], }, @@ -452,7 +453,9 @@ export class TextAssetManager { async preloadScene(scene: Scene): Promise<void> { await this.readSceneAsset(scene, true); await Promise.all( - (scene.entities || []).map((entity: SceneObject) => this.readObjectAsset(entity, true)) + [...(scene.entities || []), ...(scene.triggerboxes || [])].map((object: SceneObject) => + this.readObjectAsset(object, true) + ) ); } diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index 6a2c686..d7cd28e 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -856,7 +856,7 @@ export class SceneEditor { ctx.translate(-camX, -camY); // Apply Camera ctx.strokeStyle = '#ffff00'; - ctx.lineWidth = 2 / (scene && scene.camera ? scene.camera.zoom : 1); + ctx.lineWidth = 1.25 / (scene && scene.camera ? scene.camera.zoom : 1); ctx.beginPath(); ctx.moveTo( this.transformManager.currentPolygon[0].x, @@ -927,12 +927,12 @@ export class SceneEditor { ctx.closePath(); ctx.strokeStyle = '#00ff00'; - ctx.lineWidth = 2 / zoom; + ctx.lineWidth = 1.25 / zoom; ctx.stroke(); // Draw Vertices ctx.fillStyle = '#00ff00'; - const handleSize = 6 / zoom; + const handleSize = 5 / zoom; verts.forEach((v: any, i: number) => { const p = getDrawPos(v); // Highlight dragging vertex @@ -1008,7 +1008,7 @@ export class SceneEditor { ctx.setLineDash([4 / zoom, 4 / zoom]); // Dashed, thin line } else { ctx.strokeStyle = '#fff'; - ctx.lineWidth = 2 / zoom; + ctx.lineWidth = 1.25 / zoom; ctx.setLineDash([4 / zoom, 4 / zoom]); } @@ -1027,7 +1027,7 @@ export class SceneEditor { // Draw Resize Handles (Only if NOT locked) if (!entity.locked) { ctx.fillStyle = '#ffffff'; - const hSize = 6 / zoom; // Handle size + const hSize = 5 / zoom; // Handle size const l = drawX - entity.width / 2; const r = drawX + entity.width / 2; @@ -1064,10 +1064,10 @@ export class SceneEditor { if (selected.locked) { // Locked Style - ctx.lineWidth = 1.5 / zoom; + ctx.lineWidth = 1 / zoom; ctx.setLineDash([]); } else { - ctx.lineWidth = 3 / zoom; + ctx.lineWidth = 1.75 / zoom; ctx.setLineDash([]); } @@ -1086,7 +1086,7 @@ export class SceneEditor { if (selected instanceof Walkbox) ctx.fillStyle = '#ff0000'; else ctx.fillStyle = '#ff00ff'; - const handleSize = 6 / zoom; + const handleSize = 5 / zoom; for (const pt of poly) { ctx.fillRect(pt.x - handleSize / 2, pt.y - handleSize / 2, handleSize, handleSize); } @@ -1107,7 +1107,7 @@ export class SceneEditor { ctx.save(); ctx.strokeStyle = '#79EFA4'; ctx.fillStyle = 'rgba(121, 239, 164, 0.12)'; - ctx.lineWidth = 1; + ctx.lineWidth = 0.75; ctx.setLineDash([4, 3]); ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); @@ -1124,7 +1124,7 @@ export class SceneEditor { (this.selectedObject as any) === 'SCENE' ) { ctx.save(); - ctx.font = '10px monospace'; + ctx.font = '5px monospace'; const zoom = scene.camera ? scene.camera.zoom : 1; const camY = scene.camera ? scene.camera.y : 0; From 6742e72d0ffa1a72662a24e7ad932df7f7c95b3e Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 14:43:22 +0200 Subject: [PATCH 50/75] Feature: add pickup lift animation --- src/core/Game.ts | 1 + src/graphics/SceneRenderer.ts | 13 +++++++++- src/scene/Scene.ts | 49 +++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/core/Game.ts b/src/core/Game.ts index 771cd97..65bbe8f 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -807,6 +807,7 @@ export class Game implements IGame { const isItem = entity.components && entity.components.find((c: any) => c.type === 'Item'); if (isItem || entity.isTakeable) { + scene.playPickupAnimation(entity); scene.removeEntity(entity); this.inventory.push(entity); const itemTitle = this.getPlayerFacingObjectTitle(entity); diff --git a/src/graphics/SceneRenderer.ts b/src/graphics/SceneRenderer.ts index 16c7de2..7f6d119 100644 --- a/src/graphics/SceneRenderer.ts +++ b/src/graphics/SceneRenderer.ts @@ -12,7 +12,7 @@ export class SceneRenderer { } render(ctx: CanvasRenderingContext2D, scene: Scene): void { - const { camera, entities, activeSubscene, subsceneEntities } = scene; + const { camera, entities, activeSubscene, subsceneEntities, pickupAnimations } = scene; // Sorting Logic moved from Scene.render // Sort by Y (Depth) and Parallax @@ -119,6 +119,17 @@ export class SceneRenderer { // 3. Render Subscene Layer this.renderLayer(ctx, subsceneLayer, scene, halfW, halfH); + // 3.5. Render transient pickup effects above scene objects. + if (pickupAnimations.length > 0) { + this.renderLayer( + ctx, + pickupAnimations.map((anim) => anim.entity), + scene, + halfW, + halfH + ); + } + // 4. Debug Rendering (Walkboxes/Triggers) if (this.game && this.game.editor && this.game.editor.enabled) { const selected = this.game.editor.selectedObject; diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index a7b0c1c..0308865 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -26,6 +26,15 @@ import type { } from './spatialTypes'; import type { SubsceneComponent } from '../systems/ComponentSystem'; +interface PickupAnimation { + entity: Entity; + startY: number; + lift: number; + duration: number; + elapsed: number; + baseModelScale: number; +} + export interface SceneScaling { enabled: boolean; min: number; @@ -74,6 +83,7 @@ export class Scene { filename: string; background: HTMLImageElement | null; entities: Entity[]; + pickupAnimations: PickupAnimation[] = []; walkbox: Walkbox[]; triggerboxes: Triggerbox[]; scaling: SceneScaling; @@ -289,6 +299,28 @@ export class Scene { } } + playPickupAnimation(entity: Entity): void { + const clone = Entity.fromJSON(this.game, entity.toJSON() as EntityData); + clone.disabled = false; + clone.visible = true; + clone.locked = true; + clone.groupID = null; + clone.components = []; + clone.interactions = {}; + clone.opacity = entity.opacity ?? 1.0; + // @ts-ignore + clone.scene = this; + + this.pickupAnimations.push({ + entity: clone, + startY: clone.y, + lift: 26, + duration: 260, + elapsed: 0, + baseModelScale: clone.modelScale || 1, + }); + } + findEntity(name: string): Entity | undefined { const normalized = name.toUpperCase(); return this.entities.find((e) => { @@ -610,6 +642,23 @@ export class Scene { entity.update(deltaTime); } }); + + if (this.pickupAnimations.length > 0) { + const nextAnimations: PickupAnimation[] = []; + for (const anim of this.pickupAnimations) { + anim.elapsed += deltaTime; + const progress = Math.min(anim.elapsed / anim.duration, 1); + const eased = 1 - Math.pow(1 - progress, 2); + anim.entity.y = anim.startY - anim.lift * eased; + anim.entity.opacity = Math.max(0, 1 - progress); + anim.entity.modelScale = anim.baseModelScale * (1 + 0.1 * eased); + anim.entity.update(deltaTime); + if (progress < 1) { + nextAnimations.push(anim); + } + } + this.pickupAnimations = nextAnimations; + } } // ----------------------------------------------------- From 03e5fa2c72252100e1b69389c3f930882b489c67 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 15:33:52 +0200 Subject: [PATCH 51/75] Fix: restore command input focus after closing editor --- src/components/UIOverlay.tsx | 15 +++++++++++++++ src/tools/editor/EditorUI.ts | 1 - 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index 493df6d..b871828 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -62,6 +62,21 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { } }, [game]); + useEffect(() => { + if (!game) return; + if (editorEnabled || isConsoleOpen) return; + + const timer = window.setTimeout(() => { + const input = parserInputRef.current; + if (!input || input.disabled) return; + input.focus(); + const len = input.value.length; + input.setSelectionRange(len, len); + }, 0); + + return () => window.clearTimeout(timer); + }, [game, editorEnabled, isConsoleOpen]); + useEffect(() => { if (message) { const timer = setTimeout(() => { diff --git a/src/tools/editor/EditorUI.ts b/src/tools/editor/EditorUI.ts index 12427b9..ae70f28 100644 --- a/src/tools/editor/EditorUI.ts +++ b/src/tools/editor/EditorUI.ts @@ -85,7 +85,6 @@ export class EditorUI { // Restore Parser if (parserInput) { parserInput.disabled = false; - parserInput.focus(); } } From db983bf98f4707e616cfc2e985974f5d1be0dd44 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 18:39:47 +0200 Subject: [PATCH 52/75] Refactor: make parser context more semantic --- src/mechanics/ParserWorldModelBuilder.ts | 38 +++++++++++++++- src/mechanics/parserTypes.ts | 8 +++- tests/game/semantic-api.test.ts | 4 +- tests/parser/world-model-context.test.ts | 55 ++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 tests/parser/world-model-context.test.ts diff --git a/src/mechanics/ParserWorldModelBuilder.ts b/src/mechanics/ParserWorldModelBuilder.ts index a9578a7..d4ef23f 100644 --- a/src/mechanics/ParserWorldModelBuilder.ts +++ b/src/mechanics/ParserWorldModelBuilder.ts @@ -32,6 +32,12 @@ export class ParserWorldModelBuilder { private buildContext(rawInput: string, pendingState: ParserPendingState | null): ParserContext { const scene = this.game.sceneManager.currentScene; const normalizedInput = rawInput.trim().toUpperCase(); + const playerContext = scene?.player + ? this.compactRecord({ + x: Math.round(scene.player.x), + y: Math.round(scene.player.y), + }) + : undefined; const sceneContext = scene ? this.buildSceneContext(scene) : undefined; const entities = scene ? this.buildEntityContexts(scene) : []; const inventory = this.buildInventoryContexts(); @@ -48,6 +54,7 @@ export class ParserWorldModelBuilder { return this.compactRecord({ rawInput, normalizedInput, + player: playerContext, scene: sceneContext, entities, inventory, @@ -74,10 +81,15 @@ export class ParserWorldModelBuilder { if (!title) return null; const synonyms = this.game.textAssets.getResolvedObjectListField(sceneObject as any, 'synonyms'); const interactions = Object.keys(sceneObject.interactions || {}); + const isItem = !!sceneObject.components?.find((component: any) => component?.type === 'Item'); + const coordinates = this.isDirectSceneObject(scene, sceneObject) + ? this.getSceneObjectCoordinates(sceneObject) + : undefined; return this.compactRecord<ParserEntityContext>({ id: sceneObject.name, - type: sceneObject.type, title, + item: isItem || undefined, + ...coordinates, synonyms, description: this.game.textAssets.getResolvedObjectField(sceneObject as any, 'description') || undefined, @@ -200,6 +212,30 @@ export class ParserWorldModelBuilder { return title && title.trim() ? title.trim() : null; } + private isDirectSceneObject(scene: Scene, sceneObject: SceneObject): boolean { + const placement = scene.getSpatialPlacementForObject(sceneObject); + return !placement?.parentNodeId; + } + + private getSceneObjectCoordinates(sceneObject: SceneObject): { x: number; y: number } | undefined { + if (typeof (sceneObject as any).x === 'number' && typeof (sceneObject as any).y === 'number') { + return { + x: Math.round((sceneObject as any).x), + y: Math.round((sceneObject as any).y), + }; + } + + const poly = Array.isArray((sceneObject as any).poly) ? (sceneObject as any).poly : null; + if (!poly?.length) return undefined; + + const xs = poly.map((point: { x: number; y: number }) => point.x); + const ys = poly.map((point: { x: number; y: number }) => point.y); + return { + x: Math.round((Math.min(...xs) + Math.max(...xs)) / 2), + y: Math.round((Math.min(...ys) + Math.max(...ys)) / 2), + }; + } + private compactRecord<T extends Record<string, unknown>>(value: T): T { const result: Record<string, unknown> = {}; for (const [key, entry] of Object.entries(value)) { diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index 2f1a60f..e3c634c 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -4,8 +4,10 @@ import type { SceneObject } from '../entities/SceneObject'; export type ParserEntityContext = { id: string; - type: string; title: string; + item?: true; + x?: number; + y?: number; synonyms?: string[]; description?: string; details?: string; @@ -108,6 +110,10 @@ export type ParserCommandSpec = { export type ParserContext = { rawInput: string; normalizedInput: string; + player?: { + x: number; + y: number; + }; scene?: { id: string; title?: string; diff --git a/tests/game/semantic-api.test.ts b/tests/game/semantic-api.test.ts index 4a7ffe0..b522d23 100644 --- a/tests/game/semantic-api.test.ts +++ b/tests/game/semantic-api.test.ts @@ -11,7 +11,7 @@ describe('Game semantic API', () => { expect(outcome.message).toBe('You are in Test Scene.'); }); - it('lookEntity appends spatial parent context when present', () => { + it('lookEntity stays descriptive even when spatial parent context exists', () => { const fixture = createGameSemanticFixture(); fixture.addEntity('Table', { title: 'Table', @@ -26,7 +26,7 @@ describe('Game semantic API', () => { const outcome = fixture.game.lookEntity(note); expect(outcome.status).toBe('ok'); - expect(outcome.message).toBe('A folded note. Under the Table you see: Piece of paper.'); + expect(outcome.message).toBe('A folded note.'); }); it('examineEntity prefers details and falls back to description', () => { diff --git a/tests/parser/world-model-context.test.ts b/tests/parser/world-model-context.test.ts new file mode 100644 index 0000000..8c4ab46 --- /dev/null +++ b/tests/parser/world-model-context.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { ParserWorldModelBuilder } from '../../src/mechanics/ParserWorldModelBuilder'; +import { createSceneFixture } from '../fixtures/sceneFactory'; + +describe('Parser world model context', () => { + it('omits technical scene object type and includes item flag only when Item component exists', () => { + const fixture = createSceneFixture(); + fixture.addPlayer('Hero', 12, 34); + fixture.addEntity('note', { + title: 'Piece of paper', + description: 'A folded note.', + components: [{ type: 'Item' }], + }); + fixture.addEntity('drawer_note', { + title: 'Nested note', + description: 'Inside the drawer.', + spatial: { parentNodeId: 'Desk', relation: 'in' }, + }); + fixture.addTriggerbox('Desk', { + title: 'Desk', + description: 'A large desk.', + }); + + const builder = new ParserWorldModelBuilder(fixture.game as any); + const model = builder.build('look note', null); + const entities = model.context.entities || []; + const player = model.context.player; + + expect(player).toEqual({ x: 12, y: 34 }); + expect(entities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'note', + title: 'Piece of paper', + item: true, + x: 0, + y: 0, + }), + expect.objectContaining({ + id: 'Desk', + title: 'Desk', + x: 5, + y: 5, + }), + ]) + ); + + expect(entities.every((entity) => !('type' in entity))).toBe(true); + const deskContext = entities.find((entity) => entity.id === 'Desk'); + expect(deskContext && 'item' in deskContext).toBe(false); + const nestedContext = entities.find((entity) => entity.id === 'drawer_note'); + expect(nestedContext && 'x' in nestedContext).toBe(false); + expect(nestedContext && 'y' in nestedContext).toBe(false); + }); +}); From 4802ec39eb9e575f1a6e5a03a07e3c5f807bc9a9 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 19:06:21 +0200 Subject: [PATCH 53/75] Refactor: add spatial hints to parser context --- src/mechanics/ParserWorldModelBuilder.ts | 11 ++++++++--- src/mechanics/parserTypes.ts | 1 + tests/parser/world-model-context.test.ts | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/mechanics/ParserWorldModelBuilder.ts b/src/mechanics/ParserWorldModelBuilder.ts index d4ef23f..ef9e7e0 100644 --- a/src/mechanics/ParserWorldModelBuilder.ts +++ b/src/mechanics/ParserWorldModelBuilder.ts @@ -82,13 +82,18 @@ export class ParserWorldModelBuilder { const synonyms = this.game.textAssets.getResolvedObjectListField(sceneObject as any, 'synonyms'); const interactions = Object.keys(sceneObject.interactions || {}); const isItem = !!sceneObject.components?.find((component: any) => component?.type === 'Item'); - const coordinates = this.isDirectSceneObject(scene, sceneObject) - ? this.getSceneObjectCoordinates(sceneObject) - : undefined; + const isDirectSceneObject = this.isDirectSceneObject(scene, sceneObject); + const coordinates = isDirectSceneObject ? this.getSceneObjectCoordinates(sceneObject) : undefined; + const reachable = + isDirectSceneObject && + !ComponentSystem.getInteractionDistanceError(sceneObject as any, scene.player) + ? true + : undefined; return this.compactRecord<ParserEntityContext>({ id: sceneObject.name, title, item: isItem || undefined, + reachable, ...coordinates, synonyms, description: diff --git a/src/mechanics/parserTypes.ts b/src/mechanics/parserTypes.ts index e3c634c..89cbd7f 100644 --- a/src/mechanics/parserTypes.ts +++ b/src/mechanics/parserTypes.ts @@ -6,6 +6,7 @@ export type ParserEntityContext = { id: string; title: string; item?: true; + reachable?: true; x?: number; y?: number; synonyms?: string[]; diff --git a/tests/parser/world-model-context.test.ts b/tests/parser/world-model-context.test.ts index 8c4ab46..1da3fd8 100644 --- a/tests/parser/world-model-context.test.ts +++ b/tests/parser/world-model-context.test.ts @@ -33,12 +33,14 @@ describe('Parser world model context', () => { id: 'note', title: 'Piece of paper', item: true, + reachable: true, x: 0, y: 0, }), expect.objectContaining({ id: 'Desk', title: 'Desk', + reachable: true, x: 5, y: 5, }), @@ -51,5 +53,6 @@ describe('Parser world model context', () => { const nestedContext = entities.find((entity) => entity.id === 'drawer_note'); expect(nestedContext && 'x' in nestedContext).toBe(false); expect(nestedContext && 'y' in nestedContext).toBe(false); + expect(nestedContext && 'reachable' in nestedContext).toBe(false); }); }); From 9dd92d63e71154bfa251a5886fe1bb2c5d639bd1 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 19:12:51 +0200 Subject: [PATCH 54/75] Fix: toggle console by physical backquote key --- src/core/Input.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/Input.ts b/src/core/Input.ts index 4b45fc2..df28ab8 100644 --- a/src/core/Input.ts +++ b/src/core/Input.ts @@ -38,8 +38,8 @@ export class Input { this.onKeyDown = (e: KeyboardEvent) => { this.keys[e.key] = true; - // Global Tilde (~) to toggle Console - if (e.key === '`' || e.key === '~') { + // Toggle console by physical backquote key, independent of keyboard layout. + if (e.code === 'Backquote') { e.preventDefault(); if (this.game.console) { this.game.console.toggle(); From d3028253162a55e87b4042aa81189a51bf97f5d5 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 19:26:08 +0200 Subject: [PATCH 55/75] Refactor: use live canvas size in scene interaction --- src/scene/SceneInteraction.ts | 17 +++++++++++------ tests/scene/scene-interaction.test.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index defbbea..5605453 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -5,9 +5,16 @@ import { ComponentSystem } from '../systems/ComponentSystem'; export type HoverCursor = 'eye' | 'hand' | 'back'; +function getScreenSize(scene: Scene): { width: number; height: number } { + const canvas = scene.game?.canvas; + return { + width: canvas?.width || 420, + height: canvas?.height || 300, + }; +} + function toWorld(scene: Scene, x: number, y: number): { x: number; y: number } { - const screenW = 420; - const screenH = 300; + const { width: screenW, height: screenH } = getScreenSize(scene); const halfW = screenW / 2; const halfH = screenH / 2; return { @@ -22,8 +29,7 @@ function toWorldForParallax( y: number, parallax: number = 1.0 ): { x: number; y: number } { - const screenW = 420; - const screenH = 300; + const { width: screenW, height: screenH } = getScreenSize(scene); const halfW = screenW / 2; const halfH = screenH / 2; return { @@ -42,8 +48,7 @@ function isHitAtScreenPoint( screenX: number, screenY: number ): boolean { - const screenW = 420; - const screenH = 300; + const { width: screenW, height: screenH } = getScreenSize(scene); const halfW = screenW / 2; const halfH = screenH / 2; const camX = scene.camera.x; diff --git a/tests/scene/scene-interaction.test.ts b/tests/scene/scene-interaction.test.ts index fa3be15..d48e719 100644 --- a/tests/scene/scene-interaction.test.ts +++ b/tests/scene/scene-interaction.test.ts @@ -14,4 +14,18 @@ describe('Scene interaction text layer', () => { expect(fixture.messages.at(-1)).toBe('You see Desk Drawer'); }); + + it('uses the actual canvas size for screen-to-world click mapping', () => { + const fixture = createSceneFixture(); + fixture.game.canvas.width = 640; + fixture.game.canvas.height = 360; + fixture.addTriggerbox('tb_center', { + title: 'Center Trigger', + description: 'Centered hotspot.', + }); + + handleSceneClick(fixture.scene, 320, 180); + + expect(fixture.messages.at(-1)).toBe('You see Center Trigger'); + }); }); From a6a64f6973b962d97ac95591158fd4e8bc86875e Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 19:58:23 +0200 Subject: [PATCH 56/75] Feature: cache nlp model in local storage --- src/mechanics/NlpCascade.ts | 55 +++++++++++++++++++++++++++++++++ tests/parser/nlp-cache.test.ts | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/parser/nlp-cache.test.ts diff --git a/src/mechanics/NlpCascade.ts b/src/mechanics/NlpCascade.ts index 7fbe2e2..9c1af87 100644 --- a/src/mechanics/NlpCascade.ts +++ b/src/mechanics/NlpCascade.ts @@ -3,6 +3,7 @@ import type { ParserCascadeEnvelope, ParserContext, ParserToolAction } from './p import type { TextAssetManager } from '../core/TextAssetManager'; const NLP_CONFIDENCE_THRESHOLD = 0.58; +const NLP_MODEL_CACHE_PREFIX = 'quest:nlp:model:v1:'; type SupportedIntent = 'look' | 'examine' | 'take' | 'goTo' | 'showInventory'; @@ -81,12 +82,25 @@ export class NlpCascade { container ); + const cacheKey = this.getModelCacheKey(trainingData); + const cachedModel = this.readCachedModel(cacheKey); + if (cachedModel) { + try { + this.manager.import(cachedModel); + this.ready = true; + return; + } catch { + this.removeCachedModel(cacheKey); + } + } + for (const [intent, utterances] of Object.entries(trainingData)) { for (const utterance of utterances) { this.manager.addDocument('en', utterance, intent); } } await this.manager.train(); + this.writeCachedModel(cacheKey, this.manager.export(true)); this.ready = true; })(); @@ -235,4 +249,45 @@ export class NlpCascade { /\bhave\b/.test(lowered) ); } + + private getStorage(): Storage | null { + if (typeof window === 'undefined' || !window.localStorage) return null; + return window.localStorage; + } + + private getModelCacheKey(trainingData: Record<string, string[]>): string { + return `${NLP_MODEL_CACHE_PREFIX}${this.hashString(JSON.stringify(trainingData))}`; + } + + private readCachedModel(cacheKey: string): string | null { + try { + return this.getStorage()?.getItem(cacheKey) || null; + } catch { + return null; + } + } + + private writeCachedModel(cacheKey: string, data: string): void { + try { + this.getStorage()?.setItem(cacheKey, data); + } catch { + // Ignore storage quota/privacy mode failures and keep runtime behavior unchanged. + } + } + + private removeCachedModel(cacheKey: string): void { + try { + this.getStorage()?.removeItem(cacheKey); + } catch { + // Ignore storage failures. + } + } + + private hashString(value: string): string { + let hash = 5381; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 33) ^ value.charCodeAt(i); + } + return (hash >>> 0).toString(36); + } } diff --git a/tests/parser/nlp-cache.test.ts b/tests/parser/nlp-cache.test.ts new file mode 100644 index 0000000..43e8cad --- /dev/null +++ b/tests/parser/nlp-cache.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NlpCascade } from '../../src/mechanics/NlpCascade'; +import { Nlp } from '@nlpjs/nlp'; + +function createStorage() { + const store = new Map<string, string>(); + return { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + store.set(key, value); + }), + removeItem: vi.fn((key: string) => { + store.delete(key); + }), + clear: vi.fn(() => { + store.clear(); + }), + }; +} + +describe('NlpCascade model cache', () => { + afterEach(() => { + vi.restoreAllMocks(); + // @ts-expect-error test cleanup + delete globalThis.window; + }); + + it('stores a trained model and reuses the cache on the next initialize', async () => { + const localStorage = createStorage(); + // @ts-expect-error minimal window mock for node test env + globalThis.window = { localStorage }; + + const trainingData = { + look: ['look chair'], + take: ['take key'], + }; + const getTextAssets = () => + ({ + readParserTrainingAsset: async () => trainingData, + }) as any; + + const trainSpy = vi.spyOn(Nlp.prototype as any, 'train'); + + const firstCascade = new NlpCascade(getTextAssets); + await firstCascade.initialize(); + + expect(trainSpy).toHaveBeenCalledTimes(1); + expect(localStorage.setItem).toHaveBeenCalledTimes(1); + + const secondCascade = new NlpCascade(getTextAssets); + await secondCascade.initialize(); + + expect(localStorage.getItem).toHaveBeenCalled(); + expect(trainSpy).toHaveBeenCalledTimes(1); + }); +}); From 69dda54baa11f829bf843f960e6e9c9606b0a4c4 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 22:19:41 +0200 Subject: [PATCH 57/75] Feature: add multi-selection spatial assignment --- src/components/editor/PropertiesPanel.tsx | 85 ++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 261193c..c9f07d2 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -12,18 +12,22 @@ export const PropertiesPanel: React.FC = () => { selectedObjectType, incrementHierarchyVersion, incrementObjectVersion, + objectVersion, mode, selectedVertexIndex, } = useEditorStore(); const [groupIdDraft, setGroupIdDraft] = React.useState(''); + const [multiSpatialParentDraft, setMultiSpatialParentDraft] = React.useState(''); + const [multiSpatialRelationDraft, setMultiSpatialRelationDraft] = React.useState(''); const [resolvedTitle, setResolvedTitle] = React.useState(''); const [textAssetPath, setTextAssetPath] = React.useState(''); const [isReadingTA, setIsReadingTA] = React.useState(false); const [hasTextAsset, setHasTextAsset] = React.useState(false); // Derived Object Binding (Source of Truth) - // We re-render whenever objectVersion changes (subscribed via store hook) + // We re-render whenever objectVersion changes. let obj: any = null; + void objectVersion; if (game) { if (selectedObjectId === 'SETTINGS') { obj = game.settings; @@ -73,6 +77,24 @@ export const PropertiesPanel: React.FC = () => { return [{ value: '', label: '(None)' }, ...options]; }, [game, obj]); + const getMultiSpatialParentOptions = React.useCallback(() => { + const scene = game?.sceneManager?.currentScene; + if (!scene || !multiObjects.length) { + return [{ value: '', label: '(None)' }]; + } + + const selectedNames = new Set(multiObjects.map((item: any) => item?.name).filter(Boolean)); + const allObjects = [...scene.entities, ...scene.walkbox, ...scene.triggerboxes]; + const options = allObjects + .filter((item: any) => item && !selectedNames.has(item.name)) + .map((item: any) => ({ + value: item.name, + label: item.customName?.trim() || item.name, + })); + + return [{ value: '', label: '(None)' }, ...options]; + }, [game, multiObjects]); + const getSharedValue = (arr: any[], getter: (o: any) => any) => { if (!arr.length) return ''; const first = getter(arr[0]); @@ -97,6 +119,23 @@ export const PropertiesPanel: React.FC = () => { incrementHierarchyVersion(); }; + React.useEffect(() => { + if (selectedObjectType !== 'MULTI' || multiObjects.length <= 1) { + setMultiSpatialParentDraft(''); + setMultiSpatialRelationDraft(''); + return; + } + + const sharedParent = getSharedValue(multiObjects, (o: any) => o.spatial?.parentNodeId || ''); + const sharedRelation = getSharedValue( + multiObjects, + (o: any) => (o.spatial?.parentNodeId ? o.spatial?.relation || 'in' : o.spatial?.relation || '') + ); + + setMultiSpatialParentDraft(sharedParent === '' ? '' : sharedParent); + setMultiSpatialRelationDraft(sharedRelation === '' ? '' : sharedRelation); + }, [selectedObjectType, selectedObjectId, objectVersion, multiObjects.length]); + const loadResolvedTitle = React.useCallback( async (forceReload: boolean = false) => { if (!game || !obj || selectedObjectType === 'MULTI' || selectedObjectType === 'SETTINGS') { @@ -278,6 +317,7 @@ export const PropertiesPanel: React.FC = () => { entitiesAndQuads, (o: any) => !!o.ignoreScaling ); + const sharedParentNodeId = getSharedValue(multiObjects, (o: any) => o.spatial?.parentNodeId || ''); const sharedLocked = getSharedBooleanState(multiObjects, (o: any) => !!o.locked); const sharedDisabled = getSharedBooleanState(multiObjects, (o: any) => !!o.disabled); @@ -735,6 +775,49 @@ export const PropertiesPanel: React.FC = () => { </label> </div> + <div className="e-row"> + <label className="e-label">Parent</label> + <Select + value={multiSpatialParentDraft} + onChange={(value) => { + setMultiSpatialParentDraft(value || ''); + if (!value) { + setMultiSpatialRelationDraft(''); + } else if (!multiSpatialRelationDraft) { + setMultiSpatialRelationDraft('in'); + } + applyToMulti((o: any) => { + o.spatial = { + ...(o.spatial || {}), + parentNodeId: value || null, + relation: value ? o.spatial?.relation || 'in' : null, + }; + }); + }} + options={getMultiSpatialParentOptions()} + style={{ width: '100%' }} + /> + </div> + + <div className="e-row"> + <label className="e-label">Relation</label> + <Select + value={multiSpatialRelationDraft} + onChange={(value) => { + setMultiSpatialRelationDraft(value || ''); + applyToMulti((o: any) => { + o.spatial = { + ...(o.spatial || {}), + parentNodeId: o.spatial?.parentNodeId || null, + relation: value || (o.spatial?.parentNodeId ? 'in' : null), + }; + }); + }} + options={getSpatialRelationOptions(!!sharedParentNodeId)} + style={{ width: '100%' }} + /> + </div> + <div className="e-row"> <label className="e-label" From b6bda2f946e55a07e5b129d30d8637054c400946 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sat, 21 Mar 2026 23:31:53 +0200 Subject: [PATCH 58/75] Fix: preserve spatial hierarchy editing in undo --- src/components/editor/PropertiesPanel.tsx | 76 +++++++++++++++++++---- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index c9f07d2..150a4f3 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -60,22 +60,56 @@ export const PropertiesPanel: React.FC = () => { [spatialRelationOptions] ); + const getSpatialDescendantNames = React.useCallback((rootNames: string[]) => { + const scene = game?.sceneManager?.currentScene; + if (!scene || !rootNames.length) return new Set<string>(); + + const allObjects = [...scene.entities, ...scene.walkbox, ...scene.triggerboxes]; + const childrenByParent = new Map<string, string[]>(); + + allObjects.forEach((item: any) => { + const parentId = typeof item?.spatial?.parentNodeId === 'string' ? item.spatial.parentNodeId.trim() : ''; + const name = typeof item?.name === 'string' ? item.name.trim() : ''; + if (!parentId || !name) return; + const children = childrenByParent.get(parentId) || []; + children.push(name); + childrenByParent.set(parentId, children); + }); + + const visited = new Set<string>(); + const stack = [...rootNames.filter(Boolean)]; + while (stack.length) { + const current = stack.pop()!; + const children = childrenByParent.get(current) || []; + children.forEach((child) => { + if (visited.has(child)) return; + visited.add(child); + stack.push(child); + }); + } + + return visited; + }, [game]); + const getSceneSpatialParentOptions = React.useCallback(() => { const scene = game?.sceneManager?.currentScene; if (!scene || !obj) { return [{ value: '', label: '(None)' }]; } + const excludedNames = new Set<string>([obj?.name].filter(Boolean)); + getSpatialDescendantNames([obj?.name]).forEach((name) => excludedNames.add(name)); + const allObjects = [...scene.entities, ...scene.walkbox, ...scene.triggerboxes]; const options = allObjects - .filter((item) => item !== obj) + .filter((item: any) => item && !excludedNames.has(item.name)) .map((item: any) => ({ value: item.name, label: item.customName?.trim() || item.name, })); return [{ value: '', label: '(None)' }, ...options]; - }, [game, obj]); + }, [game, obj, getSpatialDescendantNames]); const getMultiSpatialParentOptions = React.useCallback(() => { const scene = game?.sceneManager?.currentScene; @@ -84,6 +118,8 @@ export const PropertiesPanel: React.FC = () => { } const selectedNames = new Set(multiObjects.map((item: any) => item?.name).filter(Boolean)); + getSpatialDescendantNames(Array.from(selectedNames)).forEach((name) => selectedNames.add(name)); + const allObjects = [...scene.entities, ...scene.walkbox, ...scene.triggerboxes]; const options = allObjects .filter((item: any) => item && !selectedNames.has(item.name)) @@ -93,7 +129,7 @@ export const PropertiesPanel: React.FC = () => { })); return [{ value: '', label: '(None)' }, ...options]; - }, [game, multiObjects]); + }, [game, multiObjects, getSpatialDescendantNames]); const getSharedValue = (arr: any[], getter: (o: any) => any) => { if (!arr.length) return ''; @@ -119,6 +155,17 @@ export const PropertiesPanel: React.FC = () => { incrementHierarchyVersion(); }; + const applyToMultiRoots = (fn: (o: any) => void) => { + const selectedNames = new Set(multiObjects.map((item: any) => item?.name).filter(Boolean)); + multiObjects.forEach((o: any) => { + const parentId = typeof o?.spatial?.parentNodeId === 'string' ? o.spatial.parentNodeId.trim() : ''; + if (parentId && selectedNames.has(parentId)) return; + fn(o); + }); + incrementObjectVersion(); + incrementHierarchyVersion(); + }; + React.useEffect(() => { if (selectedObjectType !== 'MULTI' || multiObjects.length <= 1) { setMultiSpatialParentDraft(''); @@ -780,17 +827,15 @@ export const PropertiesPanel: React.FC = () => { <Select value={multiSpatialParentDraft} onChange={(value) => { + const nextRelation = !value ? '' : multiSpatialRelationDraft || 'in'; + game.editor.saveUndoState(); setMultiSpatialParentDraft(value || ''); - if (!value) { - setMultiSpatialRelationDraft(''); - } else if (!multiSpatialRelationDraft) { - setMultiSpatialRelationDraft('in'); - } - applyToMulti((o: any) => { + setMultiSpatialRelationDraft(nextRelation); + applyToMultiRoots((o: any) => { o.spatial = { ...(o.spatial || {}), parentNodeId: value || null, - relation: value ? o.spatial?.relation || 'in' : null, + relation: value ? nextRelation || 'in' : null, }; }); }} @@ -804,8 +849,9 @@ export const PropertiesPanel: React.FC = () => { <Select value={multiSpatialRelationDraft} onChange={(value) => { + game.editor.saveUndoState(); setMultiSpatialRelationDraft(value || ''); - applyToMulti((o: any) => { + applyToMultiRoots((o: any) => { o.spatial = { ...(o.spatial || {}), parentNodeId: o.spatial?.parentNodeId || null, @@ -1196,12 +1242,14 @@ export const PropertiesPanel: React.FC = () => { <Select value={obj.spatial?.parentNodeId || ''} onChange={(value) => { + game.editor.saveUndoState(); obj.spatial = { ...(obj.spatial || {}), parentNodeId: value || null, relation: value ? obj.spatial?.relation || 'in' : null, }; incrementObjectVersion(); + incrementHierarchyVersion(); }} options={getSceneSpatialParentOptions()} style={{ width: '100%' }} @@ -1212,12 +1260,14 @@ export const PropertiesPanel: React.FC = () => { <Select value={obj.spatial?.relation || ''} onChange={(value) => { + game.editor.saveUndoState(); obj.spatial = { ...(obj.spatial || {}), parentNodeId: obj.spatial?.parentNodeId || null, relation: value || (obj.spatial?.parentNodeId ? 'in' : null), }; incrementObjectVersion(); + incrementHierarchyVersion(); }} options={getSpatialRelationOptions(!!obj.spatial?.parentNodeId)} style={{ width: '100%' }} @@ -1531,12 +1581,14 @@ export const PropertiesPanel: React.FC = () => { <Select value={obj.spatial?.parentNodeId || ''} onChange={(value) => { + game.editor.saveUndoState(); obj.spatial = { ...(obj.spatial || {}), parentNodeId: value || null, relation: value ? obj.spatial?.relation || 'in' : null, }; incrementObjectVersion(); + incrementHierarchyVersion(); }} options={getSceneSpatialParentOptions()} style={{ width: '100%' }} @@ -1547,12 +1599,14 @@ export const PropertiesPanel: React.FC = () => { <Select value={obj.spatial?.parentNodeId ? obj.spatial?.relation || 'in' : obj.spatial?.relation || ''} onChange={(value) => { + game.editor.saveUndoState(); obj.spatial = { ...(obj.spatial || {}), parentNodeId: obj.spatial?.parentNodeId || null, relation: value || (obj.spatial?.parentNodeId ? 'in' : null), }; incrementObjectVersion(); + incrementHierarchyVersion(); }} options={getSpatialRelationOptions(!!obj.spatial?.parentNodeId)} style={{ width: '100%' }} From d959fedb5ddcce853ac6bb72658c54b68cc30ab7 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 22 Mar 2026 00:26:11 +0200 Subject: [PATCH 59/75] Fix: stabilize editor undo for property edits --- src/components/editor/PropertiesPanel.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 150a4f3..9a1e1cb 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -23,6 +23,7 @@ export const PropertiesPanel: React.FC = () => { const [textAssetPath, setTextAssetPath] = React.useState(''); const [isReadingTA, setIsReadingTA] = React.useState(false); const [hasTextAsset, setHasTextAsset] = React.useState(false); + const lastUndoObjectKeyRef = React.useRef<string | null>(null); // Derived Object Binding (Source of Truth) // We re-render whenever objectVersion changes. @@ -311,6 +312,7 @@ export const PropertiesPanel: React.FC = () => { }; React.useEffect(() => { + lastUndoObjectKeyRef.current = null; if (selectedObjectType !== 'MULTI') { setGroupIdDraft(''); } @@ -327,6 +329,9 @@ export const PropertiesPanel: React.FC = () => { onMouseLeave={() => { if (game) game.isMouseOverUI = false; }} + onBlurCapture={() => { + lastUndoObjectKeyRef.current = null; + }} style={{ fontSize: `${12 * uiScale}px` }} > <div className="editor-header"> @@ -378,6 +383,9 @@ export const PropertiesPanel: React.FC = () => { onMouseLeave={() => { if (game) game.isMouseOverUI = false; }} + onBlurCapture={() => { + lastUndoObjectKeyRef.current = null; + }} style={{ fontSize: `${12 * uiScale}px` }} > <div className="editor-header"> @@ -892,6 +900,16 @@ export const PropertiesPanel: React.FC = () => { const handleChange = (field: string, value: any, enforceNumber = false) => { if (!obj) return; + if (selectedObjectType !== 'SETTINGS' && game?.editor) { + const objectKey = selectedObjectType === 'SCENE' + ? `SCENE:${obj.id || ''}` + : `${selectedObjectType || 'Object'}:${obj.name || ''}`; + if (lastUndoObjectKeyRef.current !== objectKey) { + game.editor.saveUndoState(); + lastUndoObjectKeyRef.current = objectKey; + } + } + let finalVal = value; if (enforceNumber) { finalVal = parseFloat(value); From f0c2345e594f4788073877eff9b7b076f11409da Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 22 Mar 2026 00:53:43 +0200 Subject: [PATCH 60/75] Style: preserve natural case in parent dropdowns --- src/components/editor/PropertiesPanel.tsx | 3 +++ src/index.css | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 9a1e1cb..39a06e0 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -833,6 +833,7 @@ export const PropertiesPanel: React.FC = () => { <div className="e-row"> <label className="e-label">Parent</label> <Select + className="parent-id-select" value={multiSpatialParentDraft} onChange={(value) => { const nextRelation = !value ? '' : multiSpatialRelationDraft || 'in'; @@ -1258,6 +1259,7 @@ export const PropertiesPanel: React.FC = () => { <div> <label className="e-label">Parent</label> <Select + className="parent-id-select" value={obj.spatial?.parentNodeId || ''} onChange={(value) => { game.editor.saveUndoState(); @@ -1597,6 +1599,7 @@ export const PropertiesPanel: React.FC = () => { <div> <label className="e-label">Parent</label> <Select + className="parent-id-select" value={obj.spatial?.parentNodeId || ''} onChange={(value) => { game.editor.saveUndoState(); diff --git a/src/index.css b/src/index.css index bb21d9b..29c14be 100644 --- a/src/index.css +++ b/src/index.css @@ -401,6 +401,14 @@ textarea, letter-spacing: -0.03em; } +.custom-select-container.parent-id-select, +.custom-select-container.parent-id-select .custom-select-trigger, +.custom-select-container.parent-id-select .custom-select-options, +.custom-select-container.parent-id-select .custom-option { + text-transform: none; + letter-spacing: normal; +} + .custom-option:hover, .custom-option.focused { background: var(--ui-main-color); From 04af90d1386d99b265ceb30e0563444dcf1b30cb Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 22 Mar 2026 01:04:34 +0200 Subject: [PATCH 61/75] Style: align panel button backgrounds with bottom menu --- src/editor.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor.css b/src/editor.css index 080a66d..e9555eb 100644 --- a/src/editor.css +++ b/src/editor.css @@ -103,7 +103,7 @@ /* Controls */ .e-btn { - background: transparent; + background: #111; border: 1px solid var(--ui-input-border); color: var(--ui-fkey-text); padding: 4px 8px; From 869619c1095a269f146ff9207dcf2405d9e48c8c Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 22 Mar 2026 01:08:56 +0200 Subject: [PATCH 62/75] Style: use display font for hierarchy IDs --- src/components/editor/HierarchyPanel.tsx | 2 +- src/index.css | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/editor/HierarchyPanel.tsx b/src/components/editor/HierarchyPanel.tsx index 29f1a29..7e9a2ff 100644 --- a/src/components/editor/HierarchyPanel.tsx +++ b/src/components/editor/HierarchyPanel.tsx @@ -416,7 +416,7 @@ export const HierarchyPanel: React.FC = () => { > {icon} </span> - {label} + <span className="hierarchy-id-text">{label}</span> </div> {item.locked && <span style={{ fontSize: '10px' }}>🔒</span>} </div> diff --git a/src/index.css b/src/index.css index 29c14be..78a96c1 100644 --- a/src/index.css +++ b/src/index.css @@ -296,6 +296,13 @@ canvas { letter-spacing: -0.03em; } +.hierarchy-id-text { + font-family: var(--ui-display-font); + font-weight: 500; + text-transform: none; + letter-spacing: -0.01em; +} + .editor-section { margin-bottom: 15px; border-bottom: 1px solid #333; From d67e7353839c9293ad73bd1febce9550bb6976d8 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 22 Mar 2026 17:07:44 +0200 Subject: [PATCH 63/75] Refactor: restructure properties panel sections --- src/components/editor/PropertiesPanel.tsx | 1895 ++++++++++++--------- src/editor.css | 106 ++ src/index.css | 8 + tasks.md | 251 +-- 4 files changed, 1253 insertions(+), 1007 deletions(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 39a06e0..db21e0c 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -23,7 +23,13 @@ export const PropertiesPanel: React.FC = () => { const [textAssetPath, setTextAssetPath] = React.useState(''); const [isReadingTA, setIsReadingTA] = React.useState(false); const [hasTextAsset, setHasTextAsset] = React.useState(false); + const [polygonScaleDraft, setPolygonScaleDraft] = React.useState('1'); const lastUndoObjectKeyRef = React.useRef<string | null>(null); + const lastPolygonScaleObjectKeyRef = React.useRef<string | null>(null); + const panelRef = React.useRef<HTMLDivElement | null>(null); + const contentRef = React.useRef<HTMLDivElement | null>(null); + const sectionRefs = React.useRef<Record<number, HTMLDivElement | null>>({}); + const isPanelHoveredRef = React.useRef(false); // Derived Object Binding (Source of Truth) // We re-render whenever objectVersion changes. @@ -150,6 +156,165 @@ export const PropertiesPanel: React.FC = () => { return first ? 'on' : 'off'; }; + const setSectionRef = React.useCallback( + (section: number) => (node: HTMLDivElement | null) => { + sectionRefs.current[section] = node; + }, + [] + ); + + const scrollToSection = React.useCallback((section: number) => { + const container = contentRef.current; + const node = sectionRefs.current[section]; + if (!container || !node) return; + const containerRect = container.getBoundingClientRect(); + const nodeRect = node.getBoundingClientRect(); + const targetTop = container.scrollTop + (nodeRect.top - containerRect.top) - 8; + container.scrollTo({ + top: Math.max(0, targetTop), + behavior: 'smooth', + }); + }, []); + + const isPanelTextEntryFocused = React.useCallback(() => { + const active = document.activeElement as HTMLElement | null; + if (!active || !panelRef.current || !panelRef.current.contains(active)) return false; + if (active.matches('input, textarea, select, [contenteditable="true"]')) return true; + if (active.closest('.custom-select-container')) return true; + return false; + }, []); + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isPanelHoveredRef.current) return; + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; + if (isPanelTextEntryFocused()) return; + + const key = e.key; + if (!/^[0-6]$/.test(key)) return; + + e.preventDefault(); + scrollToSection(parseInt(key, 10)); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isPanelTextEntryFocused, scrollToSection]); + + const getPolyCentroid = React.useCallback((poly: { x: number; y: number }[] = []) => { + if (!poly.length) return { x: 0, y: 0 }; + const sum = poly.reduce((acc, pt) => ({ x: acc.x + pt.x, y: acc.y + pt.y }), { x: 0, y: 0 }); + return { x: sum.x / poly.length, y: sum.y / poly.length }; + }, []); + + const translatePolyTo = React.useCallback( + (targetX: number, targetY: number) => { + if (!obj?.poly?.length) return; + const centroid = getPolyCentroid(obj.poly); + const dx = targetX - centroid.x; + const dy = targetY - centroid.y; + obj.poly = obj.poly.map((pt: any) => ({ + x: Math.round(pt.x + dx), + y: Math.round(pt.y + dy), + })); + incrementObjectVersion(); + }, + [getPolyCentroid, incrementObjectVersion, obj] + ); + + const getQuadCentroid = React.useCallback((quad: any) => { + const verts = quad?.vertices || []; + if (!verts.length) return { x: quad?.x || 0, y: quad?.y || 0 }; + const sum = verts.reduce((acc: any, v: any) => ({ x: acc.x + v.x, y: acc.y + v.y }), { x: 0, y: 0 }); + return { x: sum.x / verts.length, y: sum.y / verts.length }; + }, []); + + const translateQuadTo = React.useCallback( + (targetX: number, targetY: number) => { + if (!obj?.vertices?.length) return; + const centroid = getQuadCentroid(obj); + const dx = targetX - centroid.x; + const dy = targetY - centroid.y; + obj.vertices = obj.vertices.map((v: any) => ({ + ...v, + x: v.x + dx, + y: v.y + dy, + })); + obj.x = targetX; + obj.y = targetY; + incrementObjectVersion(); + }, + [getQuadCentroid, incrementObjectVersion, obj] + ); + + const scalePolyByFactor = React.useCallback( + (poly: { x: number; y: number }[], factor: number, originX: number, originY: number) => + poly.map((pt) => ({ + x: Math.round(originX + (pt.x - originX) * factor), + y: Math.round(originY + (pt.y - originY) * factor), + })), + [] + ); + + const scaleQuadVerticesByFactor = React.useCallback( + (vertices: any[], factor: number, originX: number, originY: number) => + vertices.map((v) => ({ + ...v, + x: originX + (v.x - originX) * factor, + y: originY + (v.y - originY) * factor, + })), + [] + ); + + const applyPolygonScaleDraft = React.useCallback( + (nextScaleRaw: string) => { + if (!obj || !(selectedObjectType === 'Triggerbox' || selectedObjectType === 'Quad')) return; + const nextScale = parseFloat(nextScaleRaw); + if (!Number.isFinite(nextScale) || nextScale <= 0) return; + + const objectKey = `${selectedObjectType || 'Object'}:${obj.name || ''}`; + const previousScale = + lastPolygonScaleObjectKeyRef.current === objectKey ? parseFloat(polygonScaleDraft) || 1 : 1; + const factor = nextScale / previousScale; + if (!Number.isFinite(factor) || factor <= 0 || Math.abs(factor - 1) < 0.0001) { + setPolygonScaleDraft(nextScaleRaw); + lastPolygonScaleObjectKeyRef.current = objectKey; + return; + } + + game?.editor?.saveUndoState(); + + if (selectedObjectType === 'Quad' && obj.vertices?.length) { + const centroid = getQuadCentroid(obj); + obj.vertices = scaleQuadVerticesByFactor(obj.vertices, factor, centroid.x, centroid.y); + obj.x = Math.round( + obj.vertices.reduce((acc: number, v: any) => acc + v.x, 0) / obj.vertices.length + ); + obj.y = Math.round( + obj.vertices.reduce((acc: number, v: any) => acc + v.y, 0) / obj.vertices.length + ); + } else if (obj.poly?.length) { + const centroid = getPolyCentroid(obj.poly); + obj.poly = scalePolyByFactor(obj.poly, factor, centroid.x, centroid.y); + } + + setPolygonScaleDraft(nextScaleRaw); + lastPolygonScaleObjectKeyRef.current = objectKey; + incrementObjectVersion(); + }, + [ + game, + getPolyCentroid, + getQuadCentroid, + incrementObjectVersion, + obj, + polygonScaleDraft, + scalePolyByFactor, + scaleQuadVerticesByFactor, + selectedObjectType, + ] + ); + const applyToMulti = (fn: (o: any) => void) => { multiObjects.forEach(fn); incrementObjectVersion(); @@ -184,6 +349,11 @@ export const PropertiesPanel: React.FC = () => { setMultiSpatialRelationDraft(sharedRelation === '' ? '' : sharedRelation); }, [selectedObjectType, selectedObjectId, objectVersion, multiObjects.length]); + React.useEffect(() => { + setPolygonScaleDraft('1'); + lastPolygonScaleObjectKeyRef.current = null; + }, [selectedObjectType, selectedObjectId]); + const loadResolvedTitle = React.useCallback( async (forceReload: boolean = false) => { if (!game || !obj || selectedObjectType === 'MULTI' || selectedObjectType === 'SETTINGS') { @@ -321,12 +491,15 @@ export const PropertiesPanel: React.FC = () => { if (!obj || !game) { return ( <div + ref={panelRef} id="editor-panel" className="editor-sidebar right" onMouseEnter={() => { + isPanelHoveredRef.current = true; if (game) game.isMouseOverUI = true; }} onMouseLeave={() => { + isPanelHoveredRef.current = false; if (game) game.isMouseOverUI = false; }} onBlurCapture={() => { @@ -337,7 +510,7 @@ export const PropertiesPanel: React.FC = () => { <div className="editor-header"> <span>{selectedObjectId === 'SETTINGS' ? 'SETTINGS (Loading...)' : 'PROPERTIES'}</span> </div> - <div className="editor-content ui-text-muted ui-text-italic"> + <div ref={contentRef} className="editor-content ui-text-muted ui-text-italic"> {selectedObjectId === 'SETTINGS' ? 'Loading Settings...' : 'No Selection'} </div> </div> @@ -375,12 +548,15 @@ export const PropertiesPanel: React.FC = () => { return ( <div + ref={panelRef} id="editor-panel" className="editor-sidebar right" onMouseEnter={() => { + isPanelHoveredRef.current = true; if (game) game.isMouseOverUI = true; }} onMouseLeave={() => { + isPanelHoveredRef.current = false; if (game) game.isMouseOverUI = false; }} onBlurCapture={() => { @@ -394,7 +570,7 @@ export const PropertiesPanel: React.FC = () => { X </button> </div> - <div className="editor-content"> + <div ref={contentRef} className="editor-content"> <div className="e-row"> <label className="e-label">Group #ID</label> <div className="e-label ui-text-muted ui-text-small"> @@ -853,25 +1029,27 @@ export const PropertiesPanel: React.FC = () => { /> </div> - <div className="e-row"> - <label className="e-label">Relation</label> - <Select - value={multiSpatialRelationDraft} - onChange={(value) => { - game.editor.saveUndoState(); - setMultiSpatialRelationDraft(value || ''); - applyToMultiRoots((o: any) => { - o.spatial = { - ...(o.spatial || {}), - parentNodeId: o.spatial?.parentNodeId || null, - relation: value || (o.spatial?.parentNodeId ? 'in' : null), - }; - }); - }} - options={getSpatialRelationOptions(!!sharedParentNodeId)} - style={{ width: '100%' }} - /> - </div> + {sharedParentNodeId && ( + <div className="e-row"> + <label className="e-label">Relation</label> + <Select + value={multiSpatialRelationDraft} + onChange={(value) => { + game.editor.saveUndoState(); + setMultiSpatialRelationDraft(value || ''); + applyToMultiRoots((o: any) => { + o.spatial = { + ...(o.spatial || {}), + parentNodeId: o.spatial?.parentNodeId || null, + relation: value || (o.spatial?.parentNodeId ? 'in' : null), + }; + }); + }} + options={getSpatialRelationOptions(true)} + style={{ width: '100%' }} + /> + </div> + )} <div className="e-row"> <label @@ -979,14 +1157,44 @@ export const PropertiesPanel: React.FC = () => { } }; + const renderSection = ( + section: number, + title: string | null, + color: 'blue' | 'red' | 'yellow' | 'purple' | 'neutral', + children: React.ReactNode + ) => ( + <div ref={setSectionRef(section)} className="properties-section-block" data-section={section}> + {title !== null && ( + <div className={`properties-section-header properties-section-${color}`}> + <div className="properties-section-title"> + <span className={`properties-section-number properties-section-${color}`}>{section}</span> + <span className="properties-section-label">{title}</span> + </div> + </div> + )} + {children} + </div> + ); + + const isEntityLike = + selectedObjectType === 'Entity' || selectedObjectType === 'Actor' || selectedObjectType === 'Static'; + const isTriggerbox = selectedObjectType === 'Triggerbox'; + const isWalkbox = selectedObjectType === 'Walkbox'; + const isScene = selectedObjectType === 'SCENE'; + const isSettings = selectedObjectType === 'SETTINGS'; + const isObjectWithScriptEvents = !isSettings && !isScene && !isWalkbox && selectedObjectType !== 'MULTI'; + return ( <div + ref={panelRef} id="editor-panel" className="editor-sidebar right" onMouseEnter={() => { + isPanelHoveredRef.current = true; if (game) game.isMouseOverUI = true; }} onMouseLeave={() => { + isPanelHoveredRef.current = false; if (game) game.isMouseOverUI = false; }} style={{ fontSize: `${12 * uiScale}px` }} @@ -1000,601 +1208,136 @@ export const PropertiesPanel: React.FC = () => { </button> </div> - <div className="editor-content"> - {selectedObjectType !== 'SETTINGS' && ( - <> - {/* Common: Name -> ID */} - <div className="e-row"> - <label className="e-label">{selectedObjectType === 'SCENE' ? 'ID/File' : 'ID'}</label> - <input - type="text" - className="e-input" - value={selectedObjectType === 'SCENE' ? obj.id || '' : obj.name || ''} - onChange={(e) => { - // Local update only - const val = e.target.value; - if (selectedObjectType === 'SCENE') obj.id = val; - else obj.name = val; - incrementObjectVersion(); - }} - onBlur={(e) => { - // Commit with Validation - const rawVal = e.target.value; - const finalVal = rawVal.trim(); - const field = selectedObjectType === 'SCENE' ? 'id' : 'name'; - - // Validation (Only for Name/ID) - let isValid = true; - const scene = game?.sceneManager?.currentScene; - - if (selectedObjectType !== 'SCENE' && scene) { - // Check duplicates - // Check Entities - const dupEntity = scene.entities.find( - (ent) => ent.name === finalVal && ent !== game?.editor?.selectedObject - ); - // Check Triggerboxes - const dupTrigger = scene.triggerboxes - ? scene.triggerboxes.find( - (tb) => tb.name === finalVal && tb !== game?.editor?.selectedObject - ) - : null; - - if (dupEntity || dupTrigger) { - console.warn(`[PropertiesPanel] Duplicate Name '${finalVal}' rejected.`); - // @ts-ignore - if (game.showMessage) game.showMessage(`Name '${finalVal}' already exists!`); - isValid = false; - } - } - - if (isValid) { - handleChange(field, finalVal); - } else { - // Revert to original from real object - let realObj: any = null; - if (game?.editor) realObj = game.editor.selectedObject; - - if (realObj) { - if (selectedObjectType === 'SCENE') obj.id = realObj.id; - else obj.name = realObj.name; - incrementObjectVersion(); - } - } - }} - /> - </div> - {supportsTextAsset && ( + <div ref={contentRef} className="editor-content"> + {!isSettings && + renderSection( + 0, + null, + 'neutral', + <> <div className="e-row"> - <label className="e-label">Title</label> + <label className="e-label">{isScene ? 'ID/File' : 'ID'}</label> <input type="text" className="e-input" - value={resolvedTitle} - readOnly - tabIndex={-1} - onFocus={(e) => e.currentTarget.blur()} - style={{ pointerEvents: 'none' }} - /> - {textAssetPath && ( - <> - <div style={{ display: 'flex', gap: '6px', marginTop: '4px' }}> - <button className="e-btn" onClick={handleOpenTA}> - {hasTextAsset ? 'Open TA' : 'Create TA'} - </button> - <button className="e-btn" onClick={handleReadTA} disabled={isReadingTA}> - {isReadingTA ? 'Syncing...' : 'Sync TA'} - </button> - {hasTextAsset && ( - <button className="e-btn" onClick={handleDeleteTA}> - Delete TA - </button> - )} - </div> - <div className="e-label ui-text-muted ui-text-small"> - {textAssetPath} - </div> - </> - )} - </div> - )} - </> - )} - - {selectedObjectType !== 'SETTINGS' && selectedObjectType !== 'SCENE' && ( - <div className="e-row"> - <label className="e-label">Group #ID</label> - <input - type="text" - className="e-input" - value={obj.groupID || ''} - onChange={(e) => { - const val = e.target.value; - // Auto-format: Ensure every token starts with # - // 1. Split by comma - const tokens = val.split(','); - const newTokens = tokens.map((t) => { - // Don't auto-add to the very last token if it's empty (user just typed comma) - if (t.length === 0) return ''; - - let clean = t; - // If this is a new char entry (not just backspace), check prefix - const trimmed = t.trimStart(); - if (trimmed.length > 0 && !trimmed.startsWith('#')) { - // Find where the white space ends to insert # - const firstCharIdx = t.length - trimmed.length; - clean = t.substring(0, firstCharIdx) + '#' + trimmed; - } - return clean; - }); - - handleChange('groupID', newTokens.join(',')); - }} - /> - </div> - )} - - {/* Entity Properties (Static, Actor, Entity) - Moved & Compacted */} - {(selectedObjectType === 'Entity' || - selectedObjectType === 'Actor' || - selectedObjectType === 'Static') && ( - <> - {/* Transform: X, Y, W, H */} - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label">X</label> - <input - type="number" - className="e-input" - value={obj.x ?? 0} - onChange={(e) => handleChange('x', e.target.value, true)} - /> - </div> - <div> - <label className="e-label">Y</label> - <input - type="number" - className="e-input" - value={obj.y ?? 0} - onChange={(e) => handleChange('y', e.target.value, true)} - /> - </div> - <div> - <label className="e-label">W</label> - <input - type="number" - className="e-input" - value={obj.width ?? 0} - onChange={(e) => handleChange('width', e.target.value, true)} - /> - </div> - <div> - <label className="e-label">H</label> - <input - type="number" - className="e-input" - value={obj.height ?? 0} - onChange={(e) => handleChange('height', e.target.value, true)} - /> - </div> - </div> - - {/* Scale, Layer, Parallax */} - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label">Scale</label> - <input - type="number" - step="0.1" - className="e-input" - value={obj.modelScale || 1} - onChange={(e) => handleChange('modelScale', e.target.value, true)} - /> - </div> - <div> - <label className="e-label">Layer</label> - <input - type="number" - className="e-input" - value={obj.layer || 0} - onChange={(e) => handleChange('layer', e.target.value, true)} - /> - </div> - <div> - <label className="e-label">Parallax</label> - <input - type="number" - step="0.1" - className="e-input" - value={obj.parallax ?? 1} + value={isScene ? obj.id || '' : obj.name || ''} onChange={(e) => { - const val = parseFloat(e.target.value); - const newP = isNaN(val) ? 1.0 : val; - const oldP = obj.parallax !== undefined ? obj.parallax : 1.0; - - // Auto-Correct Position to prevent visual jump - // NewPos = OldPos + Cam * (NewP - OldP) - const scene = game.sceneManager.currentScene; - if (scene && game.editor.selectedObject) { - const camX = scene.camera.x; - const camY = scene.camera.y; - - const dx = camX * (newP - oldP); - const dy = camY * (newP - oldP); - - // Apply to Local - obj.x += dx; - obj.y += dy; - - // Apply to Real (Must do this manually as handleChange only does the targeting field) - if ( - game.editor && - game.editor.selectedObject && - 'x' in game.editor.selectedObject - ) { - (game.editor.selectedObject as any).x = obj.x; - (game.editor.selectedObject as any).y = obj.y; + const val = e.target.value; + if (isScene) obj.id = val; + else obj.name = val; + incrementObjectVersion(); + }} + onBlur={(e) => { + const rawVal = e.target.value; + const finalVal = rawVal.trim(); + const field = isScene ? 'id' : 'name'; + + let isValid = true; + const scene = game?.sceneManager?.currentScene; + + if (!isScene && scene) { + const dupEntity = scene.entities.find( + (ent) => ent.name === finalVal && ent !== game?.editor?.selectedObject + ); + const dupTrigger = scene.triggerboxes + ? scene.triggerboxes.find( + (tb) => tb.name === finalVal && tb !== game?.editor?.selectedObject + ) + : null; + + if (dupEntity || dupTrigger) { + console.warn(`[PropertiesPanel] Duplicate Name '${finalVal}' rejected.`); + // @ts-ignore + if (game.showMessage) game.showMessage(`Name '${finalVal}' already exists!`); + isValid = false; } } - handleChange('parallax', newP, true); + if (isValid) { + handleChange(field, finalVal); + } else { + let realObj: any = null; + if (game?.editor) realObj = game.editor.selectedObject; + + if (realObj) { + if (isScene) obj.id = realObj.id; + else obj.name = realObj.name; + incrementObjectVersion(); + } + } }} /> </div> - </div> - - {(selectedObjectType === 'Entity' || - selectedObjectType === 'Actor' || - selectedObjectType === 'Static') && ( - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label">Parent</label> - <Select - className="parent-id-select" - value={obj.spatial?.parentNodeId || ''} - onChange={(value) => { - game.editor.saveUndoState(); - obj.spatial = { - ...(obj.spatial || {}), - parentNodeId: value || null, - relation: value ? obj.spatial?.relation || 'in' : null, - }; - incrementObjectVersion(); - incrementHierarchyVersion(); - }} - options={getSceneSpatialParentOptions()} - style={{ width: '100%' }} - /> - </div> - <div> - <label className="e-label">Relation</label> - <Select - value={obj.spatial?.relation || ''} - onChange={(value) => { - game.editor.saveUndoState(); - obj.spatial = { - ...(obj.spatial || {}), - parentNodeId: obj.spatial?.parentNodeId || null, - relation: value || (obj.spatial?.parentNodeId ? 'in' : null), - }; - incrementObjectVersion(); - incrementHierarchyVersion(); - }} - options={getSpatialRelationOptions(!!obj.spatial?.parentNodeId)} - style={{ width: '100%' }} - /> - </div> - </div> - )} - {/* Color & Blend Mode */} - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label">Color</label> - <div style={{ display: 'flex', gap: '5px' }}> + {supportsTextAsset && ( + <div className="e-row"> + <label className="e-label">Title</label> <input - type="color" + type="text" className="e-input" - style={{ - width: '30px', - padding: 0, - height: '20px', - cursor: 'pointer', - border: 'none', - }} - value={obj.color || '#AAAAAA'} - onChange={(e) => handleChange('color', e.target.value)} + value={resolvedTitle} + readOnly + tabIndex={-1} + onFocus={(e) => e.currentTarget.blur()} + style={{ pointerEvents: 'none' }} /> + {textAssetPath && ( + <> + <div style={{ display: 'flex', gap: '6px', marginTop: '4px', flexWrap: 'wrap' }}> + <button className="e-btn" onClick={handleOpenTA}> + {hasTextAsset ? 'Open TA' : 'Create TA'} + </button> + <button className="e-btn" onClick={handleReadTA} disabled={isReadingTA}> + {isReadingTA ? 'Syncing...' : 'Sync TA'} + </button> + {hasTextAsset && ( + <button className="e-btn" onClick={handleDeleteTA}> + Delete TA + </button> + )} + </div> + <div className="e-label ui-text-muted ui-text-small">{textAssetPath}</div> + </> + )} + </div> + )} + + {!isScene && !isSettings && ( + <div className="e-row"> + <label className="e-label">Group #ID</label> <input type="text" className="e-input" - style={{ flex: 1, minWidth: 0 }} - value={obj.color || ''} - onChange={(e) => handleChange('color', e.target.value)} + value={obj.groupID || ''} + onChange={(e) => { + const val = e.target.value; + const tokens = val.split(','); + const newTokens = tokens.map((t) => { + if (t.length === 0) return ''; + + let clean = t; + const trimmed = t.trimStart(); + if (trimmed.length > 0 && !trimmed.startsWith('#')) { + const firstCharIdx = t.length - trimmed.length; + clean = t.substring(0, firstCharIdx) + '#' + trimmed; + } + return clean; + }); + + handleChange('groupID', newTokens.join(',')); + }} /> </div> - </div> - <div> - <label className="e-label">Blend Mode</label> - <Select - value={obj.blendMode || 'source-over'} - onChange={(value) => handleChange('blendMode', value)} - options={[ - { value: 'source-over', label: 'Normal' }, - { value: 'multiply', label: 'Multiply' }, - { value: 'screen', label: 'Screen' }, - { value: 'overlay', label: 'Overlay' }, - { value: 'lighter', label: 'Add' }, - { value: 'difference', label: 'Diff' }, - ]} - style={{ width: '100%' }} - /> - </div> - </div> + )} - {/* Opacity & Blur */} - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label"> - Opacity ({Math.round((obj.opacity !== undefined ? obj.opacity : 1.0) * 100)}%) - </label> - <input - type="range" - className="e-input" - style={{ width: '100%' }} - min="0" - max="1" - step="0.05" - value={obj.opacity !== undefined ? obj.opacity : 1.0} - onChange={(e) => handleChange('opacity', e.target.value, true)} - /> - </div> - <div> - <label className="e-label">Blur ({obj.blur || 0}px)</label> - <input - type="range" - className="e-input" - style={{ width: '100%', direction: 'ltr' }} - min="0" - max="50" - step="1" - value={50 - (obj.blur || 0)} - onChange={(e) => handleChange('blur', 50 - parseInt(e.target.value))} - /> - </div> - </div> - - {/* Sprite */} - <div className="e-row"> - <label className="e-label">Sprite</label> - <div style={{ display: 'flex', gap: '5px' }}> - <input - type="text" - className="e-input" - style={{ flex: 1 }} - value={obj.spriteName || ''} - onChange={(e) => handleChange('spriteName', e.target.value)} - /> - <button - className="e-btn" - onClick={() => - game.openFileBrowser('load', 'public/sprites', (f) => - handleChange('spriteName', f) - ) - } - > - ... - </button> - </div> - </div> - - {/* Colliders + Flags */} - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label">Collider W</label> - <input - type="number" - className="e-input" - value={obj.colliderWidth ?? 0} - onChange={(e) => handleChange('colliderWidth', e.target.value, true)} - /> - </div> - <div> - <label className="e-label">Collider H</label> - <input - type="number" - className="e-input" - value={obj.colliderHeight ?? 0} - onChange={(e) => handleChange('colliderHeight', e.target.value, true)} - /> - </div> - </div> - - <div className="e-row"> - <label className="e-label" style={{ display: 'flex', alignItems: 'center' }}> - <input - type="checkbox" - style={{ marginRight: '5px' }} - checked={!!obj.ignoreScaling} - onChange={(e) => handleChange('ignoreScaling', e.target.checked)} - /> - Disable Depth Scaling - </label> - </div> - - <div className="e-row"> - <label className="e-label" style={{ display: 'flex', alignItems: 'center' }}> - <input - type="checkbox" - style={{ marginRight: '5px' }} - checked={!!obj.locked} - onChange={(e) => handleChange('locked', e.target.checked)} - /> - Lock Object - </label> - </div> - <div className="e-row"> - <label className="e-label ui-inline-flex-center ui-text-accent-red"> - <input - type="checkbox" - style={{ marginRight: '5px' }} - checked={!!obj.disabled} - onChange={(e) => handleChange('disabled', e.target.checked)} - /> - Disabled (Hidden) - </label> - </div> - - {/* Interactions */} - <div className="e-row ui-divider-blue" style={{ marginTop: '10px', paddingTop: '5px' }}> - <div - className="e-label ui-text-accent-blue ui-font-bold" - style={{ display: 'flex', justifyContent: 'space-between' }} - > - <span>SCRIPT EVENTS</span> - <Select - value="" - className="compact-action-select" - placeholder="+ ADD" - onChange={(value) => { - const verb = value; - if (!verb) return; - if (!obj.interactions) obj.interactions = {}; - if (!obj.interactions[verb]) { - obj.interactions[verb] = ''; - // Sync to real object - if (game.editor.selectedObject) { - if (!(game.editor.selectedObject as any).interactions) - (game.editor.selectedObject as any).interactions = {}; - (game.editor.selectedObject as any).interactions[verb] = ''; - } - incrementObjectVersion(); - } - }} - options={[ - { value: 'look', label: 'Look' }, - { value: 'use', label: 'Use' }, - { value: 'talk', label: 'Talk' }, - { value: 'pickup', label: 'Pickup' }, - ]} - style={{ width: '8em' }} - /> - </div> - - {obj.interactions && - Object.keys(obj.interactions).map((verb) => ( - <div - key={verb} - style={{ display: 'flex', alignItems: 'center', marginTop: '2px' }} - > - <div className="ui-text-light" style={{ width: '40px', fontSize: '0.85em' }}> - {verb.toUpperCase()} - </div> - <input - type="text" - className="e-input" - style={{ flex: 1, fontSize: '0.85em' }} - placeholder="Script ID" - value={obj.interactions[verb]} - onChange={(e) => { - obj.interactions[verb] = e.target.value; - // Sync to real object - if (game.editor.selectedObject) { - (game.editor.selectedObject as any).interactions[verb] = e.target.value; - } - incrementObjectVersion(); - }} - /> - <button - className="e-btn e-btn-red" - style={{ marginLeft: '2px', padding: '0 4px', fontSize: '0.85em' }} - onClick={() => { - delete obj.interactions[verb]; - // Sync to real object - if (game.editor.selectedObject) { - delete (game.editor.selectedObject as any).interactions[verb]; - } - incrementObjectVersion(); - }} - > - x - </button> - </div> - ))} - </div> - </> - )} - - {/* Walkbox/Triggerbox Properties */} - {(selectedObjectType === 'Walkbox' || selectedObjectType === 'Triggerbox') && ( - <div className="e-row"> - {selectedObjectType === 'Walkbox' && ( - <div className="e-row"> - <label className="e-label">Mode</label> - <Select - value={obj.mode || 'Invert'} - onChange={(value) => handleChange('mode', value)} - options={[ - { value: 'Invert', label: 'Invert (Standard)' }, - { value: 'Add', label: 'Add (Bridge)' }, - { value: 'Subtract', label: 'Subtract (Hole)' }, - ]} - style={{ width: '100%', marginBottom: '5px' }} - /> - </div> - )} - <button - className="e-btn e-btn-yellow" - style={{ width: '100%', marginBottom: '5px' }} - onClick={(e) => { - if (confirm('Redraw polygon? Current points will be cleared.')) { - // Clean Redraw Logic: Editor handles clearing and mode setting - game.editor.redrawSelected(); - // Blur the button so hitting Enter doesn't re-trigger it - (e.target as HTMLElement).blur(); - } - }} - > - Redraw Polygon - </button> - <div className="e-label"> - {mode && mode.includes('DRAW') - ? 'Click to add points. Press ENTER to finish. Hold Shift for 22.5° snap.' - : 'To edit, drag vertices on screen. Hold Shift for 22.5° snap.'} - </div> - - {selectedObjectType === 'Triggerbox' && ( - <> - <div className="e-row"> - <label className="e-label">Layer</label> - <input - type="number" - className="e-input" - value={obj.layer || 0} - onChange={(e) => handleChange('layer', e.target.value, true)} - /> - </div> + {!isScene && !isSettings && !isWalkbox && ( <div className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} + style={{ + display: obj.spatial?.parentNodeId ? 'grid' : 'block', + gridTemplateColumns: obj.spatial?.parentNodeId ? '1fr 1fr' : undefined, + gap: obj.spatial?.parentNodeId ? '5px' : undefined, + }} > <div> <label className="e-label">Parent</label> @@ -1615,247 +1358,437 @@ export const PropertiesPanel: React.FC = () => { style={{ width: '100%' }} /> </div> + {obj.spatial?.parentNodeId && ( + <div> + <label className="e-label">Relation</label> + <Select + value={obj.spatial?.relation || 'in'} + onChange={(value) => { + game.editor.saveUndoState(); + obj.spatial = { + ...(obj.spatial || {}), + parentNodeId: obj.spatial?.parentNodeId || null, + relation: value || (obj.spatial?.parentNodeId ? 'in' : null), + }; + incrementObjectVersion(); + incrementHierarchyVersion(); + }} + options={getSpatialRelationOptions(true)} + style={{ width: '100%' }} + /> + </div> + )} + </div> + )} + </> + )} + + {isEntityLike && ( + <> + {renderSection( + 1, + 'Transform', + 'blue', + <> + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: '5px' }} + > <div> - <label className="e-label">Relation</label> - <Select - value={obj.spatial?.parentNodeId ? obj.spatial?.relation || 'in' : obj.spatial?.relation || ''} - onChange={(value) => { - game.editor.saveUndoState(); - obj.spatial = { - ...(obj.spatial || {}), - parentNodeId: obj.spatial?.parentNodeId || null, - relation: value || (obj.spatial?.parentNodeId ? 'in' : null), - }; - incrementObjectVersion(); - incrementHierarchyVersion(); - }} - options={getSpatialRelationOptions(!!obj.spatial?.parentNodeId)} - style={{ width: '100%' }} + <label className="e-label">X</label> + <input + type="number" + className="e-input" + value={obj.x ?? 0} + onChange={(e) => handleChange('x', e.target.value, true)} + /> + </div> + <div> + <label className="e-label">Y</label> + <input + type="number" + className="e-input" + value={obj.y ?? 0} + onChange={(e) => handleChange('y', e.target.value, true)} + /> + </div> + <div> + <label className="e-label">H</label> + <input + type="number" + className="e-input" + value={obj.height ?? 0} + onChange={(e) => handleChange('height', e.target.value, true)} + /> + </div> + <div> + <label className="e-label">W</label> + <input + type="number" + className="e-input" + value={obj.width ?? 0} + onChange={(e) => handleChange('width', e.target.value, true)} /> </div> </div> - </> - )} - - <div className="e-row"> - <label className="e-label" style={{ display: 'flex', alignItems: 'center' }}> - <input - type="checkbox" - style={{ marginRight: '5px' }} - checked={!!obj.locked} - onChange={(e) => handleChange('locked', e.target.checked)} - /> - Lock Object (Prevent Mouse Edit) - </label> - </div> - <div className="e-row"> - <label className="e-label ui-inline-flex-center ui-text-accent-red"> - <input - type="checkbox" - style={{ marginRight: '5px' }} - checked={!!obj.disabled} - onChange={(e) => handleChange('disabled', e.target.checked)} - /> - Disabled (Hidden in Game) - </label> - </div> - </div> - )} - {/* Quad Properties */} - {selectedObjectType === 'Quad' && ( - <div className="e-row"> - {/* Layer */} - <div className="e-row"> - <label className="e-label">Layer</label> - <input - type="number" - className="e-input" - value={obj.layer || 0} - onChange={(e) => handleChange('layer', e.target.value, true)} - /> - </div> - - {/* Opacity / Blur */} - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label"> - Opacity ({Math.round((obj.opacity !== undefined ? obj.opacity : 1.0) * 100)}%) - </label> - <input - type="range" - className="e-input" - style={{ width: '100%' }} - min="0" - max="1" - step="0.05" - value={obj.opacity !== undefined ? obj.opacity : 1.0} - onChange={(e) => handleChange('opacity', e.target.value, true)} - /> - </div> - <div> - <label className="e-label">Blur ({obj.blur || 0}px)</label> - <input - type="range" - className="e-input" - style={{ width: '100%', direction: 'ltr' }} - min="0" - max="50" - step="1" - value={50 - (obj.blur || 0)} - onChange={(e) => handleChange('blur', 50 - parseInt(e.target.value))} - /> - </div> - </div> + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label">Scale</label> + <input + type="number" + step="0.1" + className="e-input" + value={obj.modelScale || 1} + onChange={(e) => handleChange('modelScale', e.target.value, true)} + /> + </div> + <div> + <label className="e-label">Layer</label> + <input + type="number" + className="e-input" + value={obj.layer || 0} + onChange={(e) => handleChange('layer', e.target.value, true)} + /> + </div> + <div> + <label className="e-label">Parallax</label> + <input + type="number" + step="0.1" + className="e-input" + value={obj.parallax ?? 1} + onChange={(e) => { + const val = parseFloat(e.target.value); + const newP = isNaN(val) ? 1.0 : val; + const oldP = obj.parallax !== undefined ? obj.parallax : 1.0; + const scene = game.sceneManager.currentScene; + if (scene && game.editor.selectedObject) { + const camX = scene.camera.x; + const camY = scene.camera.y; + obj.x += camX * (newP - oldP); + obj.y += camY * (newP - oldP); + if (game.editor && game.editor.selectedObject && 'x' in game.editor.selectedObject) { + (game.editor.selectedObject as any).x = obj.x; + (game.editor.selectedObject as any).y = obj.y; + } + } + handleChange('parallax', newP, true); + }} + /> + </div> + </div> - {/* Fill Color */} - <div className="e-row"> - <label - className="e-label" - style={{ - display: 'flex', - alignItems: 'center', - marginBottom: '4px', - color: obj.filled !== false ? '#ffffff' : 'inherit', - }} - > - <input - type="checkbox" - style={{ marginRight: '5px' }} - checked={obj.filled !== false} - onChange={(e) => handleChange('filled', e.target.checked)} - /> - Fill Color - </label> - {obj.filled !== false && ( - <div style={{ display: 'flex', gap: '5px' }}> - <input - type="color" - className="e-input" - style={{ width: '30px', padding: 0, height: '20px' }} - value={obj.color || '#888888'} - onChange={(e) => handleChange('color', e.target.value)} - /> - <input - type="text" - className="e-input" - style={{ flex: 1 }} - value={obj.color || ''} - onChange={(e) => handleChange('color', e.target.value)} - /> + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label">Collider H</label> + <input + type="number" + className="e-input" + value={obj.colliderHeight ?? 0} + onChange={(e) => handleChange('colliderHeight', e.target.value, true)} + /> + </div> + <div> + <label className="e-label">Collider W</label> + <input + type="number" + className="e-input" + value={obj.colliderWidth ?? 0} + onChange={(e) => handleChange('colliderWidth', e.target.value, true)} + /> + </div> </div> - )} - </div> - {/* Retro Grid */} - <div className="e-row"> - <label - className="e-label" - style={{ - display: 'flex', - alignItems: 'center', - color: obj.isGrid ? '#ffffff' : 'inherit', - }} - > - <input - type="checkbox" - style={{ marginRight: '5px' }} - checked={obj.isGrid || false} - onChange={(e) => handleChange('isGrid', e.target.checked)} - /> - Retro Grid - </label> - </div> + <div className="e-row"> + <label className="e-label" style={{ display: 'flex', alignItems: 'center' }}> + <input + type="checkbox" + style={{ marginRight: '5px' }} + checked={!!obj.ignoreScaling} + onChange={(e) => handleChange('ignoreScaling', e.target.checked)} + /> + Disable Depth-scaling + </label> + </div> + </> + )} - {obj.isGrid && ( + {renderSection( + 2, + 'Visual', + 'yellow', <> <div className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }} + style={{ display: 'grid', gridTemplateColumns: '1.5fr 1fr', gap: '5px' }} > <div> - <label className="e-label">Grid X</label> + <label className="e-label">Fill Color</label> + <div style={{ display: 'flex', gap: '5px' }}> + <input + type="color" + className="e-input" + style={{ width: '30px', padding: 0, height: '20px', cursor: 'pointer', border: 'none' }} + value={obj.color || '#AAAAAA'} + onChange={(e) => handleChange('color', e.target.value)} + /> + <input + type="text" + className="e-input" + style={{ flex: 1, minWidth: 0 }} + value={obj.color || ''} + onChange={(e) => handleChange('color', e.target.value)} + /> + </div> + </div> + <div> + <label className="e-label">Blend Mode</label> + <Select + value={obj.blendMode || 'source-over'} + onChange={(value) => handleChange('blendMode', value)} + options={[ + { value: 'source-over', label: 'Normal' }, + { value: 'multiply', label: 'Multiply' }, + { value: 'screen', label: 'Screen' }, + { value: 'overlay', label: 'Overlay' }, + { value: 'lighter', label: 'Add' }, + { value: 'difference', label: 'Diff' }, + ]} + style={{ width: '100%' }} + /> + </div> + </div> + + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label"> + Opacity ({Math.round((obj.opacity !== undefined ? obj.opacity : 1.0) * 100)}%) + </label> <input - type="number" + type="range" className="e-input" - value={obj.gridLinesX ?? 5} - onChange={(e) => handleChange('gridLinesX', parseInt(e.target.value))} - min={1} - max={50} + style={{ width: '100%' }} + min="0" + max="1" + step="0.05" + value={obj.opacity !== undefined ? obj.opacity : 1.0} + onChange={(e) => handleChange('opacity', e.target.value, true)} /> </div> <div> - <label className="e-label">Grid Y</label> + <label className="e-label">Blur ({obj.blur || 0}px)</label> + <input + type="range" + className="e-input" + style={{ width: '100%', direction: 'ltr' }} + min="0" + max="50" + step="1" + value={50 - (obj.blur || 0)} + onChange={(e) => handleChange('blur', 50 - parseInt(e.target.value))} + /> + </div> + </div> + + <div className="e-row"> + <label className="e-label">Sprite</label> + <div style={{ display: 'flex', gap: '5px' }}> + <input + type="text" + className="e-input" + style={{ flex: 1 }} + value={obj.spriteName || ''} + onChange={(e) => handleChange('spriteName', e.target.value)} + /> + <button + className="e-btn" + onClick={() => + game.openFileBrowser('load', 'public/sprites', (f) => handleChange('spriteName', f)) + } + > + ... + </button> + </div> + </div> + </> + )} + + </> + )} + + {selectedObjectType === 'Walkbox' && ( + <div className="e-row"> + <div className="e-row"> + <label className="e-label">Mode</label> + <Select + value={obj.mode || 'Invert'} + onChange={(value) => handleChange('mode', value)} + options={[ + { value: 'Invert', label: 'Invert (Standard)' }, + { value: 'Add', label: 'Add (Bridge)' }, + { value: 'Subtract', label: 'Subtract (Hole)' }, + ]} + style={{ width: '100%', marginBottom: '5px' }} + /> + </div> + <button + className="e-btn e-btn-yellow" + style={{ width: '100%', marginBottom: '5px' }} + onClick={(e) => { + if (confirm('Redraw polygon? Current points will be cleared.')) { + game.editor.redrawSelected(); + (e.target as HTMLElement).blur(); + } + }} + > + Redraw Polygon + </button> + <div className="e-label"> + {mode && mode.includes('DRAW') + ? 'Click to add points. Press ENTER to finish. Hold Shift for 22.5° snap.' + : 'To edit, drag vertices on screen. Hold Shift for 22.5° snap.'} + </div> + </div> + )} + + {isTriggerbox && ( + <> + {renderSection( + 1, + 'Transform', + 'blue', + <> + <div className="e-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}> + <div> + <label className="e-label">X</label> <input type="number" className="e-input" - value={obj.gridLinesY ?? 5} - onChange={(e) => handleChange('gridLinesY', parseInt(e.target.value))} - min={1} - max={50} + value={Math.round(getPolyCentroid(obj.poly).x)} + onChange={(e) => translatePolyTo(parseFloat(e.target.value) || 0, getPolyCentroid(obj.poly).y)} /> </div> <div> - <label className="e-label">Width</label> + <label className="e-label">Y</label> <input type="number" className="e-input" - value={obj.lineWidth ?? 1.0} - onChange={(e) => handleChange('lineWidth', parseFloat(e.target.value))} - step={0.1} - min={0.1} - max={10} + value={Math.round(getPolyCentroid(obj.poly).y)} + onChange={(e) => translatePolyTo(getPolyCentroid(obj.poly).x, parseFloat(e.target.value) || 0)} /> </div> </div> - <div className="e-row"> - <label className="e-label">Grid Color</label> - <div style={{ display: 'flex', gap: '5px' }}> + <div className="e-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}> + <div> + <label className="e-label">Scale</label> <input - type="color" + type="number" + step="0.01" + min="0.01" className="e-input" - style={{ width: '30px', padding: 0, height: '20px' }} - value={obj.gridColor || '#ffffff'} - onChange={(e) => handleChange('gridColor', e.target.value)} + value={polygonScaleDraft} + onChange={(e) => applyPolygonScaleDraft(e.target.value)} /> + </div> + <div> + <label className="e-label">Layer</label> <input - type="text" + type="number" className="e-input" - style={{ flex: 1 }} - value={obj.gridColor || ''} - onChange={(e) => handleChange('gridColor', e.target.value)} + value={obj.layer || 0} + onChange={(e) => handleChange('layer', e.target.value, true)} /> </div> </div> </> )} - {/* Blend & Sort (Extras) */} + </> + )} + + {/* Quad Properties */} + {selectedObjectType === 'Quad' && ( + <div className="e-row"> + <div ref={setSectionRef(1)} className="properties-section-block"> + <div className="properties-section-header properties-section-blue"> + <div className="properties-section-title"> + <span className="properties-section-number properties-section-blue">1</span> + <span className="properties-section-label">Transform</span> + </div> + </div> + </div> + <div className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }} > <div> - <label className="e-label">Blend Mode</label> - <Select - value={obj.blendMode || 'source-over'} - onChange={(value) => handleChange('blendMode', value)} - options={[ - { value: 'source-over', label: 'Normal' }, - { value: 'multiply', label: 'Multiply' }, - { value: 'screen', label: 'Screen' }, - { value: 'overlay', label: 'Overlay' }, - { value: 'lighter', label: 'Add (Lighter)' }, - { value: 'difference', label: 'Difference' }, - ]} - style={{ width: '100%' }} + <label className="e-label">X</label> + <input + type="number" + className="e-input" + value={Math.round(getQuadCentroid(obj).x)} + onChange={(e) => translateQuadTo(parseFloat(e.target.value) || 0, getQuadCentroid(obj).y)} + /> + </div> + <div> + <label className="e-label">Y</label> + <input + type="number" + className="e-input" + value={Math.round(getQuadCentroid(obj).y)} + onChange={(e) => translateQuadTo(getQuadCentroid(obj).x, parseFloat(e.target.value) || 0)} + /> + </div> + <div> + <label className="e-label">Layer</label> + <input + type="number" + className="e-input" + value={obj.layer || 0} + onChange={(e) => handleChange('layer', e.target.value, true)} + /> + </div> + </div> + + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label">Parallax</label> + <input + type="number" + step="0.1" + className="e-input" + value={obj.parallax ?? 1} + onChange={(e) => handleChange('parallax', e.target.value, true)} + /> + </div> + <div> + <label className="e-label">Scale</label> + <input + type="number" + step="0.01" + min="0.01" + className="e-input" + value={polygonScaleDraft} + onChange={(e) => applyPolygonScaleDraft(e.target.value)} /> </div> <div> - <label className="e-label">Sort Mode</label> + <label className="e-label">Depth Sort mode</label> <Select value={obj.sortMode || 'ignore'} onChange={(value) => handleChange('sortMode', value)} @@ -1871,11 +1804,8 @@ export const PropertiesPanel: React.FC = () => { </div> </div> - <div - className="e-label" - style={{ marginTop: '5px', borderBottom: '1px solid #444', marginBottom: '5px' }} - > - VERTICES (X / Y / P) + <div className="e-label ui-text-accent-blue ui-font-bold" style={{ marginTop: '6px', marginBottom: '6px' }}> + Vertices </div> {obj.vertices && obj.vertices.map((v: any, i: number) => { @@ -1931,18 +1861,13 @@ export const PropertiesPanel: React.FC = () => { }} onClick={() => { const binding = v.binding; - // Unbind Self (UI Copy) delete v.binding; incrementObjectVersion(); - // Sync to real object & Unbind Reverse if (game.editor.selectedObject) { const sel = game.editor.selectedObject as any; - - // Unbind Self (Real) if (sel.vertices[i].binding) delete sel.vertices[i].binding; - // Unbind Reverse (Real) if (binding && binding.type === 'vertex') { const scene = game.sceneManager.currentScene; if (scene) { @@ -1954,7 +1879,6 @@ export const PropertiesPanel: React.FC = () => { const tIdx = binding.index; if (tIdx !== undefined && tQuad.vertices[tIdx]) { const tV = tQuad.vertices[tIdx]; - // Check if target is bound back to US (Mutual) if ( tV.binding && tV.binding.type === 'vertex' && @@ -1987,8 +1911,6 @@ export const PropertiesPanel: React.FC = () => { const val = parseFloat(e.target.value); if (v.x !== val) { const diff = val - v.x; - - // Propagate to Group const scene = game.sceneManager.currentScene; if (scene && (game.editor.selectedObject as any).type === 'Quad') { const group = QuadObject.getConnectedVertices( @@ -2017,8 +1939,6 @@ export const PropertiesPanel: React.FC = () => { const val = parseFloat(e.target.value); if (v.y !== val) { const diff = val - v.y; - - // Propagate to Group const scene = game.sceneManager.currentScene; if (scene && (game.editor.selectedObject as any).type === 'Quad') { const group = QuadObject.getConnectedVertices( @@ -2058,17 +1978,13 @@ export const PropertiesPanel: React.FC = () => { ); group.forEach((ref) => { - // Auto-Correct Position to prevent visual jump - // NewPos = OldPos + Cam * (NewP - OldP) const camX = scene.camera.x; const camY = scene.camera.y; ref.v.x += camX * diffP; ref.v.y += camY * diffP; - - ref.v.p = newP; // All adopt the new P? Yes, per "changes parallax together". + ref.v.p = newP; }); } else { - // Single logic if (scene) { const camX = scene.camera.x; const camY = scene.camera.y; @@ -2087,29 +2003,191 @@ export const PropertiesPanel: React.FC = () => { ); })} + {/* Opacity / Blur */} + <div ref={setSectionRef(2)} className="properties-section-block"> + <div className="properties-section-header properties-section-yellow"> + <div className="properties-section-title"> + <span className="properties-section-number properties-section-yellow">2</span> + <span className="properties-section-label">Visual</span> + </div> + </div> + </div> + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label"> + Opacity ({Math.round((obj.opacity !== undefined ? obj.opacity : 1.0) * 100)}%) + </label> + <input + type="range" + className="e-input" + style={{ width: '100%' }} + min="0" + max="1" + step="0.05" + value={obj.opacity !== undefined ? obj.opacity : 1.0} + onChange={(e) => handleChange('opacity', e.target.value, true)} + /> + </div> + <div> + <label className="e-label">Blur ({obj.blur || 0}px)</label> + <input + type="range" + className="e-input" + style={{ width: '100%', direction: 'ltr' }} + min="0" + max="50" + step="1" + value={50 - (obj.blur || 0)} + onChange={(e) => handleChange('blur', 50 - parseInt(e.target.value))} + /> + </div> + </div> + + {/* Fill Color */} <div className="e-row"> - <label className="e-label" style={{ display: 'flex', alignItems: 'center' }}> + <label + className="e-label" + style={{ + display: 'flex', + alignItems: 'center', + marginBottom: '4px', + color: obj.filled !== false ? '#ffffff' : 'inherit', + }} + > <input type="checkbox" style={{ marginRight: '5px' }} - checked={!!obj.locked} - onChange={(e) => handleChange('locked', e.target.checked)} + checked={obj.filled !== false} + onChange={(e) => handleChange('filled', e.target.checked)} /> - Lock Object + Fill Color </label> + {obj.filled !== false && ( + <div style={{ display: 'flex', gap: '5px' }}> + <input + type="color" + className="e-input" + style={{ width: '30px', padding: 0, height: '20px' }} + value={obj.color || '#888888'} + onChange={(e) => handleChange('color', e.target.value)} + /> + <input + type="text" + className="e-input" + style={{ flex: 1 }} + value={obj.color || ''} + onChange={(e) => handleChange('color', e.target.value)} + /> + </div> + )} </div> + + {/* Retro Grid */} <div className="e-row"> - <label className="e-label ui-inline-flex-center ui-text-accent-red"> + <label + className="e-label" + style={{ + display: 'flex', + alignItems: 'center', + color: obj.isGrid ? '#ffffff' : 'inherit', + }} + > <input type="checkbox" style={{ marginRight: '5px' }} - checked={!!obj.disabled} - onChange={(e) => handleChange('disabled', e.target.checked)} + checked={obj.isGrid || false} + onChange={(e) => handleChange('isGrid', e.target.checked)} /> - Disabled + Retro Grid </label> </div> - {/* Tips moved to bottom */} + + {obj.isGrid && ( + <> + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label">Grid X</label> + <input + type="number" + className="e-input" + value={obj.gridLinesX ?? 5} + onChange={(e) => handleChange('gridLinesX', parseInt(e.target.value))} + min={1} + max={50} + /> + </div> + <div> + <label className="e-label">Grid Y</label> + <input + type="number" + className="e-input" + value={obj.gridLinesY ?? 5} + onChange={(e) => handleChange('gridLinesY', parseInt(e.target.value))} + min={1} + max={50} + /> + </div> + <div> + <label className="e-label">Width</label> + <input + type="number" + className="e-input" + value={obj.lineWidth ?? 1.0} + onChange={(e) => handleChange('lineWidth', parseFloat(e.target.value))} + step={0.1} + min={0.1} + max={10} + /> + </div> + </div> + <div className="e-row"> + <label className="e-label">Grid Color</label> + <div style={{ display: 'flex', gap: '5px' }}> + <input + type="color" + className="e-input" + style={{ width: '30px', padding: 0, height: '20px' }} + value={obj.gridColor || '#ffffff'} + onChange={(e) => handleChange('gridColor', e.target.value)} + /> + <input + type="text" + className="e-input" + style={{ flex: 1 }} + value={obj.gridColor || ''} + onChange={(e) => handleChange('gridColor', e.target.value)} + /> + </div> + </div> + </> + )} + + {/* Blend */} + <div className="e-row" style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '5px' }}> + <div> + <label className="e-label">Blend Mode</label> + <Select + value={obj.blendMode || 'source-over'} + onChange={(value) => handleChange('blendMode', value)} + options={[ + { value: 'source-over', label: 'Normal' }, + { value: 'multiply', label: 'Multiply' }, + { value: 'screen', label: 'Screen' }, + { value: 'overlay', label: 'Overlay' }, + { value: 'lighter', label: 'Add (Lighter)' }, + { value: 'difference', label: 'Difference' }, + ]} + style={{ width: '100%' }} + /> + </div> + </div> + </div> )} @@ -2119,13 +2197,15 @@ export const PropertiesPanel: React.FC = () => { selectedObjectType === 'Actor' || selectedObjectType === 'Static' || selectedObjectType === 'Quad') && ( - <div className="e-row ui-divider-red" style={{ paddingTop: '5px', marginTop: '5px' }}> + <div ref={setSectionRef(3)} className="properties-section-block"> <div - className="e-label ui-text-accent-red ui-font-bold" - style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }} + className="properties-section-header properties-section-red" > - <span>COMPONENTS</span> - <div> + <div className="properties-section-title"> + <span className="properties-section-number properties-section-red">3</span> + <span className="properties-section-label">COMPONENTS</span> + </div> + <div className="properties-section-actions"> <Select options={[ { value: 'Item', label: 'Item (Pickup)' }, @@ -2765,27 +2845,14 @@ export const PropertiesPanel: React.FC = () => { </div> )} - {selectedObjectType === 'Quad' && ( - <div - className="e-label ui-text-dim" - style={{ - marginTop: '10px', - fontSize: '10px', - fontStyle: 'italic', - paddingTop: '5px', - }} - > - Drag VERTEX: Hold ALT to snap to vertices/grid. - <br /> - Hold SHIFT for angle snap. - </div> - )} - {selectedObjectType === 'Actor' && ( <> - <div className="e-row ui-divider-blue" style={{ paddingTop: '5px' }}> - <div className="e-label ui-text-accent-blue ui-font-bold"> - ACTOR PROPERTIES + <div ref={setSectionRef(4)} className="properties-section-block"> + <div className="properties-section-header properties-section-blue"> + <div className="properties-section-title"> + <span className="properties-section-number properties-section-blue">4</span> + <span className="properties-section-label">ACTOR PROP.</span> + </div> </div> </div> @@ -3013,6 +3080,158 @@ export const PropertiesPanel: React.FC = () => { </> )} + {isObjectWithScriptEvents && ( + <div ref={setSectionRef(5)} className="properties-section-block"> + <div + className="properties-section-header properties-section-purple" + > + <div className="properties-section-title"> + <span className="properties-section-number properties-section-purple">5</span> + <span className="properties-section-label">SCRIPT EVENTS</span> + </div> + <div className="properties-section-actions"> + <Select + value="" + className="compact-action-select" + placeholder="+ ADD" + onChange={(value) => { + const verb = value; + if (!verb) return; + if (!obj.interactions) obj.interactions = {}; + if (!obj.interactions[verb]) { + obj.interactions[verb] = ''; + if (game.editor.selectedObject) { + if (!(game.editor.selectedObject as any).interactions) { + (game.editor.selectedObject as any).interactions = {}; + } + (game.editor.selectedObject as any).interactions[verb] = ''; + } + incrementObjectVersion(); + } + }} + options={[ + { value: 'look', label: 'Look' }, + { value: 'use', label: 'Use' }, + { value: 'talk', label: 'Talk' }, + { value: 'pickup', label: 'Pickup' }, + ]} + style={{ width: '8em' }} + /> + </div> + </div> + + {obj.interactions && + Object.keys(obj.interactions).map((verb) => ( + <div key={verb} style={{ display: 'flex', alignItems: 'center', marginTop: '2px' }}> + <div className="ui-text-light" style={{ width: '40px', fontSize: '0.85em' }}> + {verb.toUpperCase()} + </div> + <input + type="text" + className="e-input" + style={{ flex: 1, fontSize: '0.85em' }} + placeholder="Script ID" + value={obj.interactions[verb]} + onChange={(e) => { + obj.interactions[verb] = e.target.value; + if (game.editor.selectedObject) { + (game.editor.selectedObject as any).interactions[verb] = e.target.value; + } + incrementObjectVersion(); + }} + /> + <button + className="e-btn e-btn-red" + style={{ marginLeft: '2px', padding: '0 4px', fontSize: '0.85em' }} + onClick={() => { + delete obj.interactions[verb]; + if (game.editor.selectedObject) { + delete (game.editor.selectedObject as any).interactions[verb]; + } + incrementObjectVersion(); + }} + > + x + </button> + </div> + ))} + </div> + )} + + {!isSettings && !isScene && !isWalkbox && ( + <div ref={setSectionRef(6)} className="properties-section-block"> + {isTriggerbox && ( + <> + <button + className="e-btn e-btn-yellow" + style={{ width: '100%', marginBottom: '5px' }} + onClick={(e) => { + if (confirm('Redraw polygon? Current points will be cleared.')) { + game.editor.redrawSelected(); + (e.target as HTMLElement).blur(); + } + }} + > + Redraw Polygon + </button> + <div className="e-label"> + {mode && mode.includes('DRAW') + ? 'Click to add points. Press ENTER to finish. Hold Shift for 22.5° snap.' + : 'To edit, drag vertices on screen. Hold Shift for 22.5° snap.'} + </div> + </> + )} + + {selectedObjectType === 'Quad' && ( + <div + className="e-label ui-text-dim" + style={{ + marginTop: '10px', + fontSize: '10px', + fontStyle: 'italic', + paddingTop: '5px', + }} + > + Drag VERTEX: Hold ALT to snap to vertices/grid. + <br /> + Hold SHIFT for angle snap. + </div> + )} + + <div className="e-row" style={{ marginTop: isTriggerbox || selectedObjectType === 'Quad' ? '10px' : 0 }}> + <label + className="e-label" + title="Toggle lock hotkey: Alt-L" + style={{ display: 'flex', alignItems: 'center' }} + > + <input + type="checkbox" + title="Alt-L" + style={{ marginRight: '5px' }} + checked={!!obj.locked} + onChange={(e) => handleChange('locked', e.target.checked)} + /> + Lock Object + </label> + </div> + <div className="e-row"> + <label + className="e-label ui-inline-flex-center ui-text-accent-red" + title="Toggle disabled hotkey: Alt-D" + > + <input + type="checkbox" + title="Alt-D" + style={{ marginRight: '5px' }} + checked={!!obj.disabled} + onChange={(e) => handleChange('disabled', e.target.checked)} + /> + Disabled + </label> + </div> + </div> + )} + {/* SCENE Properties */} {selectedObjectType === 'SCENE' && ( <> diff --git a/src/editor.css b/src/editor.css index e9555eb..c22be23 100644 --- a/src/editor.css +++ b/src/editor.css @@ -179,6 +179,112 @@ margin-bottom: 2px; } +.properties-section-block { + margin-bottom: 12px; + scroll-margin-top: 8px; +} + +.properties-section-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin: 0 0 8px 0; + padding-top: 0; + position: relative; + font-family: var(--ui-display-font); + font-weight: 600; + text-transform: uppercase; + letter-spacing: -0.03em; +} + +.properties-section-header::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0; + border-top: 1px solid currentColor; +} + +.properties-section-title { + display: inline-flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + position: relative; + z-index: 1; +} + +.properties-section-label { + display: inline-block; + padding-top: 4px; +} + +.properties-section-actions { + position: relative; + z-index: 1; + padding-top: 2px; + flex-shrink: 0; +} + +.properties-section-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 22px; + padding: 0 4px; + font-size: 0.75em; + line-height: 1; + color: #000; + margin-top: 0; +} + +.properties-section-header.properties-section-blue { + color: #aaf; + border-color: #aaf; +} + +.properties-section-header.properties-section-red { + color: #faa; + border-color: #faa; +} + +.properties-section-header.properties-section-yellow { + color: #ffaa00; + border-color: #ffaa00; +} + +.properties-section-header.properties-section-purple { + color: #f0aaff; + border-color: #f0aaff; +} + +.properties-section-header.properties-section-neutral { + color: #aaa; + border-color: #333; +} + +.properties-section-number.properties-section-blue { + background: #aaf; +} + +.properties-section-number.properties-section-red { + background: #faa; +} + +.properties-section-number.properties-section-yellow { + background: #ffaa00; +} + +.properties-section-number.properties-section-purple { + background: #f0aaff; +} + +.properties-section-number.properties-section-neutral { + background: #aaa; +} + /* Bottom Menu */ .editor-bottom-menu { height: 32px; diff --git a/src/index.css b/src/index.css index 78a96c1..f33f281 100644 --- a/src/index.css +++ b/src/index.css @@ -587,6 +587,10 @@ input[type='file'] { color: #00ffff !important; } +.ui-text-accent-purple { + color: #f0aaff !important; +} + .ui-text-light { color: #ccc; } @@ -636,6 +640,10 @@ input[type='file'] { border-top: 1px solid #333; } +.ui-divider-purple { + border-top: 1px solid #f0aaff; +} + .ui-panel-card { background: #222; } diff --git a/tasks.md b/tasks.md index 3b6e299..cff6792 100644 --- a/tasks.md +++ b/tasks.md @@ -1,175 +1,88 @@ -# Autotests Plan +# Properties Panel Redesign Plan ## Goal -Introduce the first iteration of automated tests for `Scanline` / `Blue Signal` with focus on deterministic parser, core, and scene-runtime behavior. - -This iteration should: -- cover the most fragile gameplay contracts; -- avoid heavy browser/UI end-to-end coverage; -- use small dedicated fixtures instead of live content scenes; -- be cheap to maintain while the architecture is still evolving. - -Out of scope for this iteration: -- full Playwright coverage; -- LLM-stage testing; -- testing against large real content scenes as the main source of truth. - -## Target Stack - -- [x] Add `vitest` as the test runner. -- [x] Add `npm run test` script. -- [x] Keep the first iteration in a lightweight test environment: - - prefer `node` environment; - - use `jsdom` only if a specific test truly needs it. - -## Test Architecture - -The first iteration should use three layers: - -1. Unit tests for parser and helpers. -2. Runtime tests for scene/spatial/subscene behavior. -3. Thin integration tests for parser + game on tiny fixtures. - -Avoid starting with canvas/UI/browser assertions. - -## Fixtures and Helpers - -- [x] Create `tests/fixtures/sceneFactory.ts` - - helpers for minimal `Scene` setup; - - helpers for entities, triggerboxes, subscenes, switches, and spatial links. - -- [x] Create `tests/fixtures/gameFactory.ts` - - minimal `Game`/`IGame` test harness; - - controllable logging, messages, sounds, and inventory. - -- [x] Create `tests/fixtures/parserFactory.ts` - - build parser with small fixture world; - - helpers for running parser input and reading outcomes. - -- [x] Create `tests/fixtures/textAssetFactory.ts` - - minimal parser/engine text assets for tests; - - keep messages stable and deterministic. - -- [x] Decide fixture style for first iteration: - - start with programmatic fixtures; - - add tiny JSON fixture scenes later only if load/serialization tests need them. - -## First Test Files - -### Parser - -- [x] `tests/parser/resolution.test.ts` - Cover: - - exact title match; - - synonym match; - - partial match; - - ambiguity clarification; - - deterministic tie-break: - - inventory first; - - nearest scene object when needed. - -- [x] `tests/parser/commands.test.ts` - Cover: - - `teleport with id`; - - wrong item -> no effect; - - `use id on boombox`; - - multi-argument parsing for `USE X ON Y`; - - missing-argument prompt cases. - -- [x] `tests/parser/core.test.ts` - Cover: - - unified envelope intake; - - pre-API escalation; - - post-API escalation; - - linear plan execution; - - custom command validation path. - -### Scene / Runtime - -- [x] `tests/scene/spatial-index.test.ts` - Cover: - - direct parent/child lookup; - - relation grouping (`in`, `on`, `under`, `behind`); - - direct-child helper stays non-recursive. - -- [x] `tests/scene/subscene-activation.test.ts` - Cover: - - direct entity child activates; - - direct triggerbox child activates; - - nested subscene becomes available; - - grandchildren do not activate automatically. - -- [x] `tests/scene/subscene-cleanup.test.ts` - Cover: - - switch reset on subscene close; - - `sound1` path fires correctly; - - spatially included switch resets too, not only group-based targets. - -### Thin Integration - -- [x] `tests/integration/parser-game.test.ts` - Cover only a few end-to-end flows on tiny fixtures: - - `look under chair`; - - `teleport with your id card`; - - one far-but-visible `examine` case. - -## Recommended Implementation Order - -1. [x] Add `vitest` infrastructure. -2. [x] Add factories/helpers. -3. [x] Implement spatial runtime tests first: - - `spatial-index.test.ts` - - `subscene-activation.test.ts` - - `subscene-cleanup.test.ts` -4. [x] Implement parser command/resolution tests. -5. [x] Add one thin integration test file. - -## Next Iteration Candidate: Game Semantic API Tests - -- [x] Add `tests/game/semantic-api.test.ts` - Cover: - - `lookScene`; - - `lookEntity`; - - `examineEntity`; - - `showInventory`; - - `removeInventoryEntity`. - -- [x] Add `tests/game/navigation-and-spatial.test.ts` - Cover: - - `goToSceneTarget`; - - `goToScene`; - - `goToEntity`; - - `describeSpatialRelation`. - -- [x] Decide whether the current fixture `gameFactory.ts` is sufficient for direct `Game`-layer tests - or if a dedicated semantic `Game` harness should be introduced. - Result: - - keep `gameFactory.ts` as the minimal base harness; - - add `tests/fixtures/gameSemanticFactory.ts` for direct `Game` API tests through `Game.prototype`. - -## Success Criteria For Iteration 1 - -- [x] `npm run test` works locally. -- [x] Tests do not depend on large mutable content scenes. -- [x] The most fragile parser/runtime contracts are covered. -- [x] Failing tests point to a specific layer: - - parser; - - core; - - scene runtime; - - subscene behavior. +Restructure the right-side Properties panel into consistent numbered sections with: +- shared section layout; +- colored headings and dividers; +- section hotkeys `0..6` that scroll the panel to the chosen section; +- unified field placement across object types where possible. + +## Scope + +In scope: +- `Entity` +- `Actor` +- `Static` +- `Quad` +- `Triggerbox` +- multi-selection panel +- section hotkey navigation for the Properties panel +- new purple section style + +Out of scope for this task: +- `Walkbox` property layout changes +- `SETTINGS` redesign +- deep component-specific redesign beyond regrouping under `COMPONENTS` + +## Section Model + +### Section 0 +No title. +Contains: +- ID +- Title +- TA buttons +- Group ID +- Parent / Relation + +### Section 1 TRANSFORM +Contains, depending on object type: +- X, Y, H, W +- Scale, Layer, Parallax +- Collider H, W +- Depth Sort mode +- Disable Depth-scaling +- Vertices block for `Quad` + +### Section 2 VISUAL +Contains: +- Fill Color +- Blend mode +- Opacity / Blur +- Retro Grid block for `Quad` +- Sprite + +### Section 3 COMPONENTS +Contains all component-related editing. + +### Section 4 ACTOR PROP. +Actor-only section. Keep current actor-specific controls. + +### Section 5 SCRIPT EVENTS +Shared script event section for all non-Walkbox object types. + +### Section 6 +No title. +Contains all remaining object-specific controls not covered above. + +## Implementation Steps + +- [ ] Add reusable section wrapper API in `PropertiesPanel.tsx` +- [ ] Add purple section accent style in CSS +- [ ] Add section number badge style (inverse accent) +- [ ] Add Properties-panel hotkey scroll navigation for digits `0..6` +- [ ] Reorganize multi-selection layout into the new section model +- [ ] Reorganize `Entity` / `Actor` / `Static` +- [ ] Reorganize `Quad` +- [ ] Reorganize `Triggerbox` +- [ ] Keep `Walkbox` layout unchanged +- [ ] Keep `SETTINGS` layout unchanged +- [ ] Verify build and manual editor navigation behavior ## Notes -- Keep UI click behavior out of the first iteration unless a contract cannot be tested elsewhere. -- Prefer deterministic fixtures over browser automation. -- Keep tests readable enough that they double as executable architecture documentation. -- `Autotests.md` is the current developer-facing description of the test system, fixtures, coverage, and usage workflow. -- Current progress: - - `vitest` bootstrap is in place; - - runtime spatial/subscene tests are green; - - parser resolution, commands, and core tests are green; - - one thin integration smoke file is green; - - current status: first autotest iteration is functionally complete. - - next logical slice: direct tests for `Game` as the shared semantic gameplay API. - - `Game` semantic API tests are now green as well. +- Parent / Relation should hide `Relation` when Parent is `(None)`. +- Parent dropdown styling should keep natural-case IDs. +- Script Events becomes a common section for all non-Walkbox scene objects. +- Reuse existing component editors and vertex editors where possible instead of rewriting them. From 4d4e60d0a5da43a31b0bb18ae7d79b172d05dde9 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 22 Mar 2026 17:45:55 +0200 Subject: [PATCH 64/75] Fix: add undo support for multi-selection edits --- src/components/editor/PropertiesPanel.tsx | 971 ++++++++++++---------- tasks.md | 40 +- 2 files changed, 552 insertions(+), 459 deletions(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index db21e0c..2155edb 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -25,6 +25,7 @@ export const PropertiesPanel: React.FC = () => { const [hasTextAsset, setHasTextAsset] = React.useState(false); const [polygonScaleDraft, setPolygonScaleDraft] = React.useState('1'); const lastUndoObjectKeyRef = React.useRef<string | null>(null); + const lastUndoMultiKeyRef = React.useRef<string | null>(null); const lastPolygonScaleObjectKeyRef = React.useRef<string | null>(null); const panelRef = React.useRef<HTMLDivElement | null>(null); const contentRef = React.useRef<HTMLDivElement | null>(null); @@ -316,12 +317,22 @@ export const PropertiesPanel: React.FC = () => { ); const applyToMulti = (fn: (o: any) => void) => { + const multiKey = `MULTI:${multiObjects.map((item: any) => item?.name || '').filter(Boolean).join('|')}`; + if (game?.editor && lastUndoMultiKeyRef.current !== multiKey) { + game.editor.saveUndoState(); + lastUndoMultiKeyRef.current = multiKey; + } multiObjects.forEach(fn); incrementObjectVersion(); incrementHierarchyVersion(); }; const applyToMultiRoots = (fn: (o: any) => void) => { + const multiKey = `MULTI:${multiObjects.map((item: any) => item?.name || '').filter(Boolean).join('|')}`; + if (game?.editor && lastUndoMultiKeyRef.current !== multiKey) { + game.editor.saveUndoState(); + lastUndoMultiKeyRef.current = multiKey; + } const selectedNames = new Set(multiObjects.map((item: any) => item?.name).filter(Boolean)); multiObjects.forEach((o: any) => { const parentId = typeof o?.spatial?.parentNodeId === 'string' ? o.spatial.parentNodeId.trim() : ''; @@ -481,8 +492,28 @@ export const PropertiesPanel: React.FC = () => { } }; + const renderSection = ( + section: number, + title: string | null, + color: 'blue' | 'red' | 'yellow' | 'purple' | 'neutral', + children: React.ReactNode + ) => ( + <div ref={setSectionRef(section)} className="properties-section-block" data-section={section}> + {title !== null && ( + <div className={`properties-section-header properties-section-${color}`}> + <div className="properties-section-title"> + <span className={`properties-section-number properties-section-${color}`}>{section}</span> + <span className="properties-section-label">{title}</span> + </div> + </div> + )} + {children} + </div> + ); + React.useEffect(() => { lastUndoObjectKeyRef.current = null; + lastUndoMultiKeyRef.current = null; if (selectedObjectType !== 'MULTI') { setGroupIdDraft(''); } @@ -504,6 +535,7 @@ export const PropertiesPanel: React.FC = () => { }} onBlurCapture={() => { lastUndoObjectKeyRef.current = null; + lastUndoMultiKeyRef.current = null; }} style={{ fontSize: `${12 * uiScale}px` }} > @@ -561,6 +593,7 @@ export const PropertiesPanel: React.FC = () => { }} onBlurCapture={() => { lastUndoObjectKeyRef.current = null; + lastUndoMultiKeyRef.current = null; }} style={{ fontSize: `${12 * uiScale}px` }} > @@ -571,506 +604,563 @@ export const PropertiesPanel: React.FC = () => { </button> </div> <div ref={contentRef} className="editor-content"> - <div className="e-row"> - <label className="e-label">Group #ID</label> - <div className="e-label ui-text-muted ui-text-small"> - (<Enter> = append, <Ctrl+Enter> = remove) - </div> - <input - type="text" - className="e-input" - value={groupIdDraft} - placeholder="#group" - onChange={(e) => setGroupIdDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key !== 'Enter') return; - e.preventDefault(); - const raw = groupIdDraft.trim(); - if (!raw) return; - const prepared = raw - .split(',') - .map((x) => x.trim()) - .filter(Boolean) - .map((x) => (x.startsWith('#') ? x : `#${x}`)); - - let changedCount = 0; - multiObjects.forEach((o: any) => { - const existing = (o.groupID || '') - .split(',') - .map((x: string) => x.trim()) - .filter(Boolean); - - if (e.ctrlKey) { - const filtered = existing.filter((x: string) => !prepared.includes(x)); - if (filtered.length !== existing.length) { - changedCount++; - } - o.groupID = filtered.join(','); - return; - } - - const merged = [...new Set([...existing, ...prepared])]; - if (merged.length !== existing.length) { - changedCount++; - } - o.groupID = merged.join(','); - }); - - if (changedCount > 0) { - incrementObjectVersion(); - incrementHierarchyVersion(); - const tagsText = prepared.join(', '); - if (e.ctrlKey) { - game.showNotification( - `Removed ${tagsText} from ${changedCount} object${changedCount === 1 ? '' : 's'}` - ); - } else { - game.showNotification( - `Appended ${tagsText} to ${changedCount} object${changedCount === 1 ? '' : 's'}` - ); - } - } - setGroupIdDraft(''); - }} - /> - </div> - - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} - > - <div> - <label className="e-label">Group X</label> - <input - type="number" - className="e-input" - value={group.offsetX.toFixed(2)} - onChange={(e) => { - const x = parseFloat(e.target.value); - game.editor.selectionManager.applyGroupTransform( - isNaN(x) ? 0 : x, - group.offsetY, - group.scale - ); - incrementObjectVersion(); - }} - /> - </div> - <div> - <label className="e-label">Group Y</label> - <input - type="number" - className="e-input" - value={group.offsetY.toFixed(2)} - onChange={(e) => { - const y = parseFloat(e.target.value); - game.editor.selectionManager.applyGroupTransform( - group.offsetX, - isNaN(y) ? 0 : y, - group.scale - ); - incrementObjectVersion(); - }} - /> - </div> - </div> - - <div className="e-row"> - <label className="e-label">Group Scale</label> - <input - type="number" - step="0.01" - min="0.01" - className="e-input" - value={group.scale.toFixed(3)} - onChange={(e) => { - const s = parseFloat(e.target.value); - game.editor.selectionManager.applyGroupTransform( - group.offsetX, - group.offsetY, - isNaN(s) ? 1 : s - ); - incrementObjectVersion(); - }} - /> - </div> - - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} - > - <div> - <label className="e-label">Layer</label> - <input - type="number" - className="e-input" - placeholder="mixed" - value={sharedLayer === '' ? '' : sharedLayer} - onChange={(e) => { - const v = parseInt(e.target.value); - if (isNaN(v)) return; - applyToMulti((o) => { - o.layer = v; - }); - }} - /> - </div> - {entitiesAndQuads.length > 0 ? ( - <div> - <label className="e-label">Parallax</label> - <input - type="number" - step="0.1" - className="e-input ui-text-muted" - placeholder="mixed" - value={sharedParallax === '' ? '' : sharedParallax} - onChange={(e) => { - const v = parseFloat(e.target.value); - if (isNaN(v)) return; - applyToMulti((o: any) => { - if (o instanceof Entity) o.parallax = v; - }); - }} - /> + {renderSection( + 0, + null, + 'neutral', + <> + <div className="e-row"> + <label className="e-label">Selected</label> + <div className="e-label ui-text-muted ui-text-small">{multiObjects.length} objects</div> </div> - ) : ( - <div /> - )} - </div> - {entitiesAndQuads.length > 0 && ( - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} - > - <div> - <label className="e-label">Opacity</label> + <div className="e-row"> + <label className="e-label">Group #ID</label> + <div className="e-label ui-text-muted ui-text-small"> + (<Enter> = append, <Ctrl+Enter> = remove) + </div> <input - type="number" - step="0.01" - min="0" - max="1" + type="text" className="e-input" - placeholder="mixed" - value={sharedOpacity === '' ? '' : sharedOpacity} - onChange={(e) => { - const v = parseFloat(e.target.value); - if (isNaN(v)) return; - applyToMulti((o: any) => { - if (o instanceof Entity) o.opacity = v; + value={groupIdDraft} + placeholder="#group" + onChange={(e) => setGroupIdDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + const raw = groupIdDraft.trim(); + if (!raw) return; + const prepared = raw + .split(',') + .map((x) => x.trim()) + .filter(Boolean) + .map((x) => (x.startsWith('#') ? x : `#${x}`)); + + let changedCount = 0; + multiObjects.forEach((o: any) => { + const existing = (o.groupID || '') + .split(',') + .map((x: string) => x.trim()) + .filter(Boolean); + + if (e.ctrlKey) { + const filtered = existing.filter((x: string) => !prepared.includes(x)); + if (filtered.length !== existing.length) changedCount++; + o.groupID = filtered.join(','); + return; + } + + const merged = [...new Set([...existing, ...prepared])]; + if (merged.length !== existing.length) changedCount++; + o.groupID = merged.join(','); }); + + if (changedCount > 0) { + const multiKey = `MULTI:${multiObjects + .map((item: any) => item?.name || '') + .filter(Boolean) + .join('|')}`; + if (game?.editor && lastUndoMultiKeyRef.current !== multiKey) { + game.editor.saveUndoState(); + lastUndoMultiKeyRef.current = multiKey; + } + incrementObjectVersion(); + incrementHierarchyVersion(); + const tagsText = prepared.join(', '); + if (e.ctrlKey) { + game.showNotification( + `Removed ${tagsText} from ${changedCount} object${changedCount === 1 ? '' : 's'}` + ); + } else { + game.showNotification( + `Appended ${tagsText} to ${changedCount} object${changedCount === 1 ? '' : 's'}` + ); + } + } + setGroupIdDraft(''); }} /> </div> - <div> - <label className="e-label">Blur</label> - <input - type="number" - className="e-input" - placeholder="mixed" - value={sharedBlur === '' ? '' : sharedBlur} - onChange={(e) => { - const v = parseInt(e.target.value); - if (isNaN(v)) return; - applyToMulti((o: any) => { - if (o instanceof Entity) o.blur = v; + + <div className="e-row"> + <label className="e-label">Parent</label> + <Select + className="parent-id-select" + value={multiSpatialParentDraft} + onChange={(value) => { + const nextRelation = !value ? '' : multiSpatialRelationDraft || 'in'; + setMultiSpatialParentDraft(value || ''); + setMultiSpatialRelationDraft(nextRelation); + applyToMultiRoots((o: any) => { + o.spatial = { + ...(o.spatial || {}), + parentNodeId: value || null, + relation: value ? nextRelation || 'in' : null, + }; }); }} + options={getMultiSpatialParentOptions()} + style={{ width: '100%' }} /> </div> - </div> - )} - {entitiesAndQuads.length > 0 && ( - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} - > - <div> - <label className="e-label">Fill Color</label> - <div style={{ display: 'flex', gap: '5px' }}> - <input - type="color" - className="e-input" - style={{ width: '32px', padding: 0 }} - value={sharedColor === '' ? '#ffffff' : sharedColor} - onChange={(e) => { - const v = e.target.value; - applyToMulti((o: any) => { - if (o.color !== undefined) o.color = v; - }); - }} - /> - <input - type="text" - className="e-input" - placeholder="mixed" - value={sharedColor === '' ? '' : sharedColor} - onChange={(e) => { - const v = e.target.value; - applyToMulti((o: any) => { - if (o.color !== undefined) o.color = v; - }); - }} - /> - </div> - </div> - <div style={{ display: 'flex', alignItems: 'end' }}> - <div style={{ width: '100%' }}> - <label className="e-label">Blend</label> + {sharedParentNodeId && ( + <div className="e-row"> + <label className="e-label">Relation</label> <Select - value={sharedBlendMode === '' ? 'source-over' : sharedBlendMode} + value={multiSpatialRelationDraft} onChange={(value) => { - applyToMulti((o: any) => { - if (o instanceof Entity) o.blendMode = value as GlobalCompositeOperation; + setMultiSpatialRelationDraft(value || ''); + applyToMultiRoots((o: any) => { + o.spatial = { + ...(o.spatial || {}), + parentNodeId: o.spatial?.parentNodeId || null, + relation: value || (o.spatial?.parentNodeId ? 'in' : null), + }; }); }} - options={[ - { value: 'source-over', label: 'Normal' }, - { value: 'multiply', label: 'Multiply' }, - { value: 'screen', label: 'Screen' }, - { value: 'overlay', label: 'Overlay' }, - { value: 'lighter', label: 'Add' }, - { value: 'difference', label: 'Diff' }, - ]} + options={getSpatialRelationOptions(true)} style={{ width: '100%' }} /> </div> - </div> - </div> - )} - - {quads.length > 0 && ( - <div className="e-row"> - <label - className="e-label" - style={{ - display: 'flex', - alignItems: 'center', - color: sharedIsGrid === 'on' ? '#ffffff' : 'inherit', - }} - > - <input - type="checkbox" - style={{ marginRight: '5px' }} - checked={sharedIsGrid === 'on'} - ref={(el) => { - if (el) el.indeterminate = sharedIsGrid === 'mixed'; - }} - onChange={(e) => { - applyToMulti((o: any) => { - if ((o as any).type === 'Quad') (o as any).isGrid = e.target.checked; - }); - }} - /> - Retro Grid - </label> - </div> + )} + </> )} - {quads.length > 0 && sharedIsGrid !== 'off' && ( + {renderSection( + 1, + 'Transform', + 'blue', <> <div className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }} + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} > <div> - <label className="e-label">Grid X</label> + <label className="e-label">Group X</label> <input type="number" className="e-input" - placeholder="mixed" - value={sharedGridX === '' ? '' : sharedGridX} + value={group.offsetX.toFixed(2)} onChange={(e) => { - const v = parseInt(e.target.value); - if (isNaN(v)) return; - applyToMulti((o: any) => { - if ((o as any).type === 'Quad') (o as any).gridLinesX = v; - }); + const multiKey = `MULTI:${multiObjects + .map((item: any) => item?.name || '') + .filter(Boolean) + .join('|')}`; + if (game?.editor && lastUndoMultiKeyRef.current !== multiKey) { + game.editor.saveUndoState(); + lastUndoMultiKeyRef.current = multiKey; + } + const x = parseFloat(e.target.value); + game.editor.selectionManager.applyGroupTransform( + isNaN(x) ? 0 : x, + group.offsetY, + group.scale + ); + incrementObjectVersion(); }} - min={1} - max={50} /> </div> <div> - <label className="e-label">Grid Y</label> + <label className="e-label">Group Y</label> <input type="number" className="e-input" - placeholder="mixed" - value={sharedGridY === '' ? '' : sharedGridY} + value={group.offsetY.toFixed(2)} onChange={(e) => { - const v = parseInt(e.target.value); - if (isNaN(v)) return; - applyToMulti((o: any) => { - if ((o as any).type === 'Quad') (o as any).gridLinesY = v; - }); + const multiKey = `MULTI:${multiObjects + .map((item: any) => item?.name || '') + .filter(Boolean) + .join('|')}`; + if (game?.editor && lastUndoMultiKeyRef.current !== multiKey) { + game.editor.saveUndoState(); + lastUndoMultiKeyRef.current = multiKey; + } + const y = parseFloat(e.target.value); + game.editor.selectionManager.applyGroupTransform( + group.offsetX, + isNaN(y) ? 0 : y, + group.scale + ); + incrementObjectVersion(); + }} + /> + </div> + </div> + + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }} + > + <div> + <label className="e-label">Group Scale</label> + <input + type="number" + step="0.01" + className="e-input" + value={group.scale.toFixed(2)} + onChange={(e) => { + const multiKey = `MULTI:${multiObjects + .map((item: any) => item?.name || '') + .filter(Boolean) + .join('|')}`; + if (game?.editor && lastUndoMultiKeyRef.current !== multiKey) { + game.editor.saveUndoState(); + lastUndoMultiKeyRef.current = multiKey; + } + const s = parseFloat(e.target.value); + if (isNaN(s) || s <= 0) return; + game.editor.selectionManager.applyGroupTransform(group.offsetX, group.offsetY, s); + incrementObjectVersion(); }} - min={1} - max={50} /> </div> <div> - <label className="e-label">Width</label> + <label className="e-label">Layer</label> <input type="number" className="e-input" placeholder="mixed" - value={sharedGridWidth === '' ? '' : sharedGridWidth} + value={sharedLayer === '' ? '' : sharedLayer} onChange={(e) => { - const v = parseFloat(e.target.value); + const v = parseInt(e.target.value); if (isNaN(v)) return; - applyToMulti((o: any) => { - if ((o as any).type === 'Quad') (o as any).lineWidth = v; + applyToMulti((o) => { + o.layer = v; }); }} - step={0.1} - min={0.1} - max={10} /> </div> + <div> + {entitiesAndQuads.length > 0 ? ( + <> + <label className="e-label">Parallax</label> + <input + type="number" + step="0.1" + className="e-input ui-text-muted" + placeholder="mixed" + value={sharedParallax === '' ? '' : sharedParallax} + onChange={(e) => { + const v = parseFloat(e.target.value); + if (isNaN(v)) return; + applyToMulti((o: any) => { + if (o instanceof Entity) o.parallax = v; + }); + }} + /> + </> + ) : ( + <div /> + )} + </div> </div> - <div className="e-row"> - <label className="e-label">Grid Color</label> - <div style={{ display: 'flex', gap: '5px' }}> + + {entitiesAndQuads.length > 0 && ( + <div className="e-row"> + <label + className="e-label" + style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 0 }} + > + <input + type="checkbox" + checked={sharedIgnoreScaling === 'on'} + ref={(el) => { + if (el) el.indeterminate = sharedIgnoreScaling === 'mixed'; + }} + onChange={(e) => { + applyToMulti((o: any) => { + if (o instanceof Entity) o.ignoreScaling = e.target.checked; + }); + }} + /> + Disable Depth Scaling + </label> + </div> + )} + </> + )} + + {renderSection( + 2, + 'Visual', + 'yellow', + <> + {entitiesAndQuads.length > 0 && ( + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} + > + <div> + <label className="e-label">Opacity</label> + <input + type="number" + step="0.01" + min="0" + max="1" + className="e-input" + placeholder="mixed" + value={sharedOpacity === '' ? '' : sharedOpacity} + onChange={(e) => { + const v = parseFloat(e.target.value); + if (isNaN(v)) return; + applyToMulti((o: any) => { + if (o instanceof Entity) o.opacity = v; + }); + }} + /> + </div> + <div> + <label className="e-label">Blur</label> + <input + type="number" + className="e-input" + placeholder="mixed" + value={sharedBlur === '' ? '' : sharedBlur} + onChange={(e) => { + const v = parseInt(e.target.value); + if (isNaN(v)) return; + applyToMulti((o: any) => { + if (o instanceof Entity) o.blur = v; + }); + }} + /> + </div> + </div> + )} + + {entitiesAndQuads.length > 0 && ( + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} + > + <div> + <label className="e-label">Fill Color</label> + <div style={{ display: 'flex', gap: '5px' }}> + <input + type="color" + className="e-input" + style={{ width: '32px', padding: 0 }} + value={sharedColor === '' ? '#ffffff' : sharedColor} + onChange={(e) => { + const v = e.target.value; + applyToMulti((o: any) => { + if (o.color !== undefined) o.color = v; + }); + }} + /> + <input + type="text" + className="e-input" + placeholder="mixed" + value={sharedColor === '' ? '' : sharedColor} + onChange={(e) => { + const v = e.target.value; + applyToMulti((o: any) => { + if (o.color !== undefined) o.color = v; + }); + }} + /> + </div> + </div> + <div style={{ display: 'flex', alignItems: 'end' }}> + <div style={{ width: '100%' }}> + <label className="e-label">Blend Mode</label> + <Select + value={sharedBlendMode === '' ? 'source-over' : sharedBlendMode} + onChange={(value) => { + applyToMulti((o: any) => { + if (o instanceof Entity) o.blendMode = value as GlobalCompositeOperation; + }); + }} + options={[ + { value: 'source-over', label: 'Normal' }, + { value: 'multiply', label: 'Multiply' }, + { value: 'screen', label: 'Screen' }, + { value: 'overlay', label: 'Overlay' }, + { value: 'lighter', label: 'Add' }, + { value: 'difference', label: 'Diff' }, + ]} + style={{ width: '100%' }} + /> + </div> + </div> + </div> + )} + + {quads.length > 0 && ( + <div className="e-row"> + <label + className="e-label" + style={{ + display: 'flex', + alignItems: 'center', + color: sharedIsGrid === 'on' ? '#ffffff' : 'inherit', + }} + > + <input + type="checkbox" + style={{ marginRight: '5px' }} + checked={sharedIsGrid === 'on'} + ref={(el) => { + if (el) el.indeterminate = sharedIsGrid === 'mixed'; + }} + onChange={(e) => { + applyToMulti((o: any) => { + if ((o as any).type === 'Quad') (o as any).isGrid = e.target.checked; + }); + }} + /> + Retro Grid + </label> + </div> + )} + + {quads.length > 0 && sharedIsGrid !== 'off' && ( + <> + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label">Grid X</label> + <input + type="number" + className="e-input" + placeholder="mixed" + value={sharedGridX === '' ? '' : sharedGridX} + onChange={(e) => { + const v = parseInt(e.target.value); + if (isNaN(v)) return; + applyToMulti((o: any) => { + if ((o as any).type === 'Quad') (o as any).gridLinesX = v; + }); + }} + min={1} + max={50} + /> + </div> + <div> + <label className="e-label">Grid Y</label> + <input + type="number" + className="e-input" + placeholder="mixed" + value={sharedGridY === '' ? '' : sharedGridY} + onChange={(e) => { + const v = parseInt(e.target.value); + if (isNaN(v)) return; + applyToMulti((o: any) => { + if ((o as any).type === 'Quad') (o as any).gridLinesY = v; + }); + }} + min={1} + max={50} + /> + </div> + <div> + <label className="e-label">Width</label> + <input + type="number" + className="e-input" + placeholder="mixed" + value={sharedGridWidth === '' ? '' : sharedGridWidth} + onChange={(e) => { + const v = parseFloat(e.target.value); + if (isNaN(v)) return; + applyToMulti((o: any) => { + if ((o as any).type === 'Quad') (o as any).lineWidth = v; + }); + }} + step={0.1} + min={0.1} + max={10} + /> + </div> + </div> + <div className="e-row"> + <label className="e-label">Grid Color</label> + <div style={{ display: 'flex', gap: '5px' }}> + <input + type="color" + className="e-input" + style={{ width: '32px', padding: 0 }} + value={sharedGridColor === '' ? '#ffffff' : sharedGridColor} + onChange={(e) => { + const v = e.target.value; + applyToMulti((o: any) => { + if ((o as any).type === 'Quad') (o as any).gridColor = v; + }); + }} + /> + <input + type="text" + className="e-input" + placeholder="mixed" + value={sharedGridColor === '' ? '' : sharedGridColor} + onChange={(e) => { + const v = e.target.value; + applyToMulti((o: any) => { + if ((o as any).type === 'Quad') (o as any).gridColor = v; + }); + }} + /> + </div> + </div> + </> + )} + </> + )} + + {renderSection( + 6, + null, + 'neutral', + <> + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} + > + <label + className="e-label" + style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 0 }} + > <input - type="color" - className="e-input" - style={{ width: '32px', padding: 0 }} - value={sharedGridColor === '' ? '#ffffff' : sharedGridColor} + type="checkbox" + checked={sharedLocked === 'on'} + ref={(el) => { + if (el) el.indeterminate = sharedLocked === 'mixed'; + }} onChange={(e) => { - const v = e.target.value; applyToMulti((o: any) => { - if ((o as any).type === 'Quad') (o as any).gridColor = v; + o.locked = e.target.checked; }); }} /> + Lock Object + </label> + + <label + className="e-label" + style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 0 }} + > <input - type="text" - className="e-input" - placeholder="mixed" - value={sharedGridColor === '' ? '' : sharedGridColor} + type="checkbox" + checked={sharedDisabled === 'on'} + ref={(el) => { + if (el) el.indeterminate = sharedDisabled === 'mixed'; + }} onChange={(e) => { - const v = e.target.value; applyToMulti((o: any) => { - if ((o as any).type === 'Quad') (o as any).gridColor = v; + o.disabled = e.target.checked; }); }} /> - </div> + Disabled + </label> </div> </> )} - - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} - > - <div style={{ display: 'flex', alignItems: 'end' }}> - <label - className="e-label" - style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 0 }} - > - <input - type="checkbox" - checked={sharedIgnoreScaling === 'on'} - ref={(el) => { - if (el) el.indeterminate = sharedIgnoreScaling === 'mixed'; - }} - onChange={(e) => { - applyToMulti((o: any) => { - if (o instanceof Entity) o.ignoreScaling = e.target.checked; - }); - }} - /> - Disable Depth Scaling - </label> - </div> - <label - className="e-label" - style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 0 }} - > - <input - type="checkbox" - checked={sharedLocked === 'on'} - ref={(el) => { - if (el) el.indeterminate = sharedLocked === 'mixed'; - }} - onChange={(e) => { - applyToMulti((o: any) => { - o.locked = e.target.checked; - }); - }} - /> - Lock - </label> - </div> - - <div className="e-row"> - <label className="e-label">Parent</label> - <Select - className="parent-id-select" - value={multiSpatialParentDraft} - onChange={(value) => { - const nextRelation = !value ? '' : multiSpatialRelationDraft || 'in'; - game.editor.saveUndoState(); - setMultiSpatialParentDraft(value || ''); - setMultiSpatialRelationDraft(nextRelation); - applyToMultiRoots((o: any) => { - o.spatial = { - ...(o.spatial || {}), - parentNodeId: value || null, - relation: value ? nextRelation || 'in' : null, - }; - }); - }} - options={getMultiSpatialParentOptions()} - style={{ width: '100%' }} - /> - </div> - - {sharedParentNodeId && ( - <div className="e-row"> - <label className="e-label">Relation</label> - <Select - value={multiSpatialRelationDraft} - onChange={(value) => { - game.editor.saveUndoState(); - setMultiSpatialRelationDraft(value || ''); - applyToMultiRoots((o: any) => { - o.spatial = { - ...(o.spatial || {}), - parentNodeId: o.spatial?.parentNodeId || null, - relation: value || (o.spatial?.parentNodeId ? 'in' : null), - }; - }); - }} - options={getSpatialRelationOptions(true)} - style={{ width: '100%' }} - /> - </div> - )} - - <div className="e-row"> - <label - className="e-label" - style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 0 }} - > - <input - type="checkbox" - checked={sharedDisabled === 'on'} - ref={(el) => { - if (el) el.indeterminate = sharedDisabled === 'mixed'; - }} - onChange={(e) => { - applyToMulti((o: any) => { - o.disabled = e.target.checked; - }); - }} - /> - Disabled - </label> - </div> </div> </div> ); @@ -1157,25 +1247,6 @@ export const PropertiesPanel: React.FC = () => { } }; - const renderSection = ( - section: number, - title: string | null, - color: 'blue' | 'red' | 'yellow' | 'purple' | 'neutral', - children: React.ReactNode - ) => ( - <div ref={setSectionRef(section)} className="properties-section-block" data-section={section}> - {title !== null && ( - <div className={`properties-section-header properties-section-${color}`}> - <div className="properties-section-title"> - <span className={`properties-section-number properties-section-${color}`}>{section}</span> - <span className="properties-section-label">{title}</span> - </div> - </div> - )} - {children} - </div> - ); - const isEntityLike = selectedObjectType === 'Entity' || selectedObjectType === 'Actor' || selectedObjectType === 'Static'; const isTriggerbox = selectedObjectType === 'Triggerbox'; diff --git a/tasks.md b/tasks.md index cff6792..fd4fbba 100644 --- a/tasks.md +++ b/tasks.md @@ -68,17 +68,39 @@ Contains all remaining object-specific controls not covered above. ## Implementation Steps -- [ ] Add reusable section wrapper API in `PropertiesPanel.tsx` -- [ ] Add purple section accent style in CSS -- [ ] Add section number badge style (inverse accent) -- [ ] Add Properties-panel hotkey scroll navigation for digits `0..6` -- [ ] Reorganize multi-selection layout into the new section model -- [ ] Reorganize `Entity` / `Actor` / `Static` -- [ ] Reorganize `Quad` -- [ ] Reorganize `Triggerbox` +- [x] Add reusable section wrapper API in `PropertiesPanel.tsx` +- [x] Add purple section accent style in CSS +- [x] Add section number badge style (inverse accent) +- [x] Add Properties-panel hotkey scroll navigation for digits `0..6` +- [x] Reorganize multi-selection layout into the new section model +- [x] Reorganize `Entity` / `Actor` / `Static` +- [x] Reorganize `Quad` +- [x] Reorganize `Triggerbox` - [ ] Keep `Walkbox` layout unchanged - [ ] Keep `SETTINGS` layout unchanged -- [ ] Verify build and manual editor navigation behavior +- [x] Verify build after section restructuring +- [ ] Manual audit of section contents and navigation behavior +- [ ] Final visual audit of multi-selection panel +- [ ] Update any remaining field placement mismatches found during QA + +## Current Status + +Already implemented: +- shared numbered section headers with common layout; +- inverse section badges with blue / red / yellow / purple / neutral accents; +- Properties-panel digit hotkeys `0..6` with guarded focus behavior; +- single-object common section `0` for ID / Title / TA / Group ID / Parent / Relation; +- `Entity` / `Actor` / `Static` regrouped into `TRANSFORM`, `VISUAL`, `COMPONENTS`, `ACTOR PROP.`, `SCRIPT EVENTS`, and bottom misc section; +- `Quad` regrouped into `TRANSFORM` and `VISUAL`, with `Vertices` inside `TRANSFORM`; +- `Triggerbox` regrouped into the same section model where applicable; +- multi-selection regrouped into sections `0`, `1`, `2`, and `6`; +- `Lock Object` and `Disabled` moved to the bottom misc section for single-object editing with `Alt-L` / `Alt-D` tooltips. + +Still to verify manually: +- `Walkbox` still behaves and looks unchanged; +- `SETTINGS` remains unaffected by the redesign; +- section hotkeys consistently scroll with section headers visible; +- no field remains in the wrong section after live QA across object types. ## Notes From 2836530896d0f419d26b9ed32070aebeb2cfd0df Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 22 Mar 2026 19:01:41 +0200 Subject: [PATCH 65/75] Style: unify opacity and blur controls --- src/components/editor/PropertiesPanel.tsx | 535 ++++++++++------------ 1 file changed, 239 insertions(+), 296 deletions(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 2155edb..1d2a0ea 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -511,6 +511,52 @@ export const PropertiesPanel: React.FC = () => { </div> ); + const renderOpacityBlurControls = ( + opacityValue: number | '', + blurValue: number | '', + onOpacityChange: (nextOpacity: number) => void, + onBlurChange: (nextBlur: number) => void + ) => { + const normalizedOpacity = opacityValue === '' ? 1 : Number(opacityValue); + const normalizedBlur = blurValue === '' ? 0 : Number(blurValue); + const opacityUi = Math.round((1 - normalizedOpacity) * 100); + const blurUi = Math.max(0, Math.min(50, Math.round(normalizedBlur))); + + return ( + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label">Opacity ({opacityUi}%)</label> + <input + type="range" + className="e-input" + style={{ width: '100%' }} + min="0" + max="100" + step="5" + value={opacityUi} + onChange={(e) => onOpacityChange(1 - parseInt(e.target.value, 10) / 100)} + /> + </div> + <div> + <label className="e-label">Blur ({blurUi}px)</label> + <input + type="range" + className="e-input" + style={{ width: '100%' }} + min="0" + max="50" + step="1" + value={blurUi} + onChange={(e) => onBlurChange(parseInt(e.target.value, 10))} + /> + </div> + </div> + ); + }; + React.useEffect(() => { lastUndoObjectKeyRef.current = null; lastUndoMultiKeyRef.current = null; @@ -609,11 +655,6 @@ export const PropertiesPanel: React.FC = () => { null, 'neutral', <> - <div className="e-row"> - <label className="e-label">Selected</label> - <div className="e-label ui-text-muted ui-text-small">{multiObjects.length} objects</div> - </div> - <div className="e-row"> <label className="e-label">Group #ID</label> <div className="e-label ui-text-muted ui-text-small"> @@ -887,46 +928,20 @@ export const PropertiesPanel: React.FC = () => { 'yellow', <> {entitiesAndQuads.length > 0 && ( - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} - > - <div> - <label className="e-label">Opacity</label> - <input - type="number" - step="0.01" - min="0" - max="1" - className="e-input" - placeholder="mixed" - value={sharedOpacity === '' ? '' : sharedOpacity} - onChange={(e) => { - const v = parseFloat(e.target.value); - if (isNaN(v)) return; - applyToMulti((o: any) => { - if (o instanceof Entity) o.opacity = v; - }); - }} - /> - </div> - <div> - <label className="e-label">Blur</label> - <input - type="number" - className="e-input" - placeholder="mixed" - value={sharedBlur === '' ? '' : sharedBlur} - onChange={(e) => { - const v = parseInt(e.target.value); - if (isNaN(v)) return; - applyToMulti((o: any) => { - if (o instanceof Entity) o.blur = v; - }); - }} - /> - </div> - </div> + renderOpacityBlurControls( + sharedOpacity, + sharedBlur, + (nextOpacity) => { + applyToMulti((o: any) => { + if (o instanceof Entity) o.opacity = nextOpacity; + }); + }, + (nextBlur) => { + applyToMulti((o: any) => { + if (o instanceof Entity) o.blur = nextBlur; + }); + } + ) )} {entitiesAndQuads.length > 0 && ( @@ -1638,39 +1653,12 @@ export const PropertiesPanel: React.FC = () => { </div> </div> - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label"> - Opacity ({Math.round((obj.opacity !== undefined ? obj.opacity : 1.0) * 100)}%) - </label> - <input - type="range" - className="e-input" - style={{ width: '100%' }} - min="0" - max="1" - step="0.05" - value={obj.opacity !== undefined ? obj.opacity : 1.0} - onChange={(e) => handleChange('opacity', e.target.value, true)} - /> - </div> - <div> - <label className="e-label">Blur ({obj.blur || 0}px)</label> - <input - type="range" - className="e-input" - style={{ width: '100%', direction: 'ltr' }} - min="0" - max="50" - step="1" - value={50 - (obj.blur || 0)} - onChange={(e) => handleChange('blur', 50 - parseInt(e.target.value))} - /> - </div> - </div> + {renderOpacityBlurControls( + obj.opacity !== undefined ? obj.opacity : 1.0, + obj.blur || 0, + (nextOpacity) => handleChange('opacity', nextOpacity, true), + (nextBlur) => handleChange('blur', nextBlur) + )} <div className="e-row"> <label className="e-label">Sprite</label> @@ -2083,39 +2071,12 @@ export const PropertiesPanel: React.FC = () => { </div> </div> </div> - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label"> - Opacity ({Math.round((obj.opacity !== undefined ? obj.opacity : 1.0) * 100)}%) - </label> - <input - type="range" - className="e-input" - style={{ width: '100%' }} - min="0" - max="1" - step="0.05" - value={obj.opacity !== undefined ? obj.opacity : 1.0} - onChange={(e) => handleChange('opacity', e.target.value, true)} - /> - </div> - <div> - <label className="e-label">Blur ({obj.blur || 0}px)</label> - <input - type="range" - className="e-input" - style={{ width: '100%', direction: 'ltr' }} - min="0" - max="50" - step="1" - value={50 - (obj.blur || 0)} - onChange={(e) => handleChange('blur', 50 - parseInt(e.target.value))} - /> - </div> - </div> + {renderOpacityBlurControls( + obj.opacity !== undefined ? obj.opacity : 1.0, + obj.blur || 0, + (nextOpacity) => handleChange('opacity', nextOpacity, true), + (nextBlur) => handleChange('blur', nextBlur) + )} {/* Fill Color */} <div className="e-row"> @@ -3306,113 +3267,106 @@ export const PropertiesPanel: React.FC = () => { {/* SCENE Properties */} {selectedObjectType === 'SCENE' && ( <> - {/* Camera properties */} - {(obj.camera || obj.defaultCamera) && ( - <div className="e-row ui-divider-blue" style={{ paddingTop: '5px' }}> - <div className="e-label ui-text-accent-blue ui-font-bold"> - CAMERA - </div> - <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }}> - <div> - <label className="e-label">Cam X</label> - <input - type="number" - className="e-input" - value={obj.camera ? Math.round(obj.camera.x) : 0} - onChange={(e) => { - if (obj.camera) { - obj.camera.x = parseFloat(e.target.value); - incrementObjectVersion(); - } - }} - /> - </div> - <div> - <label className="e-label">Cam Y</label> - <input - type="number" - className="e-input" - value={obj.camera ? Math.round(obj.camera.y) : 0} - onChange={(e) => { - if (obj.camera) { - obj.camera.y = parseFloat(e.target.value); - incrementObjectVersion(); - } - }} - /> - </div> - <div> - <label className="e-label">Zoom</label> - <input - type="number" - step="0.1" - className="e-input" - value={obj.camera ? obj.camera.zoom : 1} - onChange={(e) => { - if (obj.camera) { - obj.camera.zoom = parseFloat(e.target.value); - incrementObjectVersion(); - } - }} - /> + {(obj.camera || obj.defaultCamera) && + renderSection( + 1, + 'Camera', + 'blue', + <> + <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }}> + <div> + <label className="e-label">Cam X</label> + <input + type="number" + className="e-input" + value={obj.camera ? Math.round(obj.camera.x) : 0} + onChange={(e) => { + if (obj.camera) { + obj.camera.x = parseFloat(e.target.value); + incrementObjectVersion(); + } + }} + /> + </div> + <div> + <label className="e-label">Cam Y</label> + <input + type="number" + className="e-input" + value={obj.camera ? Math.round(obj.camera.y) : 0} + onChange={(e) => { + if (obj.camera) { + obj.camera.y = parseFloat(e.target.value); + incrementObjectVersion(); + } + }} + /> + </div> + <div> + <label className="e-label">Zoom</label> + <input + type="number" + step="0.1" + className="e-input" + value={obj.camera ? obj.camera.zoom : 1} + onChange={(e) => { + if (obj.camera) { + obj.camera.zoom = parseFloat(e.target.value); + incrementObjectVersion(); + } + }} + /> + </div> </div> - </div> - <div className="e-row" style={{ marginTop: '5px' }}> - <label className="e-label" style={{ display: 'flex', alignItems: 'center' }}> - <input - type="checkbox" - style={{ marginRight: '5px' }} - checked={!!obj.autoCenter} - onChange={(e) => handleChange('autoCenter', e.target.checked)} - /> - Auto-Center on Player - </label> - </div> - <div - className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }} - > - <div> - <label className="e-label">Cam Spd</label> - <input - type="number" - step="0.1" - className="e-input" - value={obj.cameraSpeed || 5} - onChange={(e) => - handleChange('cameraSpeed', parseFloat(e.target.value), true) - } - /> - </div> - <div> - <label className="e-label">Dead X</label> - <input - type="number" - className="e-input" - value={obj.camDeadzoneX !== undefined ? obj.camDeadzoneX : 50} - onChange={(e) => - handleChange('camDeadzoneX', parseFloat(e.target.value), true) - } - /> + <div className="e-row" style={{ marginTop: '5px' }}> + <label className="e-label" style={{ display: 'flex', alignItems: 'center' }}> + <input + type="checkbox" + style={{ marginRight: '5px' }} + checked={!!obj.autoCenter} + onChange={(e) => handleChange('autoCenter', e.target.checked)} + /> + Auto-Center on Player + </label> </div> - <div> - <label className="e-label">Dead Y</label> - <input - type="number" - className="e-input" - value={obj.camDeadzoneY !== undefined ? obj.camDeadzoneY : 30} - onChange={(e) => - handleChange('camDeadzoneY', parseFloat(e.target.value), true) - } - /> + + <div + className="e-row" + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }} + > + <div> + <label className="e-label">Cam Spd</label> + <input + type="number" + step="0.1" + className="e-input" + value={obj.cameraSpeed || 5} + onChange={(e) => handleChange('cameraSpeed', parseFloat(e.target.value), true)} + /> + </div> + <div> + <label className="e-label">Dead X</label> + <input + type="number" + className="e-input" + value={obj.camDeadzoneX !== undefined ? obj.camDeadzoneX : 50} + onChange={(e) => handleChange('camDeadzoneX', parseFloat(e.target.value), true)} + /> + </div> + <div> + <label className="e-label">Dead Y</label> + <input + type="number" + className="e-input" + value={obj.camDeadzoneY !== undefined ? obj.camDeadzoneY : 30} + onChange={(e) => handleChange('camDeadzoneY', parseFloat(e.target.value), true)} + /> + </div> </div> - </div> - <> + <div className="e-row" style={{ marginTop: '5px' }}> - <div className="e-label ui-text-accent-blue"> - Camera Bounds (Min/Max) - </div> + <div className="e-label ui-text-accent-blue">Camera Bounds (Min/Max)</div> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}> <div> <label className="e-label">Min X</label> @@ -3480,89 +3434,81 @@ export const PropertiesPanel: React.FC = () => { </div> </div> </div> - </> - </div> - )} - {/* Default Camera (Start Position) */} - {obj.defaultCamera && ( - <div className="e-row ui-divider-blue" style={{ paddingTop: '5px' }}> - <div className="e-label ui-text-accent-blue ui-font-bold"> - DEFAULT CAMERA - </div> - <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }}> - <div> - <label className="e-label">Def X</label> - <input - type="number" - className="e-input" - value={Math.round(obj.defaultCamera.x)} - onChange={(e) => { - obj.defaultCamera.x = parseFloat(e.target.value); - incrementObjectVersion(); - }} - /> - </div> - <div> - <label className="e-label">Def Y</label> - <input - type="number" - className="e-input" - value={Math.round(obj.defaultCamera.y)} - onChange={(e) => { - obj.defaultCamera.y = parseFloat(e.target.value); - incrementObjectVersion(); - }} - /> - </div> - <div> - <label className="e-label">Def Zoom</label> - <input - type="number" - step="0.1" - className="e-input" - value={obj.defaultCamera.zoom} - onChange={(e) => { - obj.defaultCamera.zoom = parseFloat(e.target.value); - incrementObjectVersion(); - }} - /> - </div> - </div> - <div className="e-row" style={{ marginTop: '5px' }}> - <button - className="e-btn" - style={{ width: '100%' }} - onClick={() => { - if (obj.camera && obj.defaultCamera) { - obj.defaultCamera.x = obj.camera.x; - obj.defaultCamera.y = obj.camera.y; - obj.defaultCamera.zoom = obj.camera.zoom; - incrementObjectVersion(); - } - }} - > - Set Current as Default - </button> - </div> - </div> - )} + {obj.defaultCamera && ( + <div className="e-row" style={{ marginTop: '5px' }}> + <div className="e-label ui-text-accent-blue">Default Camera</div> + <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }}> + <div> + <label className="e-label">Def X</label> + <input + type="number" + className="e-input" + value={Math.round(obj.defaultCamera.x)} + onChange={(e) => { + obj.defaultCamera.x = parseFloat(e.target.value); + incrementObjectVersion(); + }} + /> + </div> + <div> + <label className="e-label">Def Y</label> + <input + type="number" + className="e-input" + value={Math.round(obj.defaultCamera.y)} + onChange={(e) => { + obj.defaultCamera.y = parseFloat(e.target.value); + incrementObjectVersion(); + }} + /> + </div> + <div> + <label className="e-label">Def Zoom</label> + <input + type="number" + step="0.1" + className="e-input" + value={obj.defaultCamera.zoom} + onChange={(e) => { + obj.defaultCamera.zoom = parseFloat(e.target.value); + incrementObjectVersion(); + }} + /> + </div> + </div> + <div className="e-row" style={{ marginTop: '5px' }}> + <button + className="e-btn" + style={{ width: '100%' }} + onClick={() => { + if (obj.camera && obj.defaultCamera) { + obj.defaultCamera.x = obj.camera.x; + obj.defaultCamera.y = obj.camera.y; + obj.defaultCamera.zoom = obj.camera.zoom; + incrementObjectVersion(); + } + }} + > + Set Current as Default + </button> + </div> + </div> + )} + </> + )} - {/* Scaling Settings */} - {game.sceneManager.currentScene && ( - <div className="e-row ui-divider-yellow" style={{ paddingTop: '5px' }}> - <div className="e-label ui-text-accent-yellow ui-font-bold"> - SCALING - </div> - {(() => { + {game.sceneManager.currentScene && + renderSection( + 2, + 'Scaling', + 'yellow', + (() => { const s = game.sceneManager.currentScene.scaling; return ( <> <div className="e-row"> - <label - className="e-label" - style={{ display: 'flex', alignItems: 'center' }} - > + <label className="e-label" style={{ display: 'flex', alignItems: 'center' }}> <input type="checkbox" style={{ marginRight: '5px' }} @@ -3576,9 +3522,7 @@ export const PropertiesPanel: React.FC = () => { </label> </div> {s.enabled && ( - <div - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} - > + <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}> <div> <label className="e-label">Min</label> <input @@ -3633,9 +3577,8 @@ export const PropertiesPanel: React.FC = () => { )} </> ); - })()} - </div> - )} + })() + )} </> )} From d50751200ea877e10409281c47a6b59af1746c9d Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 22 Mar 2026 19:35:05 +0200 Subject: [PATCH 66/75] Style: polish properties panel numeric controls --- src/components/editor/PropertiesPanel.tsx | 219 +++++++++++++--------- 1 file changed, 131 insertions(+), 88 deletions(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 1d2a0ea..7768b57 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -27,6 +27,7 @@ export const PropertiesPanel: React.FC = () => { const lastUndoObjectKeyRef = React.useRef<string | null>(null); const lastUndoMultiKeyRef = React.useRef<string | null>(null); const lastPolygonScaleObjectKeyRef = React.useRef<string | null>(null); + const polygonScaleSnapshotRef = React.useRef<any>(null); const panelRef = React.useRef<HTMLDivElement | null>(null); const contentRef = React.useRef<HTMLDivElement | null>(null); const sectionRefs = React.useRef<Record<number, HTMLDivElement | null>>({}); @@ -157,6 +158,13 @@ export const PropertiesPanel: React.FC = () => { return first ? 'on' : 'off'; }; + const formatPanelNumber = React.useCallback((value: any): number | string => { + if (value === '' || value === null || value === undefined) return ''; + const n = Number(value); + if (!Number.isFinite(n)) return value; + return Number(n.toFixed(3)); + }, []); + const setSectionRef = React.useCallback( (section: number) => (node: HTMLDivElement | null) => { sectionRefs.current[section] = node; @@ -274,29 +282,54 @@ export const PropertiesPanel: React.FC = () => { if (!Number.isFinite(nextScale) || nextScale <= 0) return; const objectKey = `${selectedObjectType || 'Object'}:${obj.name || ''}`; - const previousScale = - lastPolygonScaleObjectKeyRef.current === objectKey ? parseFloat(polygonScaleDraft) || 1 : 1; - const factor = nextScale / previousScale; - if (!Number.isFinite(factor) || factor <= 0 || Math.abs(factor - 1) < 0.0001) { + if (lastPolygonScaleObjectKeyRef.current !== objectKey) { + game?.editor?.saveUndoState(); + if (selectedObjectType === 'Quad' && obj.vertices?.length) { + polygonScaleSnapshotRef.current = { + key: objectKey, + kind: 'quad', + vertices: obj.vertices.map((v: any) => ({ ...v })), + }; + } else if (obj.poly?.length) { + polygonScaleSnapshotRef.current = { + key: objectKey, + kind: 'poly', + poly: obj.poly.map((pt: any) => ({ x: pt.x, y: pt.y })), + }; + } else { + polygonScaleSnapshotRef.current = null; + } + } + + const snapshot = polygonScaleSnapshotRef.current; + if (!snapshot || snapshot.key !== objectKey) { setPolygonScaleDraft(nextScaleRaw); lastPolygonScaleObjectKeyRef.current = objectKey; return; } - game?.editor?.saveUndoState(); - - if (selectedObjectType === 'Quad' && obj.vertices?.length) { - const centroid = getQuadCentroid(obj); - obj.vertices = scaleQuadVerticesByFactor(obj.vertices, factor, centroid.x, centroid.y); + if (snapshot.kind === 'quad' && selectedObjectType === 'Quad' && snapshot.vertices?.length) { + const sourceVertices = snapshot.vertices.map((v: any) => ({ ...v })); + const sourceCentroid = { + x: sourceVertices.reduce((acc: number, v: any) => acc + v.x, 0) / sourceVertices.length, + y: sourceVertices.reduce((acc: number, v: any) => acc + v.y, 0) / sourceVertices.length, + }; + obj.vertices = scaleQuadVerticesByFactor( + sourceVertices, + nextScale, + sourceCentroid.x, + sourceCentroid.y + ); obj.x = Math.round( obj.vertices.reduce((acc: number, v: any) => acc + v.x, 0) / obj.vertices.length ); obj.y = Math.round( obj.vertices.reduce((acc: number, v: any) => acc + v.y, 0) / obj.vertices.length ); - } else if (obj.poly?.length) { - const centroid = getPolyCentroid(obj.poly); - obj.poly = scalePolyByFactor(obj.poly, factor, centroid.x, centroid.y); + } else if (snapshot.kind === 'poly' && snapshot.poly?.length) { + const sourcePoly = snapshot.poly.map((pt: any) => ({ x: pt.x, y: pt.y })); + const sourceCentroid = getPolyCentroid(sourcePoly); + obj.poly = scalePolyByFactor(sourcePoly, nextScale, sourceCentroid.x, sourceCentroid.y); } setPolygonScaleDraft(nextScaleRaw); @@ -309,7 +342,6 @@ export const PropertiesPanel: React.FC = () => { getQuadCentroid, incrementObjectVersion, obj, - polygonScaleDraft, scalePolyByFactor, scaleQuadVerticesByFactor, selectedObjectType, @@ -363,6 +395,7 @@ export const PropertiesPanel: React.FC = () => { React.useEffect(() => { setPolygonScaleDraft('1'); lastPolygonScaleObjectKeyRef.current = null; + polygonScaleSnapshotRef.current = null; }, [selectedObjectType, selectedObjectId]); const loadResolvedTitle = React.useCallback( @@ -525,7 +558,7 @@ export const PropertiesPanel: React.FC = () => { return ( <div className="e-row" - style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }} + style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }} > <div> <label className="e-label">Opacity ({opacityUi}%)</label> @@ -782,7 +815,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={group.offsetX.toFixed(2)} + value={formatPanelNumber(group.offsetX)} onChange={(e) => { const multiKey = `MULTI:${multiObjects .map((item: any) => item?.name || '') @@ -807,7 +840,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={group.offsetY.toFixed(2)} + value={formatPanelNumber(group.offsetY)} onChange={(e) => { const multiKey = `MULTI:${multiObjects .map((item: any) => item?.name || '') @@ -839,7 +872,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="0.01" className="e-input" - value={group.scale.toFixed(2)} + value={formatPanelNumber(group.scale)} onChange={(e) => { const multiKey = `MULTI:${multiObjects .map((item: any) => item?.name || '') @@ -862,7 +895,7 @@ export const PropertiesPanel: React.FC = () => { type="number" className="e-input" placeholder="mixed" - value={sharedLayer === '' ? '' : sharedLayer} + value={sharedLayer === '' ? '' : formatPanelNumber(sharedLayer)} onChange={(e) => { const v = parseInt(e.target.value); if (isNaN(v)) return; @@ -881,7 +914,7 @@ export const PropertiesPanel: React.FC = () => { step="0.1" className="e-input ui-text-muted" placeholder="mixed" - value={sharedParallax === '' ? '' : sharedParallax} + value={sharedParallax === '' ? '' : formatPanelNumber(sharedParallax)} onChange={(e) => { const v = parseFloat(e.target.value); if (isNaN(v)) return; @@ -1043,7 +1076,7 @@ export const PropertiesPanel: React.FC = () => { type="number" className="e-input" placeholder="mixed" - value={sharedGridX === '' ? '' : sharedGridX} + value={sharedGridX === '' ? '' : formatPanelNumber(sharedGridX)} onChange={(e) => { const v = parseInt(e.target.value); if (isNaN(v)) return; @@ -1061,7 +1094,7 @@ export const PropertiesPanel: React.FC = () => { type="number" className="e-input" placeholder="mixed" - value={sharedGridY === '' ? '' : sharedGridY} + value={sharedGridY === '' ? '' : formatPanelNumber(sharedGridY)} onChange={(e) => { const v = parseInt(e.target.value); if (isNaN(v)) return; @@ -1079,7 +1112,7 @@ export const PropertiesPanel: React.FC = () => { type="number" className="e-input" placeholder="mixed" - value={sharedGridWidth === '' ? '' : sharedGridWidth} + value={sharedGridWidth === '' ? '' : formatPanelNumber(sharedGridWidth)} onChange={(e) => { const v = parseFloat(e.target.value); if (isNaN(v)) return; @@ -1138,10 +1171,12 @@ export const PropertiesPanel: React.FC = () => { > <label className="e-label" + title="Toggle lock hotkey: Alt-L" style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 0 }} > <input type="checkbox" + title="Alt-L" checked={sharedLocked === 'on'} ref={(el) => { if (el) el.indeterminate = sharedLocked === 'mixed'; @@ -1157,10 +1192,12 @@ export const PropertiesPanel: React.FC = () => { <label className="e-label" + title="Toggle disabled hotkey: Alt-D" style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 0 }} > <input type="checkbox" + title="Alt-D" checked={sharedDisabled === 'on'} ref={(el) => { if (el) el.indeterminate = sharedDisabled === 'mixed'; @@ -1485,7 +1522,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.x ?? 0} + value={formatPanelNumber(obj.x ?? 0)} onChange={(e) => handleChange('x', e.target.value, true)} /> </div> @@ -1494,7 +1531,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.y ?? 0} + value={formatPanelNumber(obj.y ?? 0)} onChange={(e) => handleChange('y', e.target.value, true)} /> </div> @@ -1503,7 +1540,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.height ?? 0} + value={formatPanelNumber(obj.height ?? 0)} onChange={(e) => handleChange('height', e.target.value, true)} /> </div> @@ -1512,7 +1549,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.width ?? 0} + value={formatPanelNumber(obj.width ?? 0)} onChange={(e) => handleChange('width', e.target.value, true)} /> </div> @@ -1528,7 +1565,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="0.1" className="e-input" - value={obj.modelScale || 1} + value={formatPanelNumber(obj.modelScale || 1)} onChange={(e) => handleChange('modelScale', e.target.value, true)} /> </div> @@ -1537,7 +1574,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.layer || 0} + value={formatPanelNumber(obj.layer || 0)} onChange={(e) => handleChange('layer', e.target.value, true)} /> </div> @@ -1547,7 +1584,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="0.1" className="e-input" - value={obj.parallax ?? 1} + value={formatPanelNumber(obj.parallax ?? 1)} onChange={(e) => { const val = parseFloat(e.target.value); const newP = isNaN(val) ? 1.0 : val; @@ -1578,7 +1615,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.colliderHeight ?? 0} + value={formatPanelNumber(obj.colliderHeight ?? 0)} onChange={(e) => handleChange('colliderHeight', e.target.value, true)} /> </div> @@ -1587,7 +1624,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.colliderWidth ?? 0} + value={formatPanelNumber(obj.colliderWidth ?? 0)} onChange={(e) => handleChange('colliderWidth', e.target.value, true)} /> </div> @@ -1734,7 +1771,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={Math.round(getPolyCentroid(obj.poly).x)} + value={formatPanelNumber(getPolyCentroid(obj.poly).x)} onChange={(e) => translatePolyTo(parseFloat(e.target.value) || 0, getPolyCentroid(obj.poly).y)} /> </div> @@ -1743,7 +1780,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={Math.round(getPolyCentroid(obj.poly).y)} + value={formatPanelNumber(getPolyCentroid(obj.poly).y)} onChange={(e) => translatePolyTo(getPolyCentroid(obj.poly).x, parseFloat(e.target.value) || 0)} /> </div> @@ -1765,7 +1802,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.layer || 0} + value={formatPanelNumber(obj.layer || 0)} onChange={(e) => handleChange('layer', e.target.value, true)} /> </div> @@ -1797,7 +1834,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={Math.round(getQuadCentroid(obj).x)} + value={formatPanelNumber(getQuadCentroid(obj).x)} onChange={(e) => translateQuadTo(parseFloat(e.target.value) || 0, getQuadCentroid(obj).y)} /> </div> @@ -1806,7 +1843,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={Math.round(getQuadCentroid(obj).y)} + value={formatPanelNumber(getQuadCentroid(obj).y)} onChange={(e) => translateQuadTo(getQuadCentroid(obj).x, parseFloat(e.target.value) || 0)} /> </div> @@ -1815,7 +1852,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.layer || 0} + value={formatPanelNumber(obj.layer || 0)} onChange={(e) => handleChange('layer', e.target.value, true)} /> </div> @@ -1831,7 +1868,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="0.1" className="e-input" - value={obj.parallax ?? 1} + value={formatPanelNumber(obj.parallax ?? 1)} onChange={(e) => handleChange('parallax', e.target.value, true)} /> </div> @@ -1965,7 +2002,7 @@ export const PropertiesPanel: React.FC = () => { type="number" className="e-input" style={{ width: '33%' }} - value={Math.round(v.x)} + value={formatPanelNumber(v.x)} onChange={(e) => { const val = parseFloat(e.target.value); if (v.x !== val) { @@ -1993,7 +2030,7 @@ export const PropertiesPanel: React.FC = () => { type="number" className="e-input" style={{ width: '33%' }} - value={Math.round(v.y)} + value={formatPanelNumber(v.y)} onChange={(e) => { const val = parseFloat(e.target.value); if (v.y !== val) { @@ -2022,7 +2059,7 @@ export const PropertiesPanel: React.FC = () => { className="e-input" style={{ width: '33%' }} step="0.1" - value={v.p} + value={formatPanelNumber(v.p)} onChange={(e) => { const newP = parseFloat(e.target.value); const oldP = v.p; @@ -2148,7 +2185,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.gridLinesX ?? 5} + value={formatPanelNumber(obj.gridLinesX ?? 5)} onChange={(e) => handleChange('gridLinesX', parseInt(e.target.value))} min={1} max={50} @@ -2159,7 +2196,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.gridLinesY ?? 5} + value={formatPanelNumber(obj.gridLinesY ?? 5)} onChange={(e) => handleChange('gridLinesY', parseInt(e.target.value))} min={1} max={50} @@ -2170,7 +2207,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.lineWidth ?? 1.0} + value={formatPanelNumber(obj.lineWidth ?? 1.0)} onChange={(e) => handleChange('lineWidth', parseFloat(e.target.value))} step={0.1} min={0.1} @@ -2834,7 +2871,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={comp.offsetX || 0} + value={formatPanelNumber(comp.offsetX || 0)} onChange={(e) => { comp.offsetX = parseFloat(e.target.value); incrementObjectVersion(); @@ -2848,7 +2885,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={comp.offsetY || 0} + value={formatPanelNumber(comp.offsetY || 0)} onChange={(e) => { comp.offsetY = parseFloat(e.target.value); incrementObjectVersion(); @@ -2932,7 +2969,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="0.01" className="e-input" - value={obj.speed !== undefined ? obj.speed : 0.1} + value={formatPanelNumber(obj.speed !== undefined ? obj.speed : 0.1)} onChange={(e) => handleChange('speed', e.target.value, true)} /> </div> @@ -2944,7 +2981,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="10" className="e-input" - value={obj.animationSpeed !== undefined ? obj.animationSpeed : 150} + value={formatPanelNumber(obj.animationSpeed !== undefined ? obj.animationSpeed : 150)} onChange={(e) => handleChange('animationSpeed', e.target.value, true)} /> </div> @@ -3230,31 +3267,37 @@ export const PropertiesPanel: React.FC = () => { </div> )} - <div className="e-row" style={{ marginTop: isTriggerbox || selectedObjectType === 'Quad' ? '10px' : 0 }}> + <div + className="e-row" + style={{ + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '6px', + marginTop: isTriggerbox || selectedObjectType === 'Quad' ? '10px' : 0, + }} + > <label className="e-label" title="Toggle lock hotkey: Alt-L" - style={{ display: 'flex', alignItems: 'center' }} + style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 0 }} > <input type="checkbox" title="Alt-L" - style={{ marginRight: '5px' }} checked={!!obj.locked} onChange={(e) => handleChange('locked', e.target.checked)} /> Lock Object </label> - </div> - <div className="e-row"> + <label - className="e-label ui-inline-flex-center ui-text-accent-red" + className="e-label" title="Toggle disabled hotkey: Alt-D" + style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: 0 }} > <input type="checkbox" title="Alt-D" - style={{ marginRight: '5px' }} checked={!!obj.disabled} onChange={(e) => handleChange('disabled', e.target.checked)} /> @@ -3279,7 +3322,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.camera ? Math.round(obj.camera.x) : 0} + value={obj.camera ? formatPanelNumber(obj.camera.x) : 0} onChange={(e) => { if (obj.camera) { obj.camera.x = parseFloat(e.target.value); @@ -3293,7 +3336,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.camera ? Math.round(obj.camera.y) : 0} + value={obj.camera ? formatPanelNumber(obj.camera.y) : 0} onChange={(e) => { if (obj.camera) { obj.camera.y = parseFloat(e.target.value); @@ -3308,7 +3351,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="0.1" className="e-input" - value={obj.camera ? obj.camera.zoom : 1} + value={obj.camera ? formatPanelNumber(obj.camera.zoom) : 1} onChange={(e) => { if (obj.camera) { obj.camera.zoom = parseFloat(e.target.value); @@ -3341,7 +3384,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="0.1" className="e-input" - value={obj.cameraSpeed || 5} + value={formatPanelNumber(obj.cameraSpeed || 5)} onChange={(e) => handleChange('cameraSpeed', parseFloat(e.target.value), true)} /> </div> @@ -3350,7 +3393,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.camDeadzoneX !== undefined ? obj.camDeadzoneX : 50} + value={formatPanelNumber(obj.camDeadzoneX !== undefined ? obj.camDeadzoneX : 50)} onChange={(e) => handleChange('camDeadzoneX', parseFloat(e.target.value), true)} /> </div> @@ -3359,7 +3402,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={obj.camDeadzoneY !== undefined ? obj.camDeadzoneY : 30} + value={formatPanelNumber(obj.camDeadzoneY !== undefined ? obj.camDeadzoneY : 30)} onChange={(e) => handleChange('camDeadzoneY', parseFloat(e.target.value), true)} /> </div> @@ -3374,7 +3417,7 @@ export const PropertiesPanel: React.FC = () => { type="number" className="e-input" placeholder="None" - value={obj.camMinX !== undefined ? obj.camMinX : ''} + value={obj.camMinX !== undefined ? formatPanelNumber(obj.camMinX) : ''} onChange={(e) => handleChange( 'camMinX', @@ -3390,7 +3433,7 @@ export const PropertiesPanel: React.FC = () => { type="number" className="e-input" placeholder="None" - value={obj.camMaxX !== undefined ? obj.camMaxX : ''} + value={obj.camMaxX !== undefined ? formatPanelNumber(obj.camMaxX) : ''} onChange={(e) => handleChange( 'camMaxX', @@ -3406,7 +3449,7 @@ export const PropertiesPanel: React.FC = () => { type="number" className="e-input" placeholder="None" - value={obj.camMinY !== undefined ? obj.camMinY : ''} + value={obj.camMinY !== undefined ? formatPanelNumber(obj.camMinY) : ''} onChange={(e) => handleChange( 'camMinY', @@ -3422,7 +3465,7 @@ export const PropertiesPanel: React.FC = () => { type="number" className="e-input" placeholder="None" - value={obj.camMaxY !== undefined ? obj.camMaxY : ''} + value={obj.camMaxY !== undefined ? formatPanelNumber(obj.camMaxY) : ''} onChange={(e) => handleChange( 'camMaxY', @@ -3444,7 +3487,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={Math.round(obj.defaultCamera.x)} + value={formatPanelNumber(obj.defaultCamera.x)} onChange={(e) => { obj.defaultCamera.x = parseFloat(e.target.value); incrementObjectVersion(); @@ -3456,7 +3499,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={Math.round(obj.defaultCamera.y)} + value={formatPanelNumber(obj.defaultCamera.y)} onChange={(e) => { obj.defaultCamera.y = parseFloat(e.target.value); incrementObjectVersion(); @@ -3469,7 +3512,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="0.1" className="e-input" - value={obj.defaultCamera.zoom} + value={formatPanelNumber(obj.defaultCamera.zoom)} onChange={(e) => { obj.defaultCamera.zoom = parseFloat(e.target.value); incrementObjectVersion(); @@ -3529,7 +3572,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="0.1" className="e-input" - value={s.min} + value={formatPanelNumber(s.min)} onChange={(e) => { s.min = parseFloat(e.target.value); incrementObjectVersion(); @@ -3542,7 +3585,7 @@ export const PropertiesPanel: React.FC = () => { type="number" step="0.1" className="e-input" - value={s.max} + value={formatPanelNumber(s.max)} onChange={(e) => { s.max = parseFloat(e.target.value); incrementObjectVersion(); @@ -3554,7 +3597,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={s.horizon} + value={formatPanelNumber(s.horizon)} onChange={(e) => { s.horizon = parseFloat(e.target.value); incrementObjectVersion(); @@ -3566,7 +3609,7 @@ export const PropertiesPanel: React.FC = () => { <input type="number" className="e-input" - value={s.front} + value={formatPanelNumber(s.front)} onChange={(e) => { s.front = parseFloat(e.target.value); incrementObjectVersion(); @@ -3600,7 +3643,7 @@ export const PropertiesPanel: React.FC = () => { className="e-label" style={{ display: 'flex', justifyContent: 'space-between' }} > - UI Scale <span>{(obj.editor?.uiScale || 1.0).toFixed(1)}x</span> + UI Scale <span>{formatPanelNumber(obj.editor?.uiScale || 1.0)}x</span> </label> <input type="number" @@ -3608,7 +3651,7 @@ export const PropertiesPanel: React.FC = () => { min="0.5" max="2.0" step="0.1" - value={obj.editor?.uiScale || 1.0} + value={formatPanelNumber(obj.editor?.uiScale || 1.0)} onChange={(e) => { if (!obj.editor) obj.editor = { uiScale: 1.0 }; obj.editor.uiScale = parseFloat(e.target.value); @@ -3656,7 +3699,7 @@ export const PropertiesPanel: React.FC = () => { className="e-label" style={{ display: 'flex', justifyContent: 'space-between' }} > - Curvature <span>{obj.crt.curvature.toFixed(2)}</span> + Curvature <span>{formatPanelNumber(obj.crt.curvature)}</span> </label> <input type="range" @@ -3664,7 +3707,7 @@ export const PropertiesPanel: React.FC = () => { min="0" max="0.5" step="0.01" - value={obj.crt.curvature} + value={formatPanelNumber(obj.crt.curvature)} onChange={(e) => { obj.crt.curvature = parseFloat(e.target.value); incrementObjectVersion(); @@ -3676,7 +3719,7 @@ export const PropertiesPanel: React.FC = () => { className="e-label" style={{ display: 'flex', justifyContent: 'space-between' }} > - Vignette <span>{obj.crt.vignette.toFixed(2)}</span> + Vignette <span>{formatPanelNumber(obj.crt.vignette)}</span> </label> <input type="range" @@ -3684,7 +3727,7 @@ export const PropertiesPanel: React.FC = () => { min="0" max="1" step="0.05" - value={obj.crt.vignette} + value={formatPanelNumber(obj.crt.vignette)} onChange={(e) => { obj.crt.vignette = parseFloat(e.target.value); incrementObjectVersion(); @@ -3696,7 +3739,7 @@ export const PropertiesPanel: React.FC = () => { className="e-label" style={{ display: 'flex', justifyContent: 'space-between' }} > - Scanline Count <span>{Math.round(obj.crt.scanlineCount)}</span> + Scanline Count <span>{formatPanelNumber(obj.crt.scanlineCount)}</span> </label> <input type="range" @@ -3704,7 +3747,7 @@ export const PropertiesPanel: React.FC = () => { min="100" max="2000" step="50" - value={obj.crt.scanlineCount} + value={formatPanelNumber(obj.crt.scanlineCount)} onChange={(e) => { obj.crt.scanlineCount = parseFloat(e.target.value); incrementObjectVersion(); @@ -3716,7 +3759,7 @@ export const PropertiesPanel: React.FC = () => { className="e-label" style={{ display: 'flex', justifyContent: 'space-between' }} > - Scanline Intensity <span>{obj.crt.scanlineIntensity.toFixed(2)}</span> + Scanline Intensity <span>{formatPanelNumber(obj.crt.scanlineIntensity)}</span> </label> <input type="range" @@ -3724,7 +3767,7 @@ export const PropertiesPanel: React.FC = () => { min="0" max="1" step="0.05" - value={obj.crt.scanlineIntensity} + value={formatPanelNumber(obj.crt.scanlineIntensity)} onChange={(e) => { obj.crt.scanlineIntensity = parseFloat(e.target.value); incrementObjectVersion(); @@ -3736,7 +3779,7 @@ export const PropertiesPanel: React.FC = () => { className="e-label" style={{ display: 'flex', justifyContent: 'space-between' }} > - RGB Split <span>{obj.crt.aberration.toFixed(1)}</span> + RGB Split <span>{formatPanelNumber(obj.crt.aberration)}</span> </label> <input type="range" @@ -3744,7 +3787,7 @@ export const PropertiesPanel: React.FC = () => { min="0" max="5" step="0.1" - value={obj.crt.aberration} + value={formatPanelNumber(obj.crt.aberration)} onChange={(e) => { obj.crt.aberration = parseFloat(e.target.value); incrementObjectVersion(); @@ -3756,7 +3799,7 @@ export const PropertiesPanel: React.FC = () => { className="e-label" style={{ display: 'flex', justifyContent: 'space-between' }} > - Bloom <span>{obj.crt.bloom.toFixed(2)}</span> + Bloom <span>{formatPanelNumber(obj.crt.bloom)}</span> </label> <input type="range" @@ -3764,7 +3807,7 @@ export const PropertiesPanel: React.FC = () => { min="0" max="1" step="0.05" - value={obj.crt.bloom} + value={formatPanelNumber(obj.crt.bloom)} onChange={(e) => { obj.crt.bloom = parseFloat(e.target.value); incrementObjectVersion(); @@ -3777,7 +3820,7 @@ export const PropertiesPanel: React.FC = () => { style={{ display: 'flex', justifyContent: 'space-between' }} > Phosphor / Grain{' '} - <span>{obj.crt.phosphor ? obj.crt.phosphor.toFixed(2) : '0.00'}</span> + <span>{formatPanelNumber(obj.crt.phosphor || 0)}</span> </label> <input type="range" @@ -3785,7 +3828,7 @@ export const PropertiesPanel: React.FC = () => { min="0" max="1" step="0.05" - value={obj.crt.phosphor || 0} + value={formatPanelNumber(obj.crt.phosphor || 0)} onChange={(e) => { obj.crt.phosphor = parseFloat(e.target.value); incrementObjectVersion(); From 3f6e6b5499765fe2723ae954d76815d4f7fa73be Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 22 Mar 2026 20:04:21 +0200 Subject: [PATCH 67/75] Feature: add parallax support for triggerboxes --- src/components/editor/PropertiesPanel.tsx | 38 ++++++++++++-- src/entities/Triggerbox.ts | 8 ++- src/graphics/SceneRenderer.ts | 2 +- src/scene/SceneInteraction.ts | 4 +- src/tools/SceneEditor.ts | 3 +- src/tools/editor/EditorTransformManager.ts | 59 +++++++++++++++------- 6 files changed, 86 insertions(+), 28 deletions(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index 7768b57..ae2356b 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -4,6 +4,7 @@ import { useGame } from '../../hooks/useGame'; import { Select } from '../../components/common/Select'; import { QuadObject } from '../../entities/QuadObject'; import { Entity } from '../../entities/Entity'; +import { Triggerbox } from '../../entities/Triggerbox'; export const PropertiesPanel: React.FC = () => { const game = useGame(); @@ -631,9 +632,12 @@ export const PropertiesPanel: React.FC = () => { if (selectedObjectType === 'MULTI' && multiObjects.length > 1) { const group = game.editor.selectionManager.getGroupTransform(); const entitiesAndQuads = multiObjects.filter((o: any) => o instanceof Entity); + const parallaxObjects = multiObjects.filter( + (o: any) => o instanceof Entity || o instanceof Triggerbox || (o as any).type === 'Quad' + ); const quads = multiObjects.filter((o: any) => (o as any).type === 'Quad'); const sharedLayer = getSharedValue(multiObjects, (o) => o.layer ?? 0); - const sharedParallax = getSharedValue(entitiesAndQuads, (o: any) => o.parallax ?? 1); + const sharedParallax = getSharedValue(parallaxObjects, (o: any) => o.parallax ?? 1); const sharedBlendMode = getSharedValue( entitiesAndQuads, (o: any) => o.blendMode || 'source-over' @@ -906,7 +910,7 @@ export const PropertiesPanel: React.FC = () => { /> </div> <div> - {entitiesAndQuads.length > 0 ? ( + {parallaxObjects.length > 0 ? ( <> <label className="e-label">Parallax</label> <input @@ -919,7 +923,9 @@ export const PropertiesPanel: React.FC = () => { const v = parseFloat(e.target.value); if (isNaN(v)) return; applyToMulti((o: any) => { - if (o instanceof Entity) o.parallax = v; + if (o instanceof Entity || o instanceof Triggerbox || (o as any).type === 'Quad') { + o.parallax = v; + } }); }} /> @@ -1785,7 +1791,7 @@ export const PropertiesPanel: React.FC = () => { /> </div> </div> - <div className="e-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}> + <div className="e-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px' }}> <div> <label className="e-label">Scale</label> <input @@ -1806,6 +1812,30 @@ export const PropertiesPanel: React.FC = () => { onChange={(e) => handleChange('layer', e.target.value, true)} /> </div> + <div> + <label className="e-label">Parallax</label> + <input + type="number" + step="0.1" + className="e-input" + value={formatPanelNumber(obj.parallax ?? 1)} + onChange={(e) => { + const val = parseFloat(e.target.value); + const newP = isNaN(val) ? 1.0 : val; + const oldP = obj.parallax !== undefined ? obj.parallax : 1.0; + const scene = game.sceneManager.currentScene; + if (scene && obj.poly?.length) { + const dx = scene.camera.x * (newP - oldP); + const dy = scene.camera.y * (newP - oldP); + obj.poly = obj.poly.map((pt: any) => ({ + x: Math.round(pt.x + dx), + y: Math.round(pt.y + dy), + })); + } + handleChange('parallax', newP, true); + }} + /> + </div> </div> </> )} diff --git a/src/entities/Triggerbox.ts b/src/entities/Triggerbox.ts index e12823d..efc5a99 100644 --- a/src/entities/Triggerbox.ts +++ b/src/entities/Triggerbox.ts @@ -4,16 +4,22 @@ import { normalizeTriggerComponents, type AnyTriggerComponent } from './TriggerC export class Triggerbox extends PolygonObject { script: string; components: AnyTriggerComponent[]; + parallax: number; /** * List of properties to be serialized to/from JSON. */ - static override SERIALIZABLE_PROPS: string[] = [...PolygonObject.SERIALIZABLE_PROPS, 'script']; + static override SERIALIZABLE_PROPS: string[] = [ + ...PolygonObject.SERIALIZABLE_PROPS, + 'script', + 'parallax', + ]; constructor(poly: { x: number; y: number }[], name: string = 'Triggerbox', script: string = '') { super(poly, name, 'Triggerbox'); this.script = script; this.components = []; + this.parallax = 1.0; } toJSON(): any { diff --git a/src/graphics/SceneRenderer.ts b/src/graphics/SceneRenderer.ts index 7f6d119..28483f1 100644 --- a/src/graphics/SceneRenderer.ts +++ b/src/graphics/SceneRenderer.ts @@ -342,7 +342,7 @@ export class SceneRenderer { const halfW = ctx.canvas.width / 2; const halfH = ctx.canvas.height / 2; - const p = 1.0; // Debug shapes usually 1.0 parallax? Walkboxes are on floor, maybe 1.0. + const p = (obj as any).parallax !== undefined ? (obj as any).parallax : 1.0; ctx.save(); ctx.translate(halfW, halfH); diff --git a/src/scene/SceneInteraction.ts b/src/scene/SceneInteraction.ts index 5605453..2fa9122 100644 --- a/src/scene/SceneInteraction.ts +++ b/src/scene/SceneInteraction.ts @@ -66,8 +66,8 @@ function isHitAtScreenPoint( } const worldPos = { - x: (screenX - halfW) / zoom + camX, - y: (screenY - halfH) / zoom + camY, + x: (screenX - halfW) / zoom + camX * (((obj as any).parallax !== undefined ? (obj as any).parallax : 1.0)), + y: (screenY - halfH) / zoom + camY * (((obj as any).parallax !== undefined ? (obj as any).parallax : 1.0)), }; return obj.hitTest(worldPos.x, worldPos.y); } diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index d7cd28e..f0e0f33 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -1052,9 +1052,10 @@ export class SceneEditor { ctx.restore(); } else if (selected instanceof Walkbox || selected instanceof Triggerbox) { // Triggerbox/Walkbox + const p = (selected as any).parallax !== undefined ? (selected as any).parallax : 1.0; ctx.translate(halfW, halfH); ctx.scale(zoom, zoom); - ctx.translate(-camX, -camY); + ctx.translate(-camX * p, -camY * p); const poly = selected.poly; diff --git a/src/tools/editor/EditorTransformManager.ts b/src/tools/editor/EditorTransformManager.ts index 319d85a..9c69979 100644 --- a/src/tools/editor/EditorTransformManager.ts +++ b/src/tools/editor/EditorTransformManager.ts @@ -107,14 +107,14 @@ export class EditorTransformManager { if (entity.hitTest(worldX, worldY)) return entity; } - const worldPos = { - x: (pos.x - halfW) / zoom + camX, - y: (pos.y - halfH) / zoom + camY, - }; - if (scene.walkbox) { for (const wb of scene.walkbox) { if (wb.disabled || wb.locked) continue; + const p = (wb as any).parallax !== undefined ? (wb as any).parallax : 1.0; + const worldPos = { + x: (pos.x - halfW) / zoom + camX * p, + y: (pos.y - halfH) / zoom + camY * p, + }; if (Geometry.isPointInPolygon(worldPos, wb.poly)) return wb; } } @@ -122,6 +122,11 @@ export class EditorTransformManager { if (scene.triggerboxes) { for (const tb of scene.triggerboxes) { if (tb.disabled || tb.locked) continue; + const p = (tb as any).parallax !== undefined ? (tb as any).parallax : 1.0; + const worldPos = { + x: (pos.x - halfW) / zoom + camX * p, + y: (pos.y - halfH) / zoom + camY * p, + }; if (Geometry.isPointInPolygon(worldPos, tb.poly)) return tb; } } @@ -139,9 +144,9 @@ export class EditorTransformManager { halfH: number ): SceneObject[] { const selected: SceneObject[] = []; - const toScreen = (wx: number, wy: number) => ({ - x: (wx - camX) * zoom + halfW, - y: (wy - camY) * zoom + halfH, + const toScreen = (wx: number, wy: number, parallax: number = 1.0) => ({ + x: (wx - camX * parallax) * zoom + halfW, + y: (wy - camY * parallax) * zoom + halfH, }); (scene.entities || []).forEach((entity: any) => { @@ -174,7 +179,8 @@ export class EditorTransformManager { (scene.walkbox || []).forEach((wb: any) => { if (wb.disabled || wb.locked || !wb.poly?.length) return; - const screenPoly = wb.poly.map((p: any) => toScreen(p.x, p.y)); + const p = wb.parallax !== undefined ? wb.parallax : 1.0; + const screenPoly = wb.poly.map((pt: any) => toScreen(pt.x, pt.y, p)); const minX = Math.min(...screenPoly.map((p: any) => p.x)); const maxX = Math.max(...screenPoly.map((p: any) => p.x)); const minY = Math.min(...screenPoly.map((p: any) => p.y)); @@ -184,7 +190,8 @@ export class EditorTransformManager { (scene.triggerboxes || []).forEach((tb: any) => { if (tb.disabled || tb.locked || !tb.poly?.length) return; - const screenPoly = tb.poly.map((p: any) => toScreen(p.x, p.y)); + const p = tb.parallax !== undefined ? tb.parallax : 1.0; + const screenPoly = tb.poly.map((pt: any) => toScreen(pt.x, pt.y, p)); const minX = Math.min(...screenPoly.map((p: any) => p.x)); const maxX = Math.max(...screenPoly.map((p: any) => p.x)); const minY = Math.min(...screenPoly.map((p: any) => p.y)); @@ -290,6 +297,11 @@ export class EditorTransformManager { poly = (editor.selectedObject as any).poly; } + const selectedParallax = + (editor.selectedObject as any).parallax !== undefined + ? (editor.selectedObject as any).parallax + : 1.0; + const vertexRadius = 6 / zoom; // Hit radius // Calculate Centroid... @@ -306,8 +318,8 @@ export class EditorTransformManager { // Check vertices const worldPos = { - x: (pos.x - halfW) / zoom + camX, - y: (pos.y - halfH) / zoom + camY, + x: (pos.x - halfW) / zoom + camX * selectedParallax, + y: (pos.y - halfH) / zoom + camY * selectedParallax, }; for (let i = 0; i < poly.length; i++) { @@ -469,15 +481,15 @@ export class EditorTransformManager { } // 2. Check Walkboxes - const worldPos = { - x: (pos.x - halfW) / zoom + camX, - y: (pos.y - halfH) / zoom + camY, - }; - if (scene.walkbox) { for (const wb of scene.walkbox) { if (wb.disabled) continue; if (wb.locked) continue; + const p = (wb as any).parallax !== undefined ? (wb as any).parallax : 1.0; + const worldPos = { + x: (pos.x - halfW) / zoom + camX * p, + y: (pos.y - halfH) / zoom + camY * p, + }; if (Geometry.isPointInPolygon(worldPos, wb.poly)) { this.editor.selectObject(wb); e.stopPropagation(); @@ -491,6 +503,11 @@ export class EditorTransformManager { for (const tb of scene.triggerboxes) { if (tb.disabled) continue; if (tb.locked) continue; + const p = (tb as any).parallax !== undefined ? (tb as any).parallax : 1.0; + const worldPos = { + x: (pos.x - halfW) / zoom + camX * p, + y: (pos.y - halfH) / zoom + camY * p, + }; if (Geometry.isPointInPolygon(worldPos, tb.poly)) { this.editor.selectObject(tb); e.stopPropagation(); @@ -579,9 +596,13 @@ export class EditorTransformManager { editor.selectedObject instanceof Triggerbox || (editor.selectedObject as any).type === 'Quad' ) { + const selectedParallax = + (editor.selectedObject as any).parallax !== undefined + ? (editor.selectedObject as any).parallax + : 1.0; const worldPos = { - x: (pos.x - halfW) / zoom + camX, - y: (pos.y - halfH) / zoom + camY, + x: (pos.x - halfW) / zoom + camX * selectedParallax, + y: (pos.y - halfH) / zoom + camY * selectedParallax, }; let poly: any; From 8bc8a6dbd619b004119a37f523f1a501c30bef16 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Sun, 22 Mar 2026 23:10:48 +0200 Subject: [PATCH 68/75] Feature: add descriptive tooltips to properties labels --- src/components/editor/PropertiesPanel.tsx | 166 +++++++++++++++++++++- src/editor.css | 8 ++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/src/components/editor/PropertiesPanel.tsx b/src/components/editor/PropertiesPanel.tsx index ae2356b..62c24bd 100644 --- a/src/components/editor/PropertiesPanel.tsx +++ b/src/components/editor/PropertiesPanel.tsx @@ -6,6 +6,139 @@ import { QuadObject } from '../../entities/QuadObject'; import { Entity } from '../../entities/Entity'; import { Triggerbox } from '../../entities/Triggerbox'; +const PROPERTIES_LABEL_TOOLTIPS: Record<string, string> = { + 'Group #ID': + 'Comma-separated tags used to address this object or selection from triggers, switches, scripts, and subscenes.', + Parent: + 'Chooses the direct spatial parent of this object. The object will be treated as attached to that parent instead of the root scene.', + Relation: + 'Defines how this object is attached to its parent in the spatial hierarchy: in, on, under, or behind.', + 'Group X': + 'Moves the whole selected group horizontally while preserving the relative layout between the selected objects.', + 'Group Y': + 'Moves the whole selected group vertically while preserving the relative layout between the selected objects.', + 'Group Scale': + 'Scales the whole selected group around its shared center while keeping the objects aligned with each other.', + X: 'Horizontal position in scene space.', + Y: 'Vertical position in scene space.', + 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.', + 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.', + 'Collider H': + 'Collision height used for walkbox and obstacle interaction. Set to 0 to make the object non-blocking.', + 'Collider W': + 'Collision width used for walkbox and obstacle interaction. Set to 0 to make the object non-blocking.', + 'Disable Depth-scaling': + 'Keeps the object at a fixed visual size instead of letting the scene depth-scaling system resize it by Y position.', + 'Fill Color': + 'Base fill color for the object when no sprite is used, or the tint/fill color used by this visual mode.', + 'Blend Mode': + 'Canvas blend mode used to combine this object with the scene behind it.', + Opacity: + 'Visual transparency. 0% keeps the object fully opaque; 100% makes it invisible and excluded from rendering.', + Blur: 'Blur radius in pixels. 0 px is sharp; higher values make the object softer.', + Sprite: + 'Sprite asset used to render this object. Leave empty to keep the plain filled rectangle look.', + Mode: + 'Selects the behavior mode for this object or component.', + 'Depth Sort mode': + 'Chooses which quad rule is used for Y sorting, or disables Y sorting so layer order stays fully manual.', + 'Grid X': + 'Number of vertical subdivisions in the retro grid effect.', + 'Grid Y': + 'Number of horizontal subdivisions in the retro grid effect.', + Width: 'Line width or stroke width used by the current visual effect.', + 'Grid Color': 'Color used to draw the retro grid lines.', + ID: 'Unique identifier used by the engine, scripts, references, and file operations.', + 'ID/File': + 'Unique scene identifier and file path key. Slashes create subfolders when the scene is saved.', + Title: + 'Text-asset title shown to the player and used by the text layer as the friendly name for this object or scene.', + 'Key Item ID': + 'Inventory item ID required to unlock or activate this interaction.', + Description: 'Player-facing short description used by text interactions and subscene presentation.', + 'Target ID(s)': + 'One or more target group IDs or object IDs affected by this component or interaction.', + 'Target ID(s) (Optional)': + 'Optional target IDs affected by this component. Leave empty when the component should only provide auxiliary behavior.', + 'Target Trigger (Name/ID)': + 'Name or ID of the triggerbox that this helper area should activate as if it were clicked directly.', + 'Target(s) 1': + 'Targets used when the switch is in state 1, usually the closed or default state.', + 'Target(s) 2': + 'Targets used when the switch is in state 2, usually the open or alternate state.', + 'Sound 1': 'Sound played when the switch moves into state 1.', + 'Sound 2': 'Sound played when the switch moves into state 2.', + State: 'Current switch state used as the starting state in the editor and at runtime.', + 'Shadow Quad ID': + 'Quad that receives or shapes this shadow effect.', + 'Offset X': 'Horizontal offset applied by the component or effect.', + 'Offset Y': 'Vertical offset applied by the component or effect.', + 'Trigger ID(s) (Zone)': + 'Trigger IDs that enable, disable, or otherwise gate this component in specific zones.', + Axis: 'Axis constrained by the component or comparison rule.', + Op: 'Comparison operator used by the current component or condition.', + 'Culling Type': + 'Chooses how the object is culled or hidden when it falls outside the active visibility rule.', + 'Vert A (0-3)': 'First quad vertex index used by this link or rule.', + 'Vert B (0-3)': 'Second quad vertex index used by this link or rule.', + Direction: 'Default facing direction for the actor.', + 'Move Speed': 'Actor movement speed in scene units per step.', + 'Anim Speed (ms)': 'Frame duration for sprite animation playback, in milliseconds.', + 'Cam X': 'Current camera X position in scene space.', + 'Cam Y': 'Current camera Y position in scene space.', + Zoom: 'Current scene camera zoom.', + 'Auto-Center on Player': + 'Automatically keeps the camera centered on the player instead of relying on manual camera values.', + 'Cam Spd': 'Camera follow speed when auto-centering or camera tracking is active.', + 'Dead X': 'Horizontal deadzone before camera follow begins.', + 'Dead Y': 'Vertical deadzone before camera follow begins.', + 'Min X': 'Minimum allowed X value for this camera range.', + 'Max X': 'Maximum allowed X value for this camera range.', + 'Min Y': 'Minimum allowed Y value for this camera range.', + 'Max Y': 'Maximum allowed Y value for this camera range.', + 'Def X': 'Default camera X used when the scene loads or resets.', + 'Def Y': 'Default camera Y used when the scene loads or resets.', + 'Def Zoom': 'Default camera zoom used when the scene loads or resets.', + 'Enable Depth Scaling': + 'Turns scene depth scaling on or off so objects can grow or shrink according to their Y position.', + Min: 'Minimum depth-scaling factor used at the horizon end of the scene.', + Max: 'Maximum depth-scaling factor used at the front end of the scene.', + 'Horizon Y': 'Y coordinate treated as the horizon for depth scaling.', + 'Front Y': 'Y coordinate treated as the foreground limit for depth scaling.', + 'UI Scale': 'Editor interface scale multiplier.', + Curvature: 'Strength of the CRT screen curvature effect.', + Vignette: 'Darkening applied toward the screen edges.', + 'Scanline Count': 'Number of scanlines used by the CRT filter.', + 'Scanline Intensity': 'Visibility strength of the CRT scanlines.', + 'RGB Split': 'Amount of RGB channel separation in the CRT effect.', + Bloom: 'Glow intensity added by the CRT effect.', + 'Phosphor / Grain': 'Amount of phosphor persistence and grain noise.', + 'Enable CRT Filter': 'Turns the CRT post-processing effect on or off.', + 'Bezel Glow': 'Adds a glow around the virtual CRT bezel.', + 'Lock Object': 'Prevents accidental editing of this object in the editor. Hotkey: Alt-L.', + Disabled: 'Disables the object so it does not participate in the scene. Hotkey: Alt-D.', + 'Retro Grid': + 'Enables the retro grid line overlay for this quad. It is also useful for alignment, because objects can snap to grid nodes while Alt is held.', +}; + +const normalizeTooltipLabelText = (rawText: string): string => { + const text = rawText.replace(/\s+/g, ' ').trim(); + if (!text) return ''; + if (text.startsWith('Opacity')) return 'Opacity'; + if (text.startsWith('Blur')) return 'Blur'; + if (text.startsWith('UI Scale')) return 'UI Scale'; + if (text.startsWith('State')) return 'State'; + if (text.startsWith('Mode:')) return 'Mode'; + if (text === 'Disable Depth Scaling') return 'Disable Depth-scaling'; + return text; +}; + export const PropertiesPanel: React.FC = () => { const game = useGame(); const { @@ -211,6 +344,31 @@ export const PropertiesPanel: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [isPanelTextEntryFocused, scrollToSection]); + React.useEffect(() => { + const panel = panelRef.current; + if (!panel) return; + + const labels = panel.querySelectorAll('label.e-label'); + labels.forEach((node) => { + const label = node as HTMLLabelElement; + if (label.dataset.tooltipFixed === 'true') { + label.classList.add('e-tooltip-label'); + return; + } + + const normalized = normalizeTooltipLabelText(label.textContent || ''); + const tooltip = PROPERTIES_LABEL_TOOLTIPS[normalized]; + if (!tooltip) { + label.removeAttribute('title'); + label.classList.remove('e-tooltip-label'); + return; + } + + label.title = tooltip; + label.classList.add('e-tooltip-label'); + }); + }); + const getPolyCentroid = React.useCallback((poly: { x: number; y: number }[] = []) => { if (!poly.length) return { x: 0, y: 0 }; const sum = poly.reduce((acc, pt) => ({ x: acc.x + pt.x, y: acc.y + pt.y }), { x: 0, y: 0 }); @@ -693,7 +851,13 @@ export const PropertiesPanel: React.FC = () => { 'neutral', <> <div className="e-row"> - <label className="e-label">Group #ID</label> + <label + className="e-label" + data-tooltip-fixed="true" + title="Use this field to add or remove group #ID tags for all selected objects at once." + > + Group #ID + </label> <div className="e-label ui-text-muted ui-text-small"> (<Enter> = append, <Ctrl+Enter> = remove) </div> diff --git a/src/editor.css b/src/editor.css index c22be23..9e600ac 100644 --- a/src/editor.css +++ b/src/editor.css @@ -179,6 +179,14 @@ margin-bottom: 2px; } +.e-tooltip-label { + cursor: help; +} + +.e-tooltip-label:hover { + color: var(--ui-main-color); +} + .properties-section-block { margin-bottom: 12px; scroll-margin-top: 8px; From 75fd48143c100b41847ed66c1a69df9f99a842bd Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 23 Mar 2026 00:52:06 +0200 Subject: [PATCH 69/75] Feature: make editor bottom menu modifier-aware --- progress.md | 5 + src/components/editor/EditorBottomMenu.tsx | 182 +++++++++++++++++++-- src/editor.css | 14 ++ src/tools/SceneEditor.ts | 2 +- 4 files changed, 190 insertions(+), 13 deletions(-) diff --git a/progress.md b/progress.md index 1cf1d40..5865e6a 100644 --- a/progress.md +++ b/progress.md @@ -24,3 +24,8 @@ Original prompt: Давай временно переключимся с пар - Budgets scene cache подняты в 3 раза; image cache получил отдельный budget по device class. - В `GDD.md` в конец раздела `Техническая реализация` добавлено описание profiler и примеры вызова через `window.__QUEST_DEBUG__`. - Проверка browser loop: skill client не запустился, потому что в окружении нет пакета 'playwright'; для smoke test использован встроенный browser MCP. + +- New task: make editor bottom menu modifier-aware (Ctrl/Alt/Shift), with dynamic labels such as D ENABLE/DISABLE based on selected object state. +- Implemented state-driven EditorBottomMenu with modifier modes; added Alt/Shift Save As path and Ctrl action set; pending browser smoke verification. + +- Verified in browser: bottom menu now switches between base / Alt / Ctrl / Shift layouts, and Alt mode updates D from Disable to Enable after toggling selected object disabled state.\n diff --git a/src/components/editor/EditorBottomMenu.tsx b/src/components/editor/EditorBottomMenu.tsx index 06b17f2..2ec8f53 100644 --- a/src/components/editor/EditorBottomMenu.tsx +++ b/src/components/editor/EditorBottomMenu.tsx @@ -2,13 +2,31 @@ import React from 'react'; import { useGame } from '../../hooks/useGame'; import { useEditorStore } from '../../store/editorStore'; +type ModifierMode = 'base' | 'shift' | 'alt' | 'ctrl'; + +type MenuEntry = { + hotkey: string; + text: string; + action: string; + enabled?: boolean; +}; + export const EditorBottomMenu: React.FC = () => { const game = useGame(); - const { toggle, toggleSpriteEditor } = useEditorStore(); + const { + enabled: editorEnabled, + toggle, + toggleSpriteEditor, + selectedObjectId, + selectedObjectType, + selectedObjectKeys, + objectVersion, + } = useEditorStore(); const [fps, setFps] = React.useState(0); const [sceneMem, setSceneMem] = React.useState(0); const [sceneCount, setSceneCount] = React.useState(0); + const [modifierMode, setModifierMode] = React.useState<ModifierMode>('base'); React.useEffect(() => { const interval = setInterval(() => { @@ -20,6 +38,74 @@ export const EditorBottomMenu: React.FC = () => { return () => clearInterval(interval); }, [game]); + React.useEffect(() => { + const getModifierMode = (e?: KeyboardEvent): ModifierMode => { + const altGraph = !!e?.getModifierState?.('AltGraph') || e?.key === 'AltGraph'; + const ctrl = !!e?.ctrlKey; + const alt = !!e?.altKey; + const shift = !!e?.shiftKey; + if (altGraph) return 'alt'; + if (ctrl && !alt) return 'ctrl'; + if (alt) return 'alt'; + if (shift) return 'shift'; + return 'base'; + }; + + const syncModifierMode = (e?: KeyboardEvent) => { + setModifierMode(getModifierMode(e)); + }; + + const isMenuModifierKey = (e: KeyboardEvent) => + e.key === 'Alt' || e.key === 'AltGraph' || e.code === 'AltLeft' || e.code === 'AltRight'; + + const handleKeyDown = (e: KeyboardEvent) => { + if (editorEnabled && isMenuModifierKey(e)) { + e.preventDefault(); + } + syncModifierMode(e); + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (editorEnabled && isMenuModifierKey(e)) { + e.preventDefault(); + } + syncModifierMode(e); + }; + + const handleBlur = () => setModifierMode('base'); + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + window.addEventListener('blur', handleBlur); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + window.removeEventListener('blur', handleBlur); + }; + }, [editorEnabled]); + + const selectedObject = game.editor?.selectedObject || null; + const hasObjectSelection = + (selectedObjectType === 'MULTI' && + Array.isArray(selectedObjectKeys) && + selectedObjectKeys.length > 0) || + (!!selectedObject && selectedObjectType !== 'SCENE' && selectedObjectType !== 'SETTINGS'); + const hasSingleSelectedObject = + !!selectedObject && selectedObjectType !== 'SCENE' && selectedObjectType !== 'SETTINGS'; + void selectedObjectId; + void objectVersion; + + const lockActionText = hasSingleSelectedObject + ? selectedObject.locked + ? 'Unlock' + : 'Lock' + : 'Lock'; + const disableActionText = hasSingleSelectedObject + ? selectedObject.disabled + ? 'Enable' + : 'Disable' + : 'Disable'; + const handleAction = (action: string) => { const editor = game.editor; @@ -30,7 +116,7 @@ export const EditorBottomMenu: React.FC = () => { case 'F2': editor.saveScene(false); break; - case 'ShiftF2': + case 'SaveAs': editor.saveScene(true); break; case 'F3': @@ -45,25 +131,97 @@ export const EditorBottomMenu: React.FC = () => { case 'F9': editor.selectObject('SETTINGS'); break; + case 'Undo': + editor.undo(); + break; + case 'Redo': + editor.redo(); + break; + case 'Duplicate': + editor.duplicateSelectedObject(); + break; + case 'Copy': + editor.copySelectedObjectToClipboard(); + break; + case 'Paste': + void editor.pasteObjectFromClipboard(); + break; + case 'SaveSelection': + editor.persistenceManager.saveObject(); + break; + case 'LoadSelection': + editor.persistenceManager.loadObject('cursor'); + break; + case 'ToggleLock': + if (editor.selectedObject) { + editor.selectedObject.locked = !editor.selectedObject.locked; + useEditorStore.getState().incrementObjectVersion(); + useEditorStore.getState().incrementHierarchyVersion(); + } + break; + case 'ToggleDisabled': + if (editor.selectedObject) { + editor.selectedObject.disabled = !editor.selectedObject.disabled; + useEditorStore.getState().incrementObjectVersion(); + useEditorStore.getState().incrementHierarchyVersion(); + } + break; } }; - const keys = [ - { label: 'F1 Game', action: 'F1' }, - { label: 'F2 Save', action: 'F2' }, - { label: 'F3 Load', action: 'F3' }, - { label: 'F4 New', action: 'F4' }, - { label: 'F5 Sprite', action: 'F5' }, - { label: 'F9 Settings', action: 'F9' }, + const baseKeys: MenuEntry[] = [ + { hotkey: 'F1', text: 'Game', action: 'F1' }, + { hotkey: 'F2', text: 'Save', action: 'F2' }, + { hotkey: 'F3', text: 'Load', action: 'F3' }, + { hotkey: 'F4', text: 'New', action: 'F4' }, + { hotkey: 'F5', text: 'Sprite', action: 'F5' }, + { hotkey: 'F9', text: 'Settings', action: 'F9' }, + ]; + + const shiftKeys: MenuEntry[] = [{ hotkey: 'F2', text: 'Save As', action: 'SaveAs' }]; + + const altKeys: MenuEntry[] = [ + { hotkey: 'F2', text: 'Save As', action: 'SaveAs' }, + { hotkey: 'L', text: lockActionText, action: 'ToggleLock', enabled: hasSingleSelectedObject }, + { + hotkey: 'D', + text: disableActionText, + action: 'ToggleDisabled', + enabled: hasSingleSelectedObject, + }, ]; + const ctrlKeys: MenuEntry[] = [ + { hotkey: 'Z', text: 'Undo', action: 'Undo' }, + { hotkey: 'Y', text: 'Redo', action: 'Redo' }, + { hotkey: 'D', text: 'Duplicate', action: 'Duplicate', enabled: hasObjectSelection }, + { hotkey: 'C', text: 'Copy', action: 'Copy', enabled: hasObjectSelection }, + { hotkey: 'V', text: 'Paste', action: 'Paste', enabled: true }, + { hotkey: 'S', text: 'Save Prefab', action: 'SaveSelection', enabled: hasObjectSelection }, + { hotkey: 'O', text: 'Load Prefab', action: 'LoadSelection', enabled: true }, + ]; + + const keys = + modifierMode === 'ctrl' + ? ctrlKeys + : modifierMode === 'alt' + ? altKeys + : modifierMode === 'shift' + ? shiftKeys + : baseKeys; + return ( <div className="editor-bottom-menu" style={{ zIndex: 2000 }}> <div className="mem-counter">{`MEM ${sceneMem} | ${sceneCount}`}</div> {keys.map((k) => ( - <button key={k.label} className="e-menu-btn" onClick={() => handleAction(k.action)}> - <span className="hotkey-accent">{k.label.split(' ')[0]}</span> - {k.label.split(' ').slice(1).join(' ')} + <button + key={`${modifierMode}-${k.hotkey}-${k.text}`} + className="e-menu-btn" + onClick={() => handleAction(k.action)} + disabled={k.enabled === false} + > + <span className="hotkey-accent">{k.hotkey}</span> + {k.text} </button> ))} <div className="fps-counter">FPS: {fps}</div> diff --git a/src/editor.css b/src/editor.css index 9e600ac..c30bb48 100644 --- a/src/editor.css +++ b/src/editor.css @@ -346,6 +346,20 @@ /* reset on hover */ } +.e-menu-btn:disabled { + opacity: 0.45; + cursor: default; +} + +.e-menu-btn:disabled:hover { + background: transparent; + color: var(--ui-fkey-text); +} + +.e-menu-btn:disabled:hover .hotkey-accent { + color: var(--ui-fkey-hotkey); +} + .fps-counter { position: absolute; right: 10px; diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index f0e0f33..df6d2e9 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -317,7 +317,7 @@ export class SceneEditor { switch (e.key.toLowerCase()) { case 'f2': e.preventDefault(); - if (e.shiftKey) + if (e.shiftKey || e.altKey) this.persistenceManager.saveScene(true); // Save As else this.persistenceManager.saveScene(false); // Quick Save break; From 05106027a1fd207f4305dba7bcc8f7b95da2f133 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 23 Mar 2026 03:35:57 +0200 Subject: [PATCH 70/75] Feature: add selection slot actions to shift menu --- src/components/editor/EditorBottomMenu.tsx | 12 +- src/tools/SceneEditor.ts | 132 ++++++++++++++++++++- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/components/editor/EditorBottomMenu.tsx b/src/components/editor/EditorBottomMenu.tsx index 2ec8f53..26f78bf 100644 --- a/src/components/editor/EditorBottomMenu.tsx +++ b/src/components/editor/EditorBottomMenu.tsx @@ -119,6 +119,12 @@ export const EditorBottomMenu: React.FC = () => { case 'SaveAs': editor.saveScene(true); break; + case 'SaveSelectionSlot1': + editor.saveSelectionSlot(0); + break; + case 'SaveSelectionSlot2': + editor.saveSelectionSlot(1); + break; case 'F3': editor.promptLoadScene(); break; @@ -178,7 +184,11 @@ export const EditorBottomMenu: React.FC = () => { { hotkey: 'F9', text: 'Settings', action: 'F9' }, ]; - const shiftKeys: MenuEntry[] = [{ hotkey: 'F2', text: 'Save As', action: 'SaveAs' }]; + const shiftKeys: MenuEntry[] = [ + { hotkey: 'F2', text: 'Save As', action: 'SaveAs' }, + { hotkey: '1', text: 'Save Sel. 1', action: 'SaveSelectionSlot1', enabled: hasObjectSelection }, + { hotkey: '2', text: 'Save Sel. 2', action: 'SaveSelectionSlot2', enabled: hasObjectSelection }, + ]; const altKeys: MenuEntry[] = [ { hotkey: 'F2', text: 'Save As', action: 'SaveAs' }, diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index df6d2e9..4e90b90 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -23,6 +23,7 @@ export class SceneEditor { game: any; enabled: boolean; + selectionSlots: Array<string[] | null>; // State Properties get selectedObject(): SceneObject | null { return this.selectionManager.selectedObject; @@ -31,6 +32,7 @@ export class SceneEditor { this.selectionManager.selectedObject = val; } lastMousePos: { x: number; y: number }; + lastClientMousePos: { x: number; y: number }; // Callbacks // Refactored: Use this.game.openFileBrowser instead of local property @@ -51,9 +53,11 @@ export class SceneEditor { this.persistenceManager = new EditorPersistenceManager(this); this.ui = new EditorUI(this); this.enabled = false; + this.selectionSlots = [null, null]; this.selectionManager.selectedObject = null; this.lastMousePos = { x: 0, y: 0 }; + this.lastClientMousePos = { x: 0, y: 0 }; // Bind handlers once for cleanup @@ -128,14 +132,17 @@ export class SceneEditor { } handleGlobalKey(e: KeyboardEvent): void { + const isTypingInField = + document.activeElement instanceof HTMLInputElement || + document.activeElement instanceof HTMLTextAreaElement; + if ( this.enabled && e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && - !(document.activeElement instanceof HTMLInputElement) && - !(document.activeElement instanceof HTMLTextAreaElement) + !isTypingInField ) { const filterInput = document.getElementById( 'hierarchy-filter-input' @@ -258,6 +265,24 @@ export class SceneEditor { // Allows opening editor with F1 or F5 even if disabled if (!this.enabled && e.key !== 'F1' && e.key !== 'F5') return; + if ( + this.enabled && + !isTypingInField && + !e.ctrlKey && + !e.metaKey && + (e.code === 'Digit1' || e.code === 'Digit2') && + this.isSelectionSlotHotkeyContext() + ) { + const slotIndex = e.code === 'Digit1' ? 0 : 1; + e.preventDefault(); + if (e.shiftKey) { + this.saveSelectionSlot(slotIndex); + } else if (!e.altKey) { + this.restoreSelectionSlot(slotIndex); + } + return; + } + // F1: Toggle Scene Editor if (e.key === 'F1') { e.preventDefault(); @@ -552,6 +577,7 @@ export class SceneEditor { onMouseMove(e: MouseEvent): void { this.lastMousePos = this.getMousePos(e); + this.lastClientMousePos = { x: e.clientX, y: e.clientY }; this.transformManager.onMouseMove(e); } @@ -579,6 +605,108 @@ export class SceneEditor { return this.selectionManager.getSelectedObjects(); } + private getObjectKey(obj: any): string | null { + if (!obj) return null; + if (obj.type === 'Quad') return `Quad:${obj.name}`; + if (obj instanceof Actor) return `Actor:${obj.name}`; + if (obj instanceof Entity) return `Entity:${obj.name}`; + if (obj instanceof Walkbox) return `Walkbox:${obj.name || 'Walkbox'}`; + if (obj instanceof Triggerbox) return `Triggerbox:${obj.name || 'Triggerbox'}`; + return null; + } + + private findObjectBySelectionKey(key: string): SceneObject | null { + const scene = this.game.sceneManager.currentScene; + if (!scene || !key) return null; + + const sep = key.indexOf(':'); + const type = sep >= 0 ? key.slice(0, sep) : ''; + const name = sep >= 0 ? key.slice(sep + 1) : key; + if (!type || !name) return null; + + if (type === 'Entity' || type === 'Actor') { + return (scene.entities || []).find((obj: any) => obj?.name === name) || null; + } + if (type === 'Walkbox') { + return (scene.walkbox || []).find((obj: any) => obj?.name === name) || null; + } + if (type === 'Triggerbox') { + return (scene.triggerboxes || []).find((obj: any) => obj?.name === name) || null; + } + if (type === 'Quad') { + return ( + (scene.entities || []).find((obj: any) => obj?.type === 'Quad' && obj?.name === name) || + null + ); + } + + return null; + } + + private getCurrentObjectSelectionKeys(): string[] { + if (this.selectionManager.hasMultiSelection()) { + return this.selectionManager + .getSelectedObjects() + .map((obj) => this.getObjectKey(obj)) + .filter((key): key is string => !!key); + } + + const key = this.getObjectKey(this.selectedObject); + return key ? [key] : []; + } + + private isSelectionSlotHotkeyContext(): boolean { + const { x, y } = this.lastClientMousePos; + if (!Number.isFinite(x) || !Number.isFinite(y)) return false; + const hovered = document.elementFromPoint(x, y) as HTMLElement | null; + if (!hovered) return false; + if (hovered.closest('#hierarchy-panel')) return true; + if (this.game.canvas?.contains(hovered) || hovered === this.game.canvas) return true; + return false; + } + + saveSelectionSlot(slotIndex: number): void { + const keys = this.getCurrentObjectSelectionKeys(); + const slotNumber = slotIndex + 1; + if (!keys.length) { + this.game.showNotification(`Nothing selected to save in slot ${slotNumber}`); + return; + } + + this.selectionSlots[slotIndex] = [...keys]; + const label = keys.length === 1 ? 'object' : 'objects'; + this.game.showNotification(`Saved ${keys.length} ${label} to selection slot ${slotNumber}`); + } + + restoreSelectionSlot(slotIndex: number): void { + const slotNumber = slotIndex + 1; + const stored = this.selectionSlots[slotIndex]; + if (!stored || stored.length === 0) { + this.game.showNotification(`Selection slot ${slotNumber} is empty`); + return; + } + + const objects = stored + .map((key) => this.findObjectBySelectionKey(key)) + .filter((obj): obj is SceneObject => !!obj); + + if (!objects.length) { + this.game.showNotification(`Saved selection in slot ${slotNumber} is no longer available`); + return; + } + + if (objects.length === 1) { + this.selectObject(objects[0]); + } else { + this.setMultiSelection(objects); + } + + const label = objects.length === 1 ? 'object' : 'objects'; + this.game.showNotification( + `Restored ${objects.length} ${label} from selection slot ${slotNumber}` + ); + } + newScene(): void { const newScene = new Scene(this.game, 'new_scene', 'New Scene'); // Add default scale From a75c22c4b3bbd5713fa4882ca35cf8bd70268ddb Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 23 Mar 2026 03:40:16 +0200 Subject: [PATCH 71/75] Docs: document editor selection slots --- GDD.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/GDD.md b/GDD.md index 321e140..3f18bc4 100644 --- a/GDD.md +++ b/GDD.md @@ -963,7 +963,17 @@ export function registerUserScripts() { Визуально это oldschool кнопки с F-клавишами в стиле Norton Commander: F1 Game F2 Save F3 Load F4 New F5 Sprite Edit F9 Settings -При нажатии Shift: F2 Save As... +Нижнее меню является **динамическим smart-меню**: + +- в обычном состоянии оно показывает базовые F-клавиши редактора; +- при удержании модификаторов (`Shift`, `Alt`, `Ctrl`) оно временно перестраивается и показывает набор горячих клавиш, доступных с этим модификатором; +- подписи в таком режиме могут зависеть от текущего состояния редактора. Например, при удержании `Alt` для выделенного объекта может показываться `D Disable` или `D Enable` в зависимости от того, отключён объект или нет. + +Пример: + +- при нажатии `Shift`: `F2 Save As...`, а также `1 Save Sel. 1` и `2 Save Sel. 2` для сохранения текущего выделения в два быстрых selection slots +- при нажатии `Alt`: `F2 Save As...`, а также контекстные действия вроде `L Lock/Unlock` и `D Disable/Enable` +- при нажатии `Ctrl`: операции редактирования вроде `Undo`, `Redo`, `Duplicate`, `Copy`, `Paste`, `Save Prefab`, `Load Prefab` Пользователь может нажимать на них мышкой или использовать клавиатурные сочетания. @@ -1078,10 +1088,16 @@ Prefab можно загрузить в текущую сцену из файл | **Ctrl + V** | Paste | Вставить объект/группу из буфера в позицию курсора (или в центр вида) | | **Ctrl + S** | Save Prefab | Сохранить текущее выделение (single/group) в файл Prefab | | **Ctrl + O** | Load Prefab | Загрузить prefab и вставить в позицию курсора (или в центр вида) | +| **Shift + 1** | Save Sel. 1 | Сохранить текущее single/group выделение в быстрый слот 1, если курсор над канвасом или Hierarchy | +| **Shift + 2** | Save Sel. 2 | Сохранить текущее single/group выделение в быстрый слот 2, если курсор над канвасом или Hierarchy | +| **1** | Restore Sel. 1 | Восстановить сохранённое выделение из быстрого слота 1, если курсор над канвасом или Hierarchy | +| **2** | Restore Sel. 2 | Восстановить сохранённое выделение из быстрого слота 2, если курсор над канвасом или Hierarchy | | **Insert** | Assign Sprite | Назначить спрайт выбранному объекту | | **Space** | Select Scene | Выбрать настройки сцены (и снять выделение с объектов), если курсор над канвасом | | **LMB Drag (empty area)** | Marquee Select | Выделить несколько объектов прямоугольной областью | +Быстрые selection slots запоминают именно текущий набор выделенных объектов по их ID в текущей сцене. Если слот пуст, или сохранённые объекты больше не существуют, редактор показывает пользователю Toast Notification. + ### 3. Создание объектов (Creation) | Клавиша | Действие | Описание | From 81f99bb3954f27e7a4534de76c53440337b1d668 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 23 Mar 2026 12:18:51 +0200 Subject: [PATCH 72/75] Fix: clear removed items from active subscenes --- src/scene/Scene.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 0308865..0fe4d51 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -132,7 +132,9 @@ export class Scene { this._activeSubscene = value; } - private normalizeSpatialPlacement(value: SpatialPlacement | undefined | null): SpatialPlacement | null { + private normalizeSpatialPlacement( + value: SpatialPlacement | undefined | null + ): SpatialPlacement | null { if (!value) return null; const parentNodeId = typeof value.parentNodeId === 'string' ? value.parentNodeId.trim() : ''; const relation = @@ -293,6 +295,9 @@ export class Scene { const index = this.entities.indexOf(entity); if (index > -1) { this.entities.splice(index, 1); + if (this.subsceneEntities.has(entity)) { + this.subsceneEntities.delete(entity); + } if (this.player === entity) { this.player = null; } From cd6da93d6733e00fba1ca7ca5977a04a97f02462 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 23 Mar 2026 12:49:44 +0200 Subject: [PATCH 73/75] Fix: route editor deletions through scene removers --- src/scene/Scene.ts | 23 +++++++++++++++++ src/tools/SceneEditor.ts | 12 +++------ tests/scene/subscene-cleanup.test.ts | 38 ++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/scene/Scene.ts b/src/scene/Scene.ts index 0fe4d51..10b9882 100644 --- a/src/scene/Scene.ts +++ b/src/scene/Scene.ts @@ -304,6 +304,29 @@ export class Scene { } } + removeTriggerbox(triggerbox: Triggerbox): void { + const index = this.triggerboxes.indexOf(triggerbox); + if (index > -1) { + this.triggerboxes.splice(index, 1); + if (this.subsceneEntities.has(triggerbox)) { + this.subsceneEntities.delete(triggerbox); + } + if (this.activeSubscene && triggerbox.name === this.activeSubscene) { + this.activeSubscene = null; + } + } + } + + removeWalkbox(walkbox: Walkbox): void { + const index = this.walkbox.indexOf(walkbox); + if (index > -1) { + this.walkbox.splice(index, 1); + if (this.subsceneEntities.has(walkbox)) { + this.subsceneEntities.delete(walkbox); + } + } + } + playPickupAnimation(entity: Entity): void { const clone = Entity.fromJSON(this.game, entity.toJSON() as EntityData); clone.disabled = false; diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index 4e90b90..7e26162 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -907,17 +907,13 @@ export class SceneEditor { const scene = this.game.sceneManager.currentScene; if (scene) { if (this.selectedObject instanceof Walkbox) { - const index = scene.walkbox.indexOf(this.selectedObject); - if (index > -1) scene.walkbox.splice(index, 1); + scene.removeWalkbox(this.selectedObject); } else if (this.selectedObject instanceof Triggerbox) { - const index = scene.triggerboxes.indexOf(this.selectedObject); - if (index > -1) scene.triggerboxes.splice(index, 1); + scene.removeTriggerbox(this.selectedObject); } else if (this.selectedObject instanceof Entity) { - const index = scene.entities.indexOf(this.selectedObject); - if (index > -1) scene.entities.splice(index, 1); + scene.removeEntity(this.selectedObject); } else if (this.selectedObject instanceof Actor) { - const index = scene.entities.indexOf(this.selectedObject); - if (index > -1) scene.entities.splice(index, 1); + scene.removeEntity(this.selectedObject); } } diff --git a/tests/scene/subscene-cleanup.test.ts b/tests/scene/subscene-cleanup.test.ts index 44d2f23..ac71d80 100644 --- a/tests/scene/subscene-cleanup.test.ts +++ b/tests/scene/subscene-cleanup.test.ts @@ -41,4 +41,42 @@ describe('Subscene cleanup', () => { expect(fixture.sounds).toEqual(['group-close']); expect(groupSwitch.disabled).toBe(true); }); + + it('removes deleted entity children from active subscene membership immediately', () => { + const fixture = createSceneFixture(); + const rootSubscene = fixture.addTriggerbox('Trig_A', { + components: [{ type: 'Subscene', targetGroupId: '' }], + }); + const child = fixture.addEntity('Coin', { + disabled: true, + spatial: { parentNodeId: 'Trig_A', relation: 'in' }, + components: [{ type: 'Item' }], + }); + + ComponentSystem.handleActivation(rootSubscene, fixture.scene); + expect(fixture.scene.subsceneEntities.has(child)).toBe(true); + + fixture.scene.removeEntity(child); + + expect(fixture.scene.subsceneEntities.has(child)).toBe(false); + }); + + it('removes deleted triggerbox children from active subscene membership immediately', () => { + const fixture = createSceneFixture(); + const rootSubscene = fixture.addTriggerbox('Trig_A', { + components: [{ type: 'Subscene', targetGroupId: '' }], + }); + const childTrigger = fixture.addTriggerbox('NestedSwitch', { + disabled: true, + spatial: { parentNodeId: 'Trig_A', relation: 'in' }, + components: [{ type: 'Switch', state: 1 }], + }); + + ComponentSystem.handleActivation(rootSubscene, fixture.scene); + expect(fixture.scene.subsceneEntities.has(childTrigger)).toBe(true); + + fixture.scene.removeTriggerbox(childTrigger); + + expect(fixture.scene.subsceneEntities.has(childTrigger)).toBe(false); + }); }); From e2e31b41a4228c7adf802ce2c7149c0224c65cdd Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 23 Mar 2026 14:09:25 +0200 Subject: [PATCH 74/75] Feature: guard scene changes against unsaved work --- src/components/UIOverlay.tsx | 66 +++++++++- src/core/Game.ts | 53 ++++++-- src/core/IGame.ts | 4 +- src/editor.css | 23 ++++ src/index.css | 42 ++++++ src/tools/SceneEditor.ts | 27 ++-- src/tools/editor/EditorPersistenceManager.ts | 127 +++++++++++++++---- 7 files changed, 291 insertions(+), 51 deletions(-) diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index b871828..8627245 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -17,6 +17,13 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { onConfirm: (f: string) => void; extension?: string; title?: string; + onCancel?: () => void; + } | null>(null); + const [choiceDialog, setChoiceDialog] = useState<{ + title: string; + message: string; + options: Array<{ id: string; label: string; variant?: 'primary' | 'danger' | 'neutral' }>; + onResolve: (choiceId: string | null) => void; } | null>(null); // Console History State @@ -35,8 +42,11 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { game.onMessage = (text) => setMessage({ id: Date.now(), text }); // Bind File Browser Request - game.onRequestFileBrowser = (mode, dir, onConfirm, extension, title) => { - setFileBrowser({ open: true, mode, dir, onConfirm, extension, title }); + game.onRequestFileBrowser = (mode, dir, onConfirm, extension, title, onCancel) => { + setFileBrowser({ open: true, mode, dir, onConfirm, extension, title, onCancel }); + }; + game.onRequestChoiceDialog = (title, dialogMessage, options, onResolve) => { + setChoiceDialog({ title, message: dialogMessage, options, onResolve }); }; // Initialize UI bindings @@ -93,6 +103,36 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { } }; + const handleBrowserCancel = () => { + if (fileBrowser?.onCancel) { + fileBrowser.onCancel(); + } + setFileBrowser(null); + }; + + const handleChoiceResolve = React.useCallback((choiceId: string | null) => { + setChoiceDialog((currentDialog) => { + if (currentDialog) { + currentDialog.onResolve(choiceId); + } + return null; + }); + }, []); + + useEffect(() => { + if (!choiceDialog) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + handleChoiceResolve(null); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [choiceDialog, handleChoiceResolve]); + return ( <> <div id="ui-layer" style={{ pointerEvents: 'none' }}> @@ -220,6 +260,26 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { </div> )} + {choiceDialog && ( + <div className="editor-choice-dialog-backdrop" style={{ pointerEvents: 'auto' }}> + <div className="editor-choice-dialog"> + <div className="editor-choice-dialog-title">{choiceDialog.title}</div> + <div className="editor-choice-dialog-message">{choiceDialog.message}</div> + <div className="editor-choice-dialog-actions"> + {choiceDialog.options.map((option) => ( + <button + key={option.id} + className={`e-btn editor-choice-btn editor-choice-btn-${option.variant || 'neutral'}`} + onClick={() => handleChoiceResolve(option.id)} + > + {option.label} + </button> + ))} + </div> + </div> + </div> + )} + {/* Virtual Console Overlay (High Res, Open State) */} {game && <ConsoleOverlay game={game} />} @@ -240,7 +300,7 @@ export const UIOverlay: React.FC<UIOverlayProps> = ({ game }) => { mode={fileBrowser.mode} directory={fileBrowser.dir} onConfirm={handleBrowserConfirm} - onCancel={() => setFileBrowser(null)} + onCancel={handleBrowserCancel} extension={fileBrowser.extension} title={fileBrowser.title} /> diff --git a/src/core/Game.ts b/src/core/Game.ts index 65bbe8f..2f24c1d 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -71,12 +71,21 @@ export class Game implements IGame { onMessage: ((text: string) => void) | null = null; onRequestFileBrowser: | (( - mode: 'save' | 'load', - dir: string, - onConfirm: (f: string) => void, - extension?: string, - title?: string - ) => void) + mode: 'save' | 'load', + dir: string, + onConfirm: (f: string) => void, + extension?: string, + title?: string, + onCancel?: () => void + ) => void) + | null = null; + onRequestChoiceDialog: + | (( + title: string, + message: string, + options: Array<{ id: string; label: string; variant?: 'primary' | 'danger' | 'neutral' }>, + onResolve: (choiceId: string | null) => void + ) => void) | null = null; settings: { @@ -91,16 +100,32 @@ export class Game implements IGame { dir: string, onConfirm: (f: string) => void, extension?: string, - title?: string + title?: string, + onCancel?: () => void ): void { if (this.onRequestFileBrowser) { - this.onRequestFileBrowser(mode, dir, onConfirm, extension, title); + this.onRequestFileBrowser(mode, dir, onConfirm, extension, title, onCancel); } else { console.error('File Browser UI not hooked up!'); alert('File Browser Unavailable'); } } + requestChoiceDialog( + title: string, + message: string, + options: Array<{ id: string; label: string; variant?: 'primary' | 'danger' | 'neutral' }> + ): Promise<string | null> { + if (!this.onRequestChoiceDialog) { + console.error('Choice Dialog UI not hooked up!'); + return Promise.resolve(null); + } + + return new Promise((resolve) => { + this.onRequestChoiceDialog!(title, message, options, resolve); + }); + } + constructor( rendererCanvas: HTMLCanvasElement, // The main visual canvas (WebGL) uiCanvas: HTMLCanvasElement, // The UI overlay canvas (2D) @@ -639,7 +664,9 @@ export class Game implements IGame { const accessError = this.canExamineObject(entity); if (accessError) return accessError; - const subsceneComponent = entity.components?.find((component: any) => component?.type === 'Subscene'); + const subsceneComponent = entity.components?.find( + (component: any) => component?.type === 'Subscene' + ); if (subsceneComponent && this.sceneManager.currentScene) { this.sceneManager.currentScene.activateObject(entity); const seeMessage = this.getSeeMessage(entity); @@ -882,8 +909,8 @@ export class Game implements IGame { this.inventory.length === 0 ? this.text('parser.inventory_empty') : this.text('parser.inventory_items', { - items: inventoryTitles.join(', '), - }), + items: inventoryTitles.join(', '), + }), data: { count: this.inventory.length, }, @@ -891,7 +918,9 @@ export class Game implements IGame { } goToSceneTarget(target: string): GameActionOutcome { - const normalized = String(target || '').trim().toUpperCase(); + const normalized = String(target || '') + .trim() + .toUpperCase(); if (!normalized) { return { status: 'failed', diff --git a/src/core/IGame.ts b/src/core/IGame.ts index 799d70f..ca2067e 100644 --- a/src/core/IGame.ts +++ b/src/core/IGame.ts @@ -38,7 +38,9 @@ export interface IGame { mode: 'load' | 'save', dir: string, callback: (file: string) => void, - extension?: string + extension?: string, + title?: string, + onCancel?: () => void ): void; setCommandInput(input: HTMLInputElement | null): void; getCommandInput(): HTMLInputElement | null; diff --git a/src/editor.css b/src/editor.css index c30bb48..375ed73 100644 --- a/src/editor.css +++ b/src/editor.css @@ -137,6 +137,29 @@ color: #ffa; } +.editor-choice-btn-primary { + background: #214e3f; + border-color: #2d6a55; + color: #d9f8ea; +} + +.editor-choice-btn-primary:hover { + background: var(--ui-main-color); + border-color: var(--ui-main-color); + color: #000; +} + +.editor-choice-btn-danger { + color: #ffb1b1; + border-color: #7a3434; +} + +.editor-choice-btn-danger:hover { + background: #7a3434; + border-color: #7a3434; + color: #fff; +} + .e-btn-enter { border-color: var(--ui-input-focus-border); box-shadow: 0 0 5px rgba(121, 239, 164, 0.2); diff --git a/src/index.css b/src/index.css index f33f281..65fe111 100644 --- a/src/index.css +++ b/src/index.css @@ -671,6 +671,48 @@ input[type='file'] { color: var(--ui-main-color); } +.editor-choice-dialog-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 5200; +} + +.editor-choice-dialog { + width: min(28rem, calc(100vw - 40px)); + background: rgba(10, 10, 10, 0.96); + border: 1px solid var(--ui-input-focus-border); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); + padding: 14px; + color: var(--ui-input-text); + font-size: 12px; +} + +.editor-choice-dialog-title { + font-family: var(--ui-display-font); + font-weight: 600; + text-transform: uppercase; + letter-spacing: -0.03em; + color: var(--ui-main-color); + margin-bottom: 10px; +} + +.editor-choice-dialog-message { + color: #c7d9d1; + line-height: 1.45; + margin-bottom: 14px; +} + +.editor-choice-dialog-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + flex-wrap: wrap; +} + .file-browser-window { width: 500px; max-width: 95vw; diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index 7e26162..11efce9 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -82,6 +82,8 @@ export class SceneEditor { lastCameraPos: { x: number; y: number } = { x: 0, y: 0 }; update(_deltaTime?: number): void { + this.persistenceManager.ensureCurrentSceneBaseline(); + // Check for Camera changes to update UI if (this.game.sceneManager.currentScene) { const cam = this.game.sceneManager.currentScene.camera; @@ -563,7 +565,7 @@ export class SceneEditor { return this.persistenceManager.loadObject(mode); } - saveScene(saveAs: boolean = false): Promise<void> { + saveScene(saveAs: boolean = false): Promise<boolean> { return this.persistenceManager.saveScene(saveAs); } @@ -708,17 +710,20 @@ export class SceneEditor { } newScene(): void { - const newScene = new Scene(this.game, 'new_scene', 'New Scene'); - // Add default scale - newScene.scaling.enabled = true; - this.game.sceneManager.addScene(newScene); - this.game.sceneManager.switchTo(newScene.id); - this.game.textAssets.ensureSceneAssetFile(newScene).catch((err: unknown) => { - console.error('Failed to create default scene text asset:', err); + void this.persistenceManager.runWithUnsavedChangesGuard(async () => { + const newScene = new Scene(this.game, 'new_scene', 'New Scene'); + // Add default scale + newScene.scaling.enabled = true; + this.game.sceneManager.addScene(newScene); + this.game.sceneManager.switchTo(newScene.id); + this.persistenceManager.markSceneSaved(newScene); + this.game.textAssets.ensureSceneAssetFile(newScene).catch((err: unknown) => { + console.error('Failed to create default scene text asset:', err); + }); + this.syncUI(); + this.refreshHierarchy(); + this.selectObject('SCENE'); }); - this.syncUI(); - this.refreshHierarchy(); - this.selectObject('SCENE'); } refreshHierarchy(): void { diff --git a/src/tools/editor/EditorPersistenceManager.ts b/src/tools/editor/EditorPersistenceManager.ts index 32afe10..9349dda 100644 --- a/src/tools/editor/EditorPersistenceManager.ts +++ b/src/tools/editor/EditorPersistenceManager.ts @@ -1,20 +1,81 @@ import { SceneEditor } from '../SceneEditor'; import { Entity } from '../../entities/Entity'; import { SceneObject } from '../../entities/SceneObject'; +import { Scene } from '../../scene/Scene'; import { useEditorStore } from '../../store/editorStore'; export class EditorPersistenceManager { private editor: SceneEditor; + private lastSavedSceneId: string | null = null; + private lastSavedSceneSnapshot: string | null = null; constructor(editor: SceneEditor) { this.editor = editor; } - // --- Scene Saving --- + private serializeScene(scene: Scene): string { + return JSON.stringify(scene.toJSON()); + } - async saveScene(saveAs: boolean = false): Promise<void> { + ensureCurrentSceneBaseline(): void { const scene = this.editor.game.sceneManager.currentScene; if (!scene) return; + if (this.lastSavedSceneId === scene.id && this.lastSavedSceneSnapshot !== null) return; + this.markSceneSaved(scene); + } + + markSceneSaved(scene: Scene): void { + this.lastSavedSceneId = scene.id; + this.lastSavedSceneSnapshot = this.serializeScene(scene); + } + + isCurrentSceneDirty(): boolean { + const scene = this.editor.game.sceneManager.currentScene; + if (!scene) return false; + if (this.lastSavedSceneId !== scene.id || this.lastSavedSceneSnapshot === null) { + return false; + } + return this.serializeScene(scene) !== this.lastSavedSceneSnapshot; + } + + private async confirmProceedWithUnsavedChanges(): Promise<'save' | 'discard' | 'cancel'> { + const game = this.editor.game; + const choice = await game.requestChoiceDialog( + 'Unsaved Changes', + 'The current scene has unsaved changes. What would you like to do?', + [ + { id: 'save', label: 'Save and Continue', variant: 'primary' }, + { id: 'discard', label: 'Continue Without Saving', variant: 'danger' }, + { id: 'cancel', label: 'Cancel', variant: 'neutral' }, + ] + ); + + if (choice === 'save' || choice === 'discard') return choice; + return 'cancel'; + } + + async runWithUnsavedChangesGuard(action: () => Promise<void> | void): Promise<void> { + if (!this.isCurrentSceneDirty()) { + await action(); + return; + } + + const choice = await this.confirmProceedWithUnsavedChanges(); + if (choice === 'cancel') return; + + if (choice === 'save') { + const saved = await this.saveScene(false); + if (!saved) return; + } + + await action(); + } + + // --- Scene Saving --- + + async saveScene(saveAs: boolean = false): Promise<boolean> { + const scene = this.editor.game.sceneManager.currentScene; + if (!scene) return false; const previousSceneId = scene.id || ''; const id = scene.id || ''; @@ -25,30 +86,39 @@ export class EditorPersistenceManager { // Smart Save // Ensure filename property matches ID (normalized for file system) scene.filename = id.replace(/\\/g, '/'); - this.performSaveScene(scene.filename, previousSceneId); - return; + return this.performSaveScene(scene.filename, previousSceneId); } // Fallback / Save As - this.editor.game.openFileBrowser('save', 'public/scenes', (filename: string) => { - // Update Filename from browser selection - const name = filename.replace('.json', ''); - - // Normalize slashes for ID: use backslash for subfolders - const idFromName = name.replace(/\//g, '\\'); - - scene.filename = name; - scene.id = idFromName; - this.editor.game.sceneManager.syncSceneRegistration(scene, previousSceneId); - - this.editor.syncUI(); // Refresh UI to show new Filename - this.performSaveScene(scene.filename, previousSceneId); + return new Promise((resolve) => { + this.editor.game.openFileBrowser( + 'save', + 'public/scenes', + async (filename: string) => { + // Update Filename from browser selection + const name = filename.replace('.json', ''); + + // Normalize slashes for ID: use backslash for subfolders + const idFromName = name.replace(/\//g, '\\'); + + scene.filename = name; + scene.id = idFromName; + this.editor.game.sceneManager.syncSceneRegistration(scene, previousSceneId); + + this.editor.syncUI(); // Refresh UI to show new Filename + const result = await this.performSaveScene(scene.filename, previousSceneId); + resolve(result); + }, + undefined, + undefined, + () => resolve(false) + ); }); } - async performSaveScene(filenameId: string, previousSceneId?: string): Promise<void> { + async performSaveScene(filenameId: string, previousSceneId?: string): Promise<boolean> { const scene = this.editor.game.sceneManager.currentScene; - if (!scene) return; + if (!scene) return false; // Ensure filenameId uses forward slashes for URL/Path const normalizedPath = filenameId.replace(/\\/g, '/'); @@ -67,25 +137,34 @@ export class EditorPersistenceManager { if (response.ok) { this.editor.game.sceneManager.syncSceneRegistration(scene, previousSceneId, data); await this.editor.game.textAssets.carrySceneAssetIfNeeded(previousSceneId, scene); + this.markSceneSaved(scene); // Use Toast Message this.editor.game.showNotification(`Scene saved as ${normalizedPath}.json`); + return true; } else { throw new Error(await response.text()); } } catch (e) { console.error('Failed to save scene:', e); this.editor.game.showNotification(`Error saving scene: ${e}`); + return false; } } // --- Scene Loading --- promptLoadScene(): void { - this.editor.game.openFileBrowser('load', 'public/scenes', async (filename: string) => { - await this.editor.game.sceneManager.loadScene(filename); - this.editor.syncUI(); - this.editor.refreshHierarchy(); - this.editor.selectObject(null); + void this.runWithUnsavedChangesGuard(async () => { + this.editor.game.openFileBrowser('load', 'public/scenes', async (filename: string) => { + await this.editor.game.sceneManager.loadScene(filename); + this.editor.syncUI(); + this.editor.refreshHierarchy(); + this.editor.selectObject(null); + const scene = this.editor.game.sceneManager.currentScene; + if (scene) { + this.markSceneSaved(scene); + } + }); }); } From 35ece68488f23a046f0cfb7f8b8021d638f03765 Mon Sep 17 00:00:00 2001 From: Michael Voitovich <zx.hunter@gmail.com> Date: Mon, 23 Mar 2026 14:26:25 +0200 Subject: [PATCH 75/75] Fix: track scene dirtiness via undo state --- src/tools/SceneEditor.ts | 2 +- src/tools/editor/EditorPersistenceManager.ts | 54 +++++++++------ src/tools/editor/EditorUndoManager.ts | 72 +++++++++++++------- 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/src/tools/SceneEditor.ts b/src/tools/SceneEditor.ts index 11efce9..bec4b4f 100644 --- a/src/tools/SceneEditor.ts +++ b/src/tools/SceneEditor.ts @@ -716,7 +716,7 @@ export class SceneEditor { newScene.scaling.enabled = true; this.game.sceneManager.addScene(newScene); this.game.sceneManager.switchTo(newScene.id); - this.persistenceManager.markSceneSaved(newScene); + this.persistenceManager.ensureCurrentSceneBaseline(); this.game.textAssets.ensureSceneAssetFile(newScene).catch((err: unknown) => { console.error('Failed to create default scene text asset:', err); }); diff --git a/src/tools/editor/EditorPersistenceManager.ts b/src/tools/editor/EditorPersistenceManager.ts index 9349dda..d5f0290 100644 --- a/src/tools/editor/EditorPersistenceManager.ts +++ b/src/tools/editor/EditorPersistenceManager.ts @@ -1,41 +1,31 @@ import { SceneEditor } from '../SceneEditor'; import { Entity } from '../../entities/Entity'; import { SceneObject } from '../../entities/SceneObject'; -import { Scene } from '../../scene/Scene'; import { useEditorStore } from '../../store/editorStore'; export class EditorPersistenceManager { private editor: SceneEditor; - private lastSavedSceneId: string | null = null; - private lastSavedSceneSnapshot: string | null = null; + private trackedSceneRef: object | null = null; + private pendingSceneSave: Promise<boolean> | null = null; constructor(editor: SceneEditor) { this.editor = editor; } - private serializeScene(scene: Scene): string { - return JSON.stringify(scene.toJSON()); - } - ensureCurrentSceneBaseline(): void { const scene = this.editor.game.sceneManager.currentScene; if (!scene) return; - if (this.lastSavedSceneId === scene.id && this.lastSavedSceneSnapshot !== null) return; - this.markSceneSaved(scene); + if (this.trackedSceneRef === scene) return; + this.trackedSceneRef = scene; + this.editor.undoManager.resetForCleanScene(); } - markSceneSaved(scene: Scene): void { - this.lastSavedSceneId = scene.id; - this.lastSavedSceneSnapshot = this.serializeScene(scene); + markSceneSaved(): void { + this.editor.undoManager.markSaved(); } isCurrentSceneDirty(): boolean { - const scene = this.editor.game.sceneManager.currentScene; - if (!scene) return false; - if (this.lastSavedSceneId !== scene.id || this.lastSavedSceneSnapshot === null) { - return false; - } - return this.serializeScene(scene) !== this.lastSavedSceneSnapshot; + return this.editor.undoManager.isDirty(); } private async confirmProceedWithUnsavedChanges(): Promise<'save' | 'discard' | 'cancel'> { @@ -55,6 +45,13 @@ export class EditorPersistenceManager { } async runWithUnsavedChangesGuard(action: () => Promise<void> | void): Promise<void> { + if (this.pendingSceneSave) { + const saveResult = await this.pendingSceneSave; + if (!saveResult && this.isCurrentSceneDirty()) { + return; + } + } + if (!this.isCurrentSceneDirty()) { await action(); return; @@ -74,6 +71,22 @@ export class EditorPersistenceManager { // --- Scene Saving --- async saveScene(saveAs: boolean = false): Promise<boolean> { + if (this.pendingSceneSave) { + return this.pendingSceneSave; + } + + const savePromise = this.saveSceneInternal(saveAs); + this.pendingSceneSave = savePromise; + try { + return await savePromise; + } finally { + if (this.pendingSceneSave === savePromise) { + this.pendingSceneSave = null; + } + } + } + + private async saveSceneInternal(saveAs: boolean = false): Promise<boolean> { const scene = this.editor.game.sceneManager.currentScene; if (!scene) return false; const previousSceneId = scene.id || ''; @@ -137,7 +150,7 @@ export class EditorPersistenceManager { if (response.ok) { this.editor.game.sceneManager.syncSceneRegistration(scene, previousSceneId, data); await this.editor.game.textAssets.carrySceneAssetIfNeeded(previousSceneId, scene); - this.markSceneSaved(scene); + this.markSceneSaved(); // Use Toast Message this.editor.game.showNotification(`Scene saved as ${normalizedPath}.json`); return true; @@ -162,7 +175,8 @@ export class EditorPersistenceManager { this.editor.selectObject(null); const scene = this.editor.game.sceneManager.currentScene; if (scene) { - this.markSceneSaved(scene); + this.trackedSceneRef = scene; + this.editor.undoManager.resetForCleanScene(); } }); }); diff --git a/src/tools/editor/EditorUndoManager.ts b/src/tools/editor/EditorUndoManager.ts index 40be2bc..53a53e6 100644 --- a/src/tools/editor/EditorUndoManager.ts +++ b/src/tools/editor/EditorUndoManager.ts @@ -2,30 +2,58 @@ import { SceneEditor } from '../SceneEditor'; export class EditorUndoManager { private editor: SceneEditor; - private undoStack: any[] = []; - private redoStack: any[] = []; + private undoStack: Array<{ sceneData: any; version: number }> = []; + private redoStack: Array<{ sceneData: any; version: number }> = []; private readonly MAX_HISTORY = 50; + private currentMutationVersion = 0; + private savedMutationVersion = 0; constructor(editor: SceneEditor) { this.editor = editor; } + private captureCurrentState(): any { + const scene = this.editor.game.sceneManager.currentScene; + if (!scene) return null; + return JSON.parse(JSON.stringify(scene.toJSON())); + } + saveUndoState(): void { const scene = this.editor.game.sceneManager.currentScene; if (!scene) return; // Push current state to Undo Stack (Deep Clone) - this.undoStack.push(JSON.parse(JSON.stringify(scene.toJSON()))); + this.undoStack.push({ + sceneData: this.captureCurrentState(), + version: this.currentMutationVersion, + }); // Enforce Max History if (this.undoStack.length > this.MAX_HISTORY) { this.undoStack.shift(); // Remove oldest } + this.currentMutationVersion += 1; + // Clear Redo Stack on new action this.redoStack = []; } + markSaved(): void { + this.savedMutationVersion = this.currentMutationVersion; + } + + isDirty(): boolean { + return this.currentMutationVersion !== this.savedMutationVersion; + } + + resetForCleanScene(): void { + this.undoStack = []; + this.redoStack = []; + this.currentMutationVersion = 0; + this.savedMutationVersion = 0; + } + restoreSceneState(data: any): void { const scene = this.editor.game.sceneManager.currentScene; if (!scene) return; @@ -74,47 +102,45 @@ export class EditorUndoManager { } undo(): void { - const scene = this.editor.game.sceneManager.currentScene; - if (!scene) return; - if (this.undoStack.length === 0) { this.editor.game.showNotification('Cannot Undo: Start of Buffer'); return; } - // 1. Capture CURRENT state and push to Redo Stack - // 1. Capture CURRENT state and push to Redo Stack - const currentState = JSON.parse(JSON.stringify(scene.toJSON())); - this.redoStack.push(currentState); + const currentState = this.captureCurrentState(); + if (!currentState) return; + this.redoStack.push({ + sceneData: currentState, + version: this.currentMutationVersion, + }); - // 2. Pop from Undo Stack const previousState = this.undoStack.pop(); + if (!previousState) return; - // 3. Restore Previous State - this.restoreSceneState(previousState); + this.restoreSceneState(previousState.sceneData); + this.currentMutationVersion = previousState.version; this.editor.game.showNotification(`Undo (-${this.redoStack.length})`); } redo(): void { - const scene = this.editor.game.sceneManager.currentScene; - if (!scene) return; - if (this.redoStack.length === 0) { this.editor.game.showNotification('Cannot Redo: End of Buffer'); return; } - // 1. Capture CURRENT state and push to Undo Stack - // 1. Capture CURRENT state and push to Undo Stack - const currentState = JSON.parse(JSON.stringify(scene.toJSON())); - this.undoStack.push(currentState); + const currentState = this.captureCurrentState(); + if (!currentState) return; + this.undoStack.push({ + sceneData: currentState, + version: this.currentMutationVersion, + }); - // 2. Pop from Redo Stack const nextState = this.redoStack.pop(); + if (!nextState) return; - // 3. Restore Next State - this.restoreSceneState(nextState); + this.restoreSceneState(nextState.sceneData); + this.currentMutationVersion = nextState.version; if (this.redoStack.length === 0) { this.editor.game.showNotification(`Redo (Latest)`);