Polish Princess Lima map data and interaction feedback
All checks were successful
Build Org Website / build (push) Successful in 38s

This commit is contained in:
gitea-actions
2026-07-30 13:58:25 +01:00
parent 15b626ee0d
commit a6502b168d
13 changed files with 198 additions and 51 deletions

View File

@@ -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
});
}));

View File

@@ -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);

0
assets/scripts/pages/princess-lima-maps.js Normal file → Executable file
View File

View File

@@ -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));

View File

@@ -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) {

View File

@@ -803,3 +803,10 @@ at <ScriptBlock>, /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.

View File

@@ -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.

View File

@@ -134,7 +134,7 @@ Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
"<meta name=\"description\" content=\"A standalone fantasy RPG about rescuing Princess Lima.\" />\n"
"<title>Rescue Princess Lima</title>\n"
"<link rel=\"icon\" href=\"/assets/icons/icons8-film-tape-100.png\" />\n"
"<link rel=\"stylesheet\" href=\"/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.1.1\" />\n"
"<link rel=\"stylesheet\" href=\"/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.1.2\" />\n"
"</head>\n<body class=\"princess-lima-page\">\n"
body
"\n</body>\n</html>\n"))

View File

@@ -107,16 +107,16 @@
</noscript>
</main>
<link rel="stylesheet" href="/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.1.1" />
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=princess-lima-1.1.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-data.js?v=princess-lima-1.1.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-state.js?v=princess-lima-1.1.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-systems.js?v=princess-lima-1.1.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-audio.js?v=princess-lima-1.1.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-ui.js?v=princess-lima-1.1.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-maps.js?v=princess-lima-1.1.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-intro.js?v=princess-lima-1.1.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-scenes.js?v=princess-lima-1.1.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-game.js?v=princess-lima-1.1.1" defer></script>
<link rel="stylesheet" href="/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.1.2" />
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-data.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-state.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-systems.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-audio.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-ui.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-maps.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-intro.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-scenes.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-game.js?v=princess-lima-1.1.2" defer></script>
<!-- RPG-STANDALONE-END -->
#+END_EXPORT

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">30-07-2026 12:23</span>@@
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">30-07-2026 12:42</span>@@
- [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@

View File

@@ -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]]

View File

@@ -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 <kbd>I<\/kbd>/);

View File

@@ -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");