From 982ca972b665b7dd9e18c6f41f0de7925fbaa7fe Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Fri, 20 Mar 2026 01:54:09 +0200 Subject: [PATCH 01/38] 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; 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(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): Entity[] { + private getScopeCandidates(sliceNames: Array): 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({ - 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(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 = { + '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 Date: Fri, 20 Mar 2026 02:11:09 +0200 Subject: [PATCH 02/38] 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 = ({ 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 Date: Fri, 20 Mar 2026 16:19:18 +0200 Subject: [PATCH 03/38] 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 Date: Fri, 20 Mar 2026 18:06:52 +0200 Subject: [PATCH 04/38] 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 Date: Fri, 20 Mar 2026 18:54:37 +0200 Subject: [PATCH 05/38] 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 Date: Sat, 21 Mar 2026 00:18:44 +0200 Subject: [PATCH 06/38] 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,') + 16 16, + pointer; +} + +.cursor-back { + cursor: + url('data:image/svg+xml;utf8,') + 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 Date: Sat, 21 Mar 2026 01:05:49 +0200 Subject: [PATCH 07/38] 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 Date: Sat, 21 Mar 2026 02:14:28 +0200 Subject: [PATCH 08/38] 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 = () => { SCRIPT EVENTS = ({ autoFocus /> -
- -
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 = ({ onGameInit }) => { const canvasRef = useRef(null); const uiCanvasRef = useRef(null); + const editorOverlayCanvasRef = useRef(null); const gameRef = useRef(null); const containerRef = useRef(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 = ({ 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 = ({ onGameInit }) => { pointerEvents: 'auto', }} /> + + ); }; 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 = () => {
{selectedObjectId === 'SETTINGS' ? 'SETTINGS (Loading...)' : 'PROPERTIES'}
-
+
{selectedObjectId === 'SETTINGS' ? 'Loading Settings...' : 'No Selection'}
@@ -302,7 +302,7 @@ export const PropertiesPanel: React.FC = () => {
-
+
(<Enter> = append, <Ctrl+Enter> = remove)
{ { @@ -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 = () => { )}
-
+
{textAssetPath}
@@ -1299,10 +1299,7 @@ export const PropertiesPanel: React.FC = () => {
-
{/* Interactions */} -
+
SCRIPT EVENTS {
-