From a6502b168dcece92f03650c74d138e225e6ef9ca Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 30 Jul 2026 13:58:25 +0100 Subject: [PATCH] Polish Princess Lima map data and interaction feedback --- assets/scripts/pages/princess-lima-data.js | 71 ++++++++++++------- assets/scripts/pages/princess-lima-game.js | 7 +- assets/scripts/pages/princess-lima-maps.js | 0 assets/scripts/pages/princess-lima-scenes.js | 25 ++++++- assets/scripts/pages/princess-lima-systems.js | 8 ++- build-logs/gitea-build-monitor.log | 7 ++ docs/princess-lima-map-authoring.md | 58 +++++++++++++++ lisp/build-html.el | 2 +- play/rpg.org | 22 +++--- posts/posts-list.org | 2 +- sitemap.org | 14 ++-- tests/princess-lima-polish.test.cjs | 10 +++ tests/princess-lima-systems.test.cjs | 23 ++++++ 13 files changed, 198 insertions(+), 51 deletions(-) mode change 100644 => 100755 assets/scripts/pages/princess-lima-maps.js create mode 100644 docs/princess-lima-map-authoring.md diff --git a/assets/scripts/pages/princess-lima-data.js b/assets/scripts/pages/princess-lima-data.js index 5f61e83..d48b919 100755 --- a/assets/scripts/pages/princess-lima-data.js +++ b/assets/scripts/pages/princess-lima-data.js @@ -234,29 +234,14 @@ }) }); - function edges(openings) { - const gap = Object.assign({ top: null, right: null, bottom: null, left: null }, openings || {}); - const result = []; + function edges() { const thickness = 18; - [["top", WIDTH, "x"], ["bottom", WIDTH, "x"]].forEach(([side, length]) => { - const opening = gap[side]; - const y = side === "top" ? 0 : HEIGHT - thickness; - if (!opening) result.push(rect(0, y, length, thickness, "edge")); - else { - result.push(rect(0, y, opening[0], thickness, "edge")); - result.push(rect(opening[1], y, length - opening[1], thickness, "edge")); - } - }); - [["left", HEIGHT, "y"], ["right", HEIGHT, "y"]].forEach(([side, length]) => { - const opening = gap[side]; - const x = side === "left" ? 0 : WIDTH - thickness; - if (!opening) result.push(rect(x, 0, thickness, length, "edge")); - else { - result.push(rect(x, 0, thickness, opening[0], "edge")); - result.push(rect(x, opening[1], thickness, length - opening[1], "edge")); - } - }); - return result; + return [ + rect(0, 0, WIDTH, thickness, "edge"), + rect(0, HEIGHT - thickness, WIDTH, thickness, "edge"), + rect(0, 0, thickness, HEIGHT, "edge"), + rect(WIDTH - thickness, 0, thickness, HEIGHT, "edge") + ]; } /* @@ -323,7 +308,11 @@ pickups: Object.freeze([["moon_coin", 360, 330], ["healing_tonic", 390, 525]]) }), mountain: Object.freeze({ - spawns: Object.freeze({ forestTrail: { x: 365, y: 660 }, campRoad: { x: 355, y: 55 } }), + spawns: Object.freeze({ + forestTrail: { x: 365, y: 660 }, + campRoad: { x: 355, y: 55 }, + bridgeControls: { x: 365, y: 430 } + }), obstacles: Object.freeze([ ...edges({ top: [315, 405], bottom: [320, 410] }), rect(18, 18, 230, 684, "cliff"), rect(500, 18, 202, 684, "cliff"), @@ -337,8 +326,8 @@ exit("camp", 325, 0, 75, 48, "camp", "mountainRoad", "guardian_defeated", "Road to Blackridge Camp") ]), npcs: Object.freeze([["bram", 325, 250]]), - enemies: Object.freeze([enemy("bat", 355, 455), enemy("guard", 360, 230), enemy("stone_guardian", 365, 115, { quest: "stone_guardian", boss: true, requires: "bridge_repaired", leash: 180 })]), - puzzle: Object.freeze({ id: "bridge_winches", type: "set", sequence: ["west", "east"], objects: [["west", 330, 290], ["east", 390, 290]] }), + enemies: Object.freeze([enemy("guard", 360, 230), enemy("stone_guardian", 365, 115, { quest: "stone_guardian", boss: true, requires: "bridge_repaired", leash: 180 })]), + puzzle: Object.freeze({ id: "bridge_winches", type: "set", sequence: ["west", "east"], objects: [["west", 335, 405], ["east", 395, 445]] }), pickups: Object.freeze([["moon_coin", 340, 575], ["healing_tonic", 400, 520]]) }), camp: Object.freeze({ @@ -426,6 +415,36 @@ Object.freeze(Object.assign({}, LEGACY_MAPS[id], VISUAL_LAYOUT[id])) ]))); + function tiledPoint(name, type, x, y, properties) { + return Object.freeze({ + name, type, x, y, point: true, + properties: Object.freeze(Object.assign({}, properties || {})) + }); + } + + function tiledLayer(name, objects) { + return Object.freeze({ type: "objectgroup", name, visible: true, objects: Object.freeze(objects) }); + } + + /* + * Tiled-compatible object-layer view of every authored region. Runtime + * collision reads these named layers, and the remaining layers give map + * editing/export tools a single inspectable contract for game objects. + */ + const MAP_LAYERS = Object.freeze(Object.fromEntries(Object.entries(MAPS).map(([regionId, map]) => [ + regionId, + Object.freeze([ + tiledLayer("Collision", map.obstacles), + tiledLayer("Dynamic Collision", map.dynamicObstacles || []), + tiledLayer("Exits", (map.exits || []).map((item) => Object.freeze(Object.assign({ type: "exit" }, item)))), + tiledLayer("NPCs", (map.npcs || []).map(([id, x, y]) => tiledPoint(id, "npc", x, y))), + tiledLayer("Enemies", (map.enemies || []).map((item) => tiledPoint(item.type, "enemy", item.x, item.y, item))), + tiledLayer("Puzzles", map.puzzle ? map.puzzle.objects.map(([id, x, y]) => + tiledPoint(id, "puzzle", x, y, { puzzle: map.puzzle.id })) : []), + tiledLayer("Items", (map.pickups || []).map(([id, x, y]) => tiledPoint(id, "item", x, y))) + ]) + ]))); + const MAP_IDS = Object.freeze(Object.keys(MAPS)); const QUEST_IDS = Object.freeze(Object.keys(QUESTS)); const ITEM_IDS = Object.freeze(Object.keys(ITEMS)); @@ -433,7 +452,7 @@ const NPC_IDS = Object.freeze(Object.keys(NPCS)); return Object.freeze({ - WIDTH, HEIGHT, ITEMS, QUESTS, ENEMIES, NPCS, MAPS, + WIDTH, HEIGHT, ITEMS, QUESTS, ENEMIES, NPCS, MAPS, MAP_LAYERS, MAP_IDS, QUEST_IDS, ITEM_IDS, ENEMY_IDS, NPC_IDS }); })); diff --git a/assets/scripts/pages/princess-lima-game.js b/assets/scripts/pages/princess-lima-game.js index 36818f1..b98de90 100755 --- a/assets/scripts/pages/princess-lima-game.js +++ b/assets/scripts/pages/princess-lima-game.js @@ -88,8 +88,11 @@ } const debugRegion = localRegionPreview(); if (debugRegion) { - const firstSpawn = Object.entries(Data.MAPS[debugRegion].spawns)[0]; - return State.withPosition(parsed, debugRegion, firstSpawn[0], firstSpawn[1].x, firstSpawn[1].y, "south"); + const map = Data.MAPS[debugRegion]; + const requestedSpawn = new URLSearchParams(window.location.search).get("spawn"); + const spawn = map.spawns[requestedSpawn] ? [requestedSpawn, map.spawns[requestedSpawn]] + : Object.entries(map.spawns)[0]; + return State.withPosition(parsed, debugRegion, spawn[0], spawn[1].x, spawn[1].y, "south"); } const safe = Systems.nearestSafeSpawn(parsed, parsed.region, parsed.position.x, parsed.position.y); return State.withPosition(parsed, safe.region, safe.spawn, safe.x, safe.y, parsed.position.facing); diff --git a/assets/scripts/pages/princess-lima-maps.js b/assets/scripts/pages/princess-lima-maps.js old mode 100644 new mode 100755 diff --git a/assets/scripts/pages/princess-lima-scenes.js b/assets/scripts/pages/princess-lima-scenes.js index 247b89b..bf07738 100755 --- a/assets/scripts/pages/princess-lima-scenes.js +++ b/assets/scripts/pages/princess-lima-scenes.js @@ -182,7 +182,8 @@ if (!puzzle || controller.getState().solvedPuzzles.includes(puzzle.id)) return; puzzle.objects.forEach(([id, x, y]) => { const node = this.add.zone(x, y, 38, 38); - this.interactables.push({ type: "puzzle", id, puzzle, x, y, sprite: node }); + const marker = this.createInteractionMarker(x, y, `${readableId(id)} · E`); + this.interactables.push({ type: "puzzle", id, puzzle, x, y, sprite: node, marker }); }); } @@ -191,10 +192,22 @@ const chestId = `${this.regionId}-${id}-${index}`; if (controller.getState().openedChests.includes(chestId)) return; const item = this.add.zone(x, y, 34, 34); - this.interactables.push({ type: "pickup", id, chestId, x, y, sprite: item }); + const marker = this.createInteractionMarker(x, y, `${Data.ITEMS[id].name} · E`); + this.interactables.push({ type: "pickup", id, chestId, x, y, sprite: item, marker }); }); } + createInteractionMarker(x, y, label) { + return this.add.text(x, y - 32, label, { + fontFamily: "monospace", + fontSize: "13px", + fontStyle: "bold", + color: "#fff6ce", + backgroundColor: "#111827", + padding: { x: 7, y: 4 } + }).setOrigin(0.5).setDepth(900).setVisible(false); + } + createInput() { this.cursors = this.input.keyboard.createCursorKeys(); this.keys = this.input.keyboard.addKeys({ @@ -253,6 +266,7 @@ let nearest = null; this.interactables.forEach((item) => { const distance = Math.hypot(item.x - this.player.x, item.y - this.player.y); + if (item.marker) item.marker.setVisible(distance <= 92); const radius = item.type === "exit" ? 70 : 54; if (distance <= radius && distance < best) { best = distance; @@ -342,6 +356,7 @@ if (state.solvedPuzzles.includes(puzzle.id)) return; if (puzzle.type === "set") { if (!this.puzzleInput.includes(item.id)) this.puzzleInput.push(item.id); + if (item.marker) item.marker.setText(`✓ ${readableId(item.id)}`); if (this.puzzleInput.length === puzzle.sequence.length) this.finishPuzzle(puzzle.id); else controller.status(`${this.puzzleInput.length} / ${puzzle.sequence.length} mechanisms active.`); return; @@ -353,6 +368,7 @@ controller.status("The sequence resets. Look for the environmental clue and try again."); return; } + if (item.marker) item.marker.setText(`✓ ${readableId(item.id)}`); if (this.puzzleInput.length === puzzle.sequence.length) this.finishPuzzle(puzzle.id); else controller.status(`${item.id} answers. ${this.puzzleInput.length} / ${puzzle.sequence.length}.`); } @@ -377,6 +393,7 @@ controller.setState(state, `${Data.ITEMS[item.id].name} collected.`); controller.audio.play("pickup"); item.sprite.destroy(); + if (item.marker) item.marker.destroy(); this.interactables = this.interactables.filter((entry) => entry !== item); } @@ -585,5 +602,9 @@ return "Interact · E / Enter"; } + function readableId(value) { + return String(value).replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); + } + root.PrincessLimaScenes = Object.freeze({ createSceneClasses, playerFrame }); }(typeof globalThis !== "undefined" ? globalThis : this)); diff --git a/assets/scripts/pages/princess-lima-systems.js b/assets/scripts/pages/princess-lima-systems.js index 63c0305..1ead398 100755 --- a/assets/scripts/pages/princess-lima-systems.js +++ b/assets/scripts/pages/princess-lima-systems.js @@ -40,7 +40,13 @@ function activeObstacles(state, regionId) { const map = Data.MAPS[regionId]; if (!map) return []; - return map.obstacles.concat((map.dynamicObstacles || []).filter((item) => !state.unlockedRoutes.includes(item.opensWith))); + const layers = Data.MAP_LAYERS[regionId] || []; + const collision = layers.find((layer) => layer.name === "Collision"); + const dynamic = layers.find((layer) => layer.name === "Dynamic Collision"); + return (collision ? collision.objects : map.obstacles).concat( + (dynamic ? dynamic.objects : map.dynamicObstacles || []) + .filter((item) => !state.unlockedRoutes.includes(item.opensWith)) + ); } function isSafePosition(state, regionId, x, y) { diff --git a/build-logs/gitea-build-monitor.log b/build-logs/gitea-build-monitor.log index 75f759c..f78fa7f 100755 --- a/build-logs/gitea-build-monitor.log +++ b/build-logs/gitea-build-monitor.log @@ -803,3 +803,10 @@ at , /home/zaine/master-folder/org-platform/org_web/build-logs/gite 2026-07-30T12:23:36.7830664+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python. 2026-07-30T12:23:36.8532292+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0 2026-07-30T12:23:37.1987243+01:00 [INFO] Sent authoring server test notification. +2026-07-30T12:48:57.8025412+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success +2026-07-30T12:48:57.8102120+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web. +2026-07-30T12:48:58.1535872+01:00 [ERROR] Failed to analyze job 783: Cannot bind argument to parameter 'LogText' because it is an empty string. +2026-07-30T12:48:58.6713722+01:00 [INFO] Sent build status notification with 1 embed(s). +2026-07-30T12:48:58.6913478+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python. +2026-07-30T12:48:58.7600936+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0 +2026-07-30T12:48:59.0618648+01:00 [INFO] Sent authoring server test notification. diff --git a/docs/princess-lima-map-authoring.md b/docs/princess-lima-map-authoring.md new file mode 100644 index 0000000..7a3fbca --- /dev/null +++ b/docs/princess-lima-map-authoring.md @@ -0,0 +1,58 @@ +# Princess Lima map authoring + +## Framework + +Use [Tiled](https://www.mapeditor.org/) as the visual authoring tool and Phaser +as the runtime. Every region is a 720×720 orthogonal map whose locked image +layer is the corresponding frame from `regional-style-atlas.png`. + +The runtime contract mirrors Tiled object groups in +`PrincessLimaData.MAP_LAYERS`. Every map has these named layers: + +1. `Collision` +2. `Dynamic Collision` +3. `Exits` +4. `NPCs` +5. `Enemies` +6. `Puzzles` +7. `Items` + +Do not paint collision or interaction geometry into the finished artwork. +Production collision objects are invisible. + +## Collision rules + +- Trace only terrain that is visibly solid: buildings, walls, cliff faces, + deep water, fences, furniture, and the four outer edges. +- Keep paths, doors, bridges, stairs, and floor tiles walkable. +- Use rectangles while the game uses Phaser Arcade Physics. A future switch to + Matter Physics can use Tiled polygon objects without approximating them. +- Every dynamic blocker needs an `opensWith` property matching a validated + route ID. +- Never put a puzzle control, item, actor, or safe spawn inside collision. +- Keep enemies away from puzzle controls and safe spawns. + +## Interaction rules + +- Exits, NPCs, enemies, puzzles, and items belong on their named object layer. +- Puzzle and item labels appear only within 92 pixels of the player. +- The interaction radius is 54 pixels; exit portals use 70 pixels. +- Every mechanism must be reachable before the state change it triggers. +- Interaction labels must use the object name rather than an unexplained shape. + +## Verification + +On localhost, use: + +```text +/play/rpg.html?region=mountain&spawn=bridgeControls +/play/rpg.html?region=mountain&spawn=bridgeControls&collisionDebug=1 +``` + +Before publishing: + +1. Compare the clean rendered map with its locked source image. +2. Enable collision debugging and check every visible boundary. +3. Walk each route in both its locked and unlocked states. +4. Confirm all object-layer points pass the collision-safety tests. +5. Confirm ordinary production URLs show no collision geometry. diff --git a/lisp/build-html.el b/lisp/build-html.el index ad99160..eb74c1a 100755 --- a/lisp/build-html.el +++ b/lisp/build-html.el @@ -134,7 +134,7 @@ Created with %c on Arch \ "\n" "Rescue Princess Lima\n" "\n" - "\n" + "\n" "\n\n" body "\n\n\n")) diff --git a/play/rpg.org b/play/rpg.org index b832b46..4cc95b7 100755 --- a/play/rpg.org +++ b/play/rpg.org @@ -107,16 +107,16 @@ - - - - - - - - - - - + + + + + + + + + + + #+END_EXPORT diff --git a/posts/posts-list.org b/posts/posts-list.org index c356e39..54699c9 100755 --- a/posts/posts-list.org +++ b/posts/posts-list.org @@ -4,7 +4,7 @@ See the categories: @@html:Categories@@ * Posts: -- [[file:career/career-list.org][Career List]] @@html:@@ +- [[file:career/career-list.org][Career List]] @@html:@@ - [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:@@ @@html:@@ @@html:@@ - [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:@@ @@html:@@ @@html:@@ - [[file:career/javascript.org][Understands the Javascript language]] @@html:@@ @@html:@@ @@html:@@ diff --git a/sitemap.org b/sitemap.org index 4158ffb..ba9e363 100755 --- a/sitemap.org +++ b/sitemap.org @@ -112,11 +112,11 @@ flowchart TD n43 --> n53 n54["Tag: insights"] n43 --> n54 - n55["Tag: emacs"] + n55["Tag: reading"] n43 --> n55 - n56["Tag: maths"] + n56["Tag: emacs"] n43 --> n56 - n57["Tag: reading"] + n57["Tag: maths"] n43 --> n57 n58{{"posts"}} root --> n58 @@ -228,9 +228,9 @@ flowchart TD click n52 "tags/life.html" "Tag: life" click n53 "tags/education.html" "Tag: education" click n54 "tags/insights.html" "Tag: insights" - click n55 "tags/emacs.html" "Tag: emacs" - click n56 "tags/maths.html" "Tag: maths" - click n57 "tags/reading.html" "Tag: reading" + click n55 "tags/reading.html" "Tag: reading" + click n56 "tags/emacs.html" "Tag: emacs" + click n57 "tags/maths.html" "Tag: maths" click n59 "posts/posts-intro.html" "Posts Introduction" click n60 "posts/posts-list.html" "Posts List" click n62 "posts/career/solid-principles.html" "SOLID Principles" @@ -322,9 +322,9 @@ flowchart TD - [[file:tags/life.org][Tag: life]] - [[file:tags/education.org][Tag: education]] - [[file:tags/insights.org][Tag: insights]] + - [[file:tags/reading.org][Tag: reading]] - [[file:tags/emacs.org][Tag: emacs]] - [[file:tags/maths.org][Tag: maths]] - - [[file:tags/reading.org][Tag: reading]] - posts - [[file:posts/posts-intro.org][Posts Introduction]] - [[file:posts/posts-list.org][Posts List]] diff --git a/tests/princess-lima-polish.test.cjs b/tests/princess-lima-polish.test.cjs index 521aa8e..210b3dd 100755 --- a/tests/princess-lima-polish.test.cjs +++ b/tests/princess-lima-polish.test.cjs @@ -48,6 +48,16 @@ test("all authored actors and interaction points sit outside visible terrain col }); }); +test("every region exposes named Tiled-compatible collision and object layers", () => { + const expected = ["Collision", "Dynamic Collision", "Exits", "NPCs", "Enemies", "Puzzles", "Items"]; + data.MAP_IDS.forEach((region) => { + const layers = data.MAP_LAYERS[region]; + assert.deepEqual(layers.map((layer) => layer.name), expected); + assert.ok(layers.every((layer) => layer.type === "objectgroup")); + assert.equal(layers.find((layer) => layer.name === "Collision").objects, data.MAPS[region].obstacles); + }); +}); + test("keyboard-only page contains one left HUD and no touch controls", () => { assert.match(rpgSource, /class="lima-hud"/); assert.match(rpgSource, /Inventory I<\/kbd>/); diff --git a/tests/princess-lima-systems.test.cjs b/tests/princess-lima-systems.test.cjs index db1e40e..47ab630 100755 --- a/tests/princess-lima-systems.test.cjs +++ b/tests/princess-lima-systems.test.cjs @@ -51,6 +51,29 @@ test("all named spawns are collision-safe and invalid positions recover", () => assert.equal(systems.isSafePosition(current, recovered.region, recovered.x, recovered.y), true); }); +test("mountain bridge controls are reachable before repair and crossing opens afterward", () => { + let current = state.fresh("Ada", "azure"); + const mountain = data.MAPS.mountain; + const controls = mountain.puzzle.objects.map(([, x, y]) => ({ x, y })); + controls.forEach((point) => { + assert.equal(systems.isSafePosition(current, "mountain", point.x, point.y), true); + }); + assert.ok(Math.hypot(controls[0].x - controls[1].x, controls[0].y - controls[1].y) <= 80); + assert.equal(systems.isSafePosition(current, "mountain", 365, 345), false); + current = systems.solvePuzzle(current, "bridge_winches"); + assert.equal(current.unlockedRoutes.includes("bridge_repaired"), true); + assert.equal(systems.isSafePosition(current, "mountain", 365, 345), true); +}); + +test("every region has an unbroken outer collision border", () => { + const current = state.fresh("Ada", "azure"); + data.MAP_IDS.forEach((region) => { + [[10, 360], [710, 360], [360, 10], [360, 710]].forEach(([x, y]) => { + assert.equal(systems.isSafePosition(current, region, x, y), false, `${region}:${x},${y}`); + }); + }); +}); + test("quest progression grants rewards once and unlocks routes", () => { let current = state.fresh("Ada", "azure"); current = systems.completeQuest(current, "aftermath");