Refine Archive World navigation and gate progression
All checks were successful
Build Org Website / build (push) Successful in 38s

This commit is contained in:
gitea-actions
2026-07-30 10:02:58 +01:00
parent c06af38d80
commit 2f125f32ae
10 changed files with 301 additions and 80 deletions

View File

@@ -5,6 +5,12 @@
return Object.freeze({ x, y, width, height, depth: depth || 2 }); return Object.freeze({ x, y, width, height, depth: depth || 2 });
} }
function enemy(type, x, y, options) {
return Object.freeze(Object.assign({
type, x, y, leash: 150, engage: 245, requiresSigil: null, questId: null
}, options || {}));
}
function landmark(id, title, eyebrow, x, y, npc, description, links, questId, area) { function landmark(id, title, eyebrow, x, y, npc, description, links, questId, area) {
return Object.freeze({ id, title, eyebrow, x, y, npc, description, links, questId, area: area || null }); return Object.freeze({ id, title, eyebrow, x, y, npc, description, links, questId, area: area || null });
} }
@@ -13,22 +19,22 @@
gate: landmark("gate", "Town Gate", "Arrivals", 724, 920, "orin", gate: landmark("gate", "Town Gate", "Arrivals", 724, 920, "orin",
"The only opening in Archive Town's old wall. Gatekeeper Orin is trying to restore the erased arrival plaques.", "The only opening in Archive Town's old wall. Gatekeeper Orin is trying to restore the erased arrival plaques.",
[["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"]], "gate"), [["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"]], "gate"),
library: landmark("library", "Grand Library", "Knowledge", 265, 280, "sera", library: landmark("library", "Grand Library", "Knowledge", 265, 305, "sera",
"Head Archivist Sera guards the sealed vault and a shelf whose symbols no longer remember their order.", "Head Archivist Sera guards the sealed vault and a shelf whose symbols no longer remember their order.",
[["Posts", "/posts/posts-list.html"], ["Categories", "/home/categories.html"], ["Posts introduction", "/posts/posts-intro.html"]], "library", "vault"), [["Posts", "/posts/posts-list.html"], ["Categories", "/home/categories.html"], ["Posts introduction", "/posts/posts-intro.html"]], "library", "vault"),
study: landmark("study", "Guild Study", "Work", 720, 250, "vale", study: landmark("study", "Guild Study", "Work", 720, 300, "vale",
"Instructor Vale keeps the guild calm while Nulls gather beyond the practice banners.", "Instructor Vale keeps the guild calm while Nulls gather beyond the practice banners.",
[["Career", "/posts/career/career-list.html"], ["Competency status", "/home/status.html"], ["Probation objectives", "/posts/career/probation-objectives.html"]], "study"), [["Career", "/posts/career/career-list.html"], ["Competency status", "/home/status.html"], ["Probation objectives", "/posts/career/probation-objectives.html"]], "study"),
inn: landmark("inn", "Kitchen Inn", "Daily life", 1110, 285, "mara", inn: landmark("inn", "Kitchen Inn", "Daily life", 1110, 305, "mara",
"Innkeeper Mara holds three borrowed memories and cannot recall which traveller entrusted each one.", "Innkeeper Mara holds three borrowed memories and cannot recall which traveller entrusted each one.",
[["Blogs", "/blogs/blogs-list.html"], ["Weekly reviews", "/tags/review.html"], ["Blogs introduction", "/blogs/blogs-intro.html"]], "inn"), [["Blogs", "/blogs/blogs-list.html"], ["Weekly reviews", "/tags/review.html"], ["Blogs introduction", "/blogs/blogs-intro.html"]], "inn"),
workshop: landmark("workshop", "Workshop", "Living systems", 1125, 545, "pell", workshop: landmark("workshop", "Workshop", "Living systems", 1125, 590, "pell",
"Inventor Pell's recall engine has lost its cogs. A Redacted Sentinel occupies the repair yard.", "Inventor Pell's recall engine has lost its cogs. A Redacted Sentinel occupies the repair yard.",
[["Services", "/home/services.html"], ["Wird tracker", "/home/wird-tracker.html"], ["Countdowns", "/home/countdown.html"], ["Backlog", "/home/backlog.html"]], "workshop", "workshop"), [["Services", "/home/services.html"], ["Wird tracker", "/home/wird-tracker.html"], ["Countdowns", "/home/countdown.html"], ["Backlog", "/home/backlog.html"]], "workshop", "workshop"),
playroom: landmark("playroom", "Playroom", "Experiments", 1120, 805, "pip", playroom: landmark("playroom", "Playroom", "Experiments", 1120, 835, "pip",
"Gamekeeper Pip's lantern maze changes whenever a name disappears. Six patient lights still know the route.", "Gamekeeper Pip's lantern maze changes whenever a name disappears. Six patient lights still know the route.",
[["Play hub", "/play/play.html"], ["The Rain Index", "/play/the-rain-index.html"], ["House of Pages", "/play/house.html"]], "playroom", "playroom"), [["Play hub", "/play/play.html"], ["The Rain Index", "/play/the-rain-index.html"], ["House of Pages", "/play/house.html"]], "playroom", "playroom"),
museum: landmark("museum", "Lima Museum", "Keepsakes", 270, 805, "lima", museum: landmark("museum", "Lima Museum", "Keepsakes", 270, 830, "lima",
"Curator Lima can reveal what the Redactor was before the ink, but the Museum Lens is fractured.", "Curator Lima can reveal what the Redactor was before the ink, but the Museum Lens is fractured.",
[["Lima archive", "/lima/index.html"], ["Older writing", "/blogs/2025/2025-list.html"], ["Memory Cabinet", "/play/memory.html"]], "museum"), [["Lima archive", "/lima/index.html"], ["Older writing", "/blogs/2025/2025-list.html"], ["Memory Cabinet", "/play/memory.html"]], "museum"),
garden: landmark("garden", "Notes Garden", "Connections", 255, 545, "rowan", garden: landmark("garden", "Notes Garden", "Connections", 255, 545, "rowan",
@@ -115,56 +121,110 @@
const AREAS = Object.freeze({ const AREAS = Object.freeze({
town: Object.freeze({ town: Object.freeze({
width: 1448, height: 1086, background: "town", spawn: { x: 724, y: 965 }, width: 1448, height: 1086, background: "town", spawn: { x: 724, y: 965 },
movement: Object.freeze({ speed: 162, acceleration: 1280, deceleration: 1850, bootAcceleration: 1720 }),
safeSpawns: Object.freeze({ safeSpawns: Object.freeze({
gate: { x: 724, y: 965 }, square: { x: 724, y: 555 }, library: { x: 270, y: 350 }, gate: { x: 724, y: 965 }, square: { x: 724, y: 555 }, library: { x: 270, y: 350 },
study: { x: 720, y: 330 }, inn: { x: 1110, y: 350 }, workshop: { x: 1060, y: 550 }, study: { x: 720, y: 330 }, inn: { x: 1110, y: 350 }, workshop: { x: 1060, y: 615 },
playroom: { x: 1060, y: 805 }, museum: { x: 330, y: 805 }, garden: { x: 330, y: 545 } playroom: { x: 1060, y: 840 }, museum: { x: 330, y: 835 }, garden: { x: 330, y: 545 }
}), }),
safeZones: Object.freeze([
rect(650, 845, 150, 220), rect(585, 455, 280, 190)
]),
gates: Object.freeze([
Object.freeze({ id: "town-gate", x: 672, y: 850, width: 104, height: 102, requiresSigil: "gate" })
]),
collisions: Object.freeze([ collisions: Object.freeze([
rect(0, 0, 1448, 58), rect(0, 0, 58, 1086), rect(1390, 0, 58, 1086), /* Outer town wall, with a deliberate walkable opening through the gate. */
rect(0, 930, 645, 156), rect(803, 930, 645, 156), rect(0, 0, 1448, 62), rect(0, 0, 54, 1086), rect(1394, 0, 54, 1086),
rect(125, 68, 330, 230, 7), rect(580, 70, 285, 210, 7), rect(960, 70, 355, 230, 7), rect(0, 862, 590, 98), rect(590, 840, 82, 132), rect(776, 840, 86, 132), rect(862, 862, 586, 98),
rect(1010, 420, 335, 210, 7), rect(1000, 690, 345, 225, 7), /* Major buildings. Their steps and front doors remain approachable. */
rect(105, 685, 340, 230, 7), rect(85, 390, 350, 225, 7), rect(188, 62, 270, 212, 7), rect(606, 55, 238, 218, 7), rect(964, 58, 292, 218, 7),
rect(470, 75, 95, 350), rect(885, 75, 88, 350), rect(1010, 418, 330, 142, 7), rect(1002, 655, 340, 155, 7), rect(218, 620, 220, 178, 7),
rect(465, 615, 100, 275), rect(885, 615, 98, 275) /* Rivers. Breaks align with the two east/west bridge routes. */
rect(468, 88, 112, 255), rect(468, 432, 112, 93), rect(468, 610, 112, 250),
rect(868, 88, 112, 255), rect(868, 432, 112, 93), rect(868, 610, 112, 250),
/* Garden pond, dense hedges and corner ink pockets. */
rect(56, 360, 105, 292), rect(160, 382, 286, 45), rect(78, 675, 105, 175),
rect(1260, 310, 130, 108), rect(1270, 572, 120, 92), rect(1270, 812, 120, 80)
]), ]),
npcs: Object.freeze([ npcs: Object.freeze([
["orin", 724, 875], ["sera", 270, 335], ["vale", 720, 315], ["mara", 1110, 335], ["orin", 724, 875], ["sera", 270, 335], ["vale", 720, 315], ["mara", 1110, 335],
["pell", 1045, 545], ["pip", 1045, 800], ["lima", 335, 800], ["rowan", 335, 545], ["pell", 1060, 590], ["pip", 1060, 835], ["lima", 335, 830], ["rowan", 335, 545],
["nell", 760, 720], ["otho", 675, 520], ["remy", 790, 585] ["nell", 760, 720], ["otho", 675, 520], ["remy", 790, 585]
]), ]),
enemies: Object.freeze([ enemies: Object.freeze([
["ink_blot", 510, 490], ["lost_footnote", 930, 640], ["null_scribe", 520, 780], enemy("ink_blot", 635, 350, { leash: 95, engage: 210, requiresSigil: "gate", questId: "study" }),
["ink_blot", 1320, 380], ["lost_footnote", 120, 640] enemy("lost_footnote", 805, 350, { leash: 95, engage: 210, requiresSigil: "gate", questId: "study" }),
enemy("null_scribe", 720, 405, { leash: 105, engage: 280, requiresSigil: "gate", questId: "study" }),
enemy("ink_blot", 1110, 625, { leash: 105, engage: 205, requiresSigil: "gate" }),
enemy("lost_footnote", 340, 590, { leash: 75, engage: 175, requiresSigil: "gate" })
]) ])
}), }),
vault: Object.freeze({ vault: Object.freeze({
width: 724, height: 543, background: "areas", frame: 0, spawn: { x: 362, y: 485 }, width: 724, height: 543, background: "areas", frame: 0, spawn: { x: 362, y: 485 },
movement: Object.freeze({ speed: 155, acceleration: 1300, deceleration: 1900, bootAcceleration: 1740 }),
safeSpawns: Object.freeze({ entrance: { x: 362, y: 485 } }), safeSpawns: Object.freeze({ entrance: { x: 362, y: 485 } }),
collisions: Object.freeze([rect(0, 0, 724, 55), rect(0, 0, 45, 543), rect(679, 0, 45, 543), rect(0, 500, 300, 43), rect(424, 500, 300, 43)]), safeZones: Object.freeze([rect(300, 430, 124, 95)]),
collisions: Object.freeze([
rect(0, 0, 724, 32), rect(0, 0, 34, 543), rect(690, 0, 34, 543),
rect(0, 32, 278, 130), rect(446, 32, 278, 130), rect(278, 32, 168, 60),
rect(0, 472, 300, 71), rect(424, 472, 300, 71),
rect(145, 150, 100, 72), rect(479, 150, 100, 72),
rect(72, 272, 95, 70), rect(557, 272, 95, 70),
rect(145, 345, 100, 72), rect(479, 345, 100, 72)
]),
npcs: Object.freeze([]), enemies: Object.freeze([]) npcs: Object.freeze([]), enemies: Object.freeze([])
}), }),
workshop: Object.freeze({ workshop: Object.freeze({
width: 724, height: 543, background: "areas", frame: 1, spawn: { x: 362, y: 475 }, width: 724, height: 543, background: "areas", frame: 1, spawn: { x: 362, y: 475 },
movement: Object.freeze({ speed: 155, acceleration: 1300, deceleration: 1900, bootAcceleration: 1740 }),
safeSpawns: Object.freeze({ entrance: { x: 362, y: 475 } }), safeSpawns: Object.freeze({ entrance: { x: 362, y: 475 } }),
collisions: Object.freeze([rect(0, 0, 724, 48), rect(0, 0, 42, 543), rect(682, 0, 42, 543), rect(0, 500, 300, 43), rect(424, 500, 300, 43), rect(50, 50, 220, 105), rect(470, 70, 190, 95)]), safeZones: Object.freeze([rect(300, 430, 124, 95)]),
npcs: Object.freeze([]), enemies: Object.freeze([["sentinel", 365, 210]]) collisions: Object.freeze([
rect(0, 0, 724, 32), rect(0, 0, 34, 543), rect(690, 0, 34, 543),
rect(34, 32, 656, 115), rect(0, 472, 300, 71), rect(424, 472, 300, 71),
rect(34, 145, 120, 118), rect(570, 145, 120, 125),
rect(34, 342, 218, 130), rect(520, 360, 170, 112)
]),
npcs: Object.freeze([]), enemies: Object.freeze([
enemy("sentinel", 362, 245, { leash: 205, engage: 360 })
])
}), }),
playroom: Object.freeze({ playroom: Object.freeze({
width: 724, height: 543, background: "areas", frame: 2, spawn: { x: 362, y: 485 }, width: 724, height: 543, background: "areas", frame: 2, spawn: { x: 362, y: 485 },
movement: Object.freeze({ speed: 150, acceleration: 1250, deceleration: 1850, bootAcceleration: 1680 }),
safeSpawns: Object.freeze({ entrance: { x: 362, y: 485 } }), safeSpawns: Object.freeze({ entrance: { x: 362, y: 485 } }),
safeZones: Object.freeze([rect(300, 445, 124, 80)]),
collisions: Object.freeze([ collisions: Object.freeze([
rect(0, 0, 724, 45), rect(0, 0, 42, 543), rect(682, 0, 42, 543), rect(0, 500, 300, 43), rect(424, 500, 300, 43), rect(0, 0, 724, 38), rect(0, 0, 28, 543), rect(696, 0, 28, 543),
rect(100, 95, 210, 42), rect(405, 95, 210, 42), rect(200, 190, 320, 42), rect(90, 285, 220, 42), rect(410, 285, 220, 42), rect(220, 380, 285, 42) rect(0, 488, 300, 55), rect(424, 488, 300, 55),
rect(28, 38, 210, 58), rect(294, 38, 186, 58), rect(522, 38, 174, 58),
rect(147, 104, 92, 92), rect(345, 96, 72, 118), rect(520, 106, 96, 94),
rect(28, 220, 128, 72), rect(217, 214, 118, 78), rect(416, 210, 116, 82), rect(615, 220, 81, 72),
rect(112, 315, 126, 78), rect(302, 300, 94, 102), rect(492, 315, 126, 78),
rect(28, 408, 118, 80), rect(205, 408, 105, 80), rect(414, 408, 105, 80), rect(578, 408, 118, 80)
]), ]),
npcs: Object.freeze([]), enemies: Object.freeze([["lost_footnote", 120, 180], ["ink_blot", 590, 360]]) npcs: Object.freeze([]), enemies: Object.freeze([
enemy("lost_footnote", 90, 180, { leash: 60, engage: 150 }),
enemy("ink_blot", 650, 350, { leash: 55, engage: 145 })
])
}), }),
boss: Object.freeze({ boss: Object.freeze({
width: 724, height: 543, background: "areas", frame: 3, spawn: { x: 362, y: 470 }, width: 724, height: 543, background: "areas", frame: 3, spawn: { x: 362, y: 470 },
movement: Object.freeze({ speed: 158, acceleration: 1380, deceleration: 1950, bootAcceleration: 1780 }),
safeSpawns: Object.freeze({ entrance: { x: 362, y: 470 } }), safeSpawns: Object.freeze({ entrance: { x: 362, y: 470 } }),
collisions: Object.freeze([rect(0, 0, 724, 35), rect(0, 0, 35, 543), rect(689, 0, 35, 543), rect(0, 508, 285, 35), rect(439, 508, 285, 35)]), safeZones: Object.freeze([rect(310, 438, 104, 70)]),
npcs: Object.freeze([]), enemies: Object.freeze([["redactor", 362, 190]]) collisions: Object.freeze([
rect(0, 0, 724, 42), rect(0, 0, 34, 543), rect(690, 0, 34, 543),
rect(34, 42, 145, 55), rect(545, 42, 145, 55),
rect(34, 97, 48, 350), rect(642, 97, 48, 350),
rect(34, 447, 245, 64), rect(445, 447, 245, 64),
rect(196, 146, 48, 48), rect(480, 146, 48, 48),
rect(196, 326, 48, 48), rect(480, 326, 48, 48)
]),
npcs: Object.freeze([]), enemies: Object.freeze([
enemy("redactor", 362, 190, { leash: 250, engage: 500 })
])
}) })
}); });

View File

@@ -44,6 +44,9 @@
this.enemySerial = 0; this.enemySerial = 0;
this.bossPuzzle = []; this.bossPuzzle = [];
this.bossPuzzleSolved = false; this.bossPuzzleSolved = false;
this.controlLockedUntil = 0;
this.stepDistance = 0;
this.previousPlayerPosition = null;
} }
init(data) { init(data) {
@@ -55,6 +58,7 @@
const area = Data.AREAS[this.areaId]; const area = Data.AREAS[this.areaId];
this.area = area; this.area = area;
this.physics.world.setBounds(0, 0, area.width, area.height); this.physics.world.setBounds(0, 0, area.width, area.height);
this.cameras.main.setBackgroundColor("#0b1518");
if (area.background === "town") this.add.image(0, 0, "town").setOrigin(0).setDepth(0); if (area.background === "town") this.add.image(0, 0, "town").setOrigin(0).setDepth(0);
else this.add.image(0, 0, "areas", area.frame).setOrigin(0).setDepth(0); else this.add.image(0, 0, "areas", area.frame).setOrigin(0).setDepth(0);
@@ -67,6 +71,17 @@
this.physics.add.existing(block, true); this.physics.add.existing(block, true);
this.collisions.add(block); this.collisions.add(block);
}); });
this.gateBlocks = [];
(area.gates || []).forEach((gate) => {
if (controller.getState().sigils.includes(gate.requiresSigil)) return;
const block = this.add.rectangle(
gate.x + gate.width / 2, gate.y + gate.height / 2,
gate.width, gate.height, 0xf4c96b, debug ? 0.2 : 0
).setDepth(debug ? 51 : 2);
this.physics.add.existing(block, true);
this.collisions.add(block);
this.gateBlocks.push({ gate, block });
});
this.createAnimations(); this.createAnimations();
this.npcSprites = []; this.npcSprites = [];
@@ -78,7 +93,8 @@
this.player = this.physics.add.sprite(safe.x, safe.y, `traveler-${state.palette}`, frameFor(this.lastFacing)) this.player = this.physics.add.sprite(safe.x, safe.y, `traveler-${state.palette}`, frameFor(this.lastFacing))
.setScale(0.3).setDepth(20); .setScale(0.3).setDepth(20);
this.player.body.setSize(72, 42).setOffset(92, 190); this.player.body.setSize(72, 42).setOffset(92, 190);
this.player.setCollideWorldBounds(true).setDrag(900, 900).setMaxVelocity(190, 190); this.player.setCollideWorldBounds(true).setDrag(0, 0).setMaxVelocity(230, 230);
this.previousPlayerPosition = { x: this.player.x, y: this.player.y };
this.physics.add.collider(this.player, this.collisions); this.physics.add.collider(this.player, this.collisions);
this.interactables = []; this.interactables = [];
@@ -98,8 +114,10 @@
}); });
this.cameras.main.setBounds(0, 0, area.width, area.height); this.cameras.main.setBounds(0, 0, area.width, area.height);
this.cameras.main.setZoom(this.areaId === "town" ? 1 : 1.32);
this.cameras.main.startFollow(this.player, true, this.cameras.main.startFollow(this.player, true,
controller.reducedMotion() ? 1 : 0.14, controller.reducedMotion() ? 1 : 0.14); controller.reducedMotion() ? 1 : 0.24, controller.reducedMotion() ? 1 : 0.24);
this.cameras.main.setDeadzone(72, 48);
controller.audio.attach(this, this.areaId); controller.audio.attach(this, this.areaId);
controller.renderHud(); controller.renderHud();
controller.status(this.areaId === "town" controller.status(this.areaId === "town"
@@ -152,17 +170,17 @@
this.interactables.push(npc.getData("interaction")); this.interactables.push(npc.getData("interaction"));
if (!controller.reducedMotion()) this.tweens.add({ targets: npc, y: y - 2, duration: 1200 + Data.NPCS[id].frame * 30, yoyo: true, repeat: -1 }); if (!controller.reducedMotion()) this.tweens.add({ targets: npc, y: y - 2, duration: 1200 + Data.NPCS[id].frame * 30, yoyo: true, repeat: -1 });
}); });
this.addChest("town-garden", 115, 550, "memory_fragment", 2); this.addChest("town-garden", 205, 565, "memory_fragment", 2);
this.addChest("town-wall", 1280, 870, "ink_vial", 2); this.addChest("town-wall", 1235, 840, "ink_vial", 2);
} else { } else {
this.addPortal("town", this.area.width / 2, this.area.height - 38, "Return to Archive Town"); this.addPortal("town", this.area.width / 2, this.area.height - 38, "Return to Archive Town");
if (this.areaId === "vault") { if (this.areaId === "vault") {
this.addPortal("boss", this.area.width / 2, 95, controller.getState().story.ending ? "Visit the restored core" : "Confront The Redactor"); this.addPortal("boss", this.area.width / 2, 120, controller.getState().story.ending ? "Visit the restored core" : "Confront The Redactor");
this.addChest("vault-lore", 100, 420, "lore_page", 2); this.addChest("vault-lore", 100, 420, "lore_page", 2);
} }
if (this.areaId === "playroom") { if (this.areaId === "playroom") {
this.interactables.push({ type: "challenge", id: "playroom", x: this.area.width / 2, y: 110 }); this.interactables.push({ type: "challenge", id: "playroom", x: this.area.width / 2, y: 255 });
this.add.circle(this.area.width / 2, 110, 22, 0xf8d879, 0.45).setDepth(5); this.add.circle(this.area.width / 2, 255, 22, 0xf8d879, 0.45).setDepth(5);
} }
if (this.areaId === "boss") this.createBossPedestals(); if (this.areaId === "boss") this.createBossPedestals();
} }
@@ -197,19 +215,26 @@
createEnemies() { createEnemies() {
this.enemies = this.physics.add.group(); this.enemies = this.physics.add.group();
this.physics.add.collider(this.enemies, this.collisions); this.physics.add.collider(this.enemies, this.collisions);
this.area.enemies.forEach(([type, x, y]) => { this.physics.add.collider(this.enemies, this.enemies);
this.area.enemies.forEach((entry) => {
const spawn = Array.isArray(entry)
? { type: entry[0], x: entry[1], y: entry[2] }
: entry;
const { type, x, y } = spawn;
if ((type === "sentinel" || type === "redactor") && controller.getState().defeatedBosses.includes(type)) return; if ((type === "sentinel" || type === "redactor") && controller.getState().defeatedBosses.includes(type)) return;
this.spawnEnemy(type, x, y); this.spawnEnemy(type, x, y, spawn);
}); });
} }
spawnEnemy(type, x, y) { spawnEnemy(type, x, y, spawn) {
const spec = Data.ENEMIES[type]; const spec = Data.ENEMIES[type];
const enemy = this.physics.add.sprite(x, y, "cast", spec.frame).setScale(spec.boss ? 0.31 : 0.21).setDepth(y + 30); const enemy = this.physics.add.sprite(x, y, "cast", spec.frame).setScale(spec.boss ? 0.31 : 0.21).setDepth(y + 30);
enemy.body.setSize(spec.boss ? 155 : 115, spec.boss ? 85 : 65).setOffset(spec.boss ? 79 : 99, spec.boss ? 196 : 200); enemy.body.setSize(spec.boss ? 155 : 115, spec.boss ? 85 : 65).setOffset(spec.boss ? 79 : 99, spec.boss ? 196 : 200);
enemy.setData({ enemy.setData({
id: `${type}-${++this.enemySerial}`, type, spec, health: spec.health, id: `${type}-${++this.enemySerial}`, type, spec, health: spec.health,
originX: x, originY: y, nextAction: this.time.now + 900, phase: 1 originX: x, originY: y, leash: spawn && spawn.leash || 150,
engage: spawn && spawn.engage || 245, requiresSigil: spawn && spawn.requiresSigil || null,
questId: spawn && spawn.questId || null, nextAction: this.time.now + 900, phase: 1
}); });
this.enemies.add(enemy); this.enemies.add(enemy);
return enemy; return enemy;
@@ -218,10 +243,15 @@
update(time, delta) { update(time, delta) {
if (!this.player) return; if (!this.player) return;
if (controller.locked()) { if (controller.locked()) {
this.player.setAcceleration(0).setVelocity(0); this.player.setVelocity(0);
this.player.anims.stop(); this.player.anims.stop();
return; return;
} }
const traveled = Math.hypot(
this.player.x - this.previousPlayerPosition.x,
this.player.y - this.previousPlayerPosition.y
);
this.previousPlayerPosition = { x: this.player.x, y: this.player.y };
let dx = 0; let dx = 0;
let dy = 0; let dy = 0;
if (this.cursors.left.isDown || this.keys.left.isDown || controller.move.left) dx -= 1; if (this.cursors.left.isDown || this.keys.left.isDown || controller.move.left) dx -= 1;
@@ -230,20 +260,33 @@
if (this.cursors.down.isDown || this.keys.down.isDown || controller.move.down) dy += 1; if (this.cursors.down.isDown || this.keys.down.isDown || controller.move.down) dy += 1;
const moving = dx !== 0 || dy !== 0; const moving = dx !== 0 || dy !== 0;
const state = controller.getState(); const state = controller.getState();
const acceleration = state.equipment.boots ? 980 : 760; this.gateBlocks = this.gateBlocks.filter((entry) => {
if (moving) { if (!state.sigils.includes(entry.gate.requiresSigil)) return true;
const vector = Systems.normalizedVector(dx, dy, acceleration); entry.block.destroy();
this.player.setAcceleration(vector.x, vector.y); controller.status("The Town Gate opens. Archive Town is ready to be explored.");
return false;
});
const acceptingMovement = time >= this.controlLockedUntil;
if (acceptingMovement) {
const velocity = Systems.approachVelocity(
this.player.body.velocity.x, this.player.body.velocity.y,
dx, dy, this.area.movement, delta, state.equipment.boots
);
this.player.setVelocity(velocity.x, velocity.y);
}
if (moving && acceptingMovement) {
this.lastFacing = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south"); this.lastFacing = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south");
this.player.anims.play(`traveler-${state.palette}-walk-${this.lastFacing}`, true); this.player.anims.play(`traveler-${state.palette}-walk-${this.lastFacing}`, true);
if (state.settings.soundEnabled && time - this.lastStepAt > 350) { this.stepDistance += traveled;
if (state.settings.soundEnabled && this.stepDistance >= 44) {
controller.audio.play("step", { rate: 0.96 + Math.random() * 0.08 }); controller.audio.play("step", { rate: 0.96 + Math.random() * 0.08 });
this.lastStepAt = time; this.lastStepAt = time;
this.stepDistance = 0;
} }
} else { } else {
this.player.setAcceleration(0);
this.player.anims.stop(); this.player.anims.stop();
this.player.setFrame(frameFor(this.lastFacing)); this.player.setFrame(frameFor(this.lastFacing));
if (!moving) this.stepDistance = 0;
} }
this.player.setDepth(this.player.y + 60); this.player.setDepth(this.player.y + 60);
this.npcSprites.forEach((npc) => npc.setFlipX(this.player.x < npc.x)); this.npcSprites.forEach((npc) => npc.setFlipX(this.player.x < npc.x));
@@ -351,7 +394,7 @@
controller.audio.play(type === "redactor" ? "victory" : "defeat"); controller.audio.play(type === "redactor" ? "victory" : "defeat");
let state = Systems.grantXp(controller.getState(), spec.xp); let state = Systems.grantXp(controller.getState(), spec.xp);
if (type === "sentinel" || type === "redactor") state = Systems.recordBoss(state, type); if (type === "sentinel" || type === "redactor") state = Systems.recordBoss(state, type);
if (state.quests.study.status === "active" && this.areaId === "town") { if (enemy.getData("questId") === "study" && state.quests.study.status === "active") {
state = Systems.progressQuest(state, "study", 1); state = Systems.progressQuest(state, "study", 1);
if (state.quests.study.count >= Data.QUESTS.study.target) state = Systems.completeQuest(state, "study"); if (state.quests.study.count >= Data.QUESTS.study.target) state = Systems.completeQuest(state, "study");
} }
@@ -368,22 +411,40 @@
this.enemies.getChildren().forEach((enemy) => { this.enemies.getChildren().forEach((enemy) => {
if (!enemy.active) return; if (!enemy.active) return;
const spec = enemy.getData("spec"); const spec = enemy.getData("spec");
const required = enemy.getData("requiresSigil");
const available = !required || controller.getState().sigils.includes(required);
enemy.setVisible(available);
enemy.body.enable = available;
if (!available) return;
const distance = Phaser.Math.Distance.Between(enemy.x, enemy.y, this.player.x, this.player.y); const distance = Phaser.Math.Distance.Between(enemy.x, enemy.y, this.player.x, this.player.y);
const homeDistance = Phaser.Math.Distance.Between(
enemy.x, enemy.y, enemy.getData("originX"), enemy.getData("originY")
);
const playerInSafeZone = Systems.isCombatSafe(this.areaId, this.player.x, this.player.y);
if (playerInSafeZone || homeDistance > enemy.getData("leash")) {
if (homeDistance > 8) {
this.physics.moveTo(enemy, enemy.getData("originX"), enemy.getData("originY"), spec.speed);
} else {
enemy.setPosition(enemy.getData("originX"), enemy.getData("originY")).setVelocity(0);
}
enemy.setDepth(enemy.y + 50);
return;
}
if (spec.behaviour === "chase") { if (spec.behaviour === "chase") {
if (distance < 280) this.physics.moveToObject(enemy, this.player, spec.speed); if (distance < enemy.getData("engage")) this.physics.moveToObject(enemy, this.player, spec.speed);
else enemy.setVelocity(0); else this.returnEnemyHome(enemy, spec.speed);
} else if (spec.behaviour === "wander") { } else if (spec.behaviour === "wander") {
if (time > enemy.getData("nextAction")) { if (distance < enemy.getData("engage") && time > enemy.getData("nextAction")) {
enemy.setData("nextAction", time + 650 + Math.random() * 800); enemy.setData("nextAction", time + 650 + Math.random() * 800);
const angle = Math.random() * Math.PI * 2; const angle = Math.random() * Math.PI * 2;
enemy.setVelocity(Math.cos(angle) * spec.speed, Math.sin(angle) * spec.speed); enemy.setVelocity(Math.cos(angle) * spec.speed, Math.sin(angle) * spec.speed);
} } else if (distance >= enemy.getData("engage")) this.returnEnemyHome(enemy, spec.speed);
} else if (spec.behaviour === "ranged") { } else if (spec.behaviour === "ranged") {
if (distance < 340 && time > enemy.getData("nextAction")) { if (distance < enemy.getData("engage") && time > enemy.getData("nextAction")) {
enemy.setData("nextAction", time + (controller.getState().settings.assist ? 1900 : 1350)); enemy.setData("nextAction", time + (controller.getState().settings.assist ? 1900 : 1350));
this.fireProjectile(enemy, spec.damage); this.fireProjectile(enemy, spec.damage);
} else if (distance < 170) this.physics.moveToObject(enemy, this.player, -spec.speed); } else if (distance < 170) this.physics.moveToObject(enemy, this.player, -spec.speed);
else enemy.setVelocity(0); else this.returnEnemyHome(enemy, spec.speed);
} else if (spec.behaviour === "charge") { } else if (spec.behaviour === "charge") {
if (time > enemy.getData("nextAction")) { if (time > enemy.getData("nextAction")) {
enemy.setTint(0xffd36e); enemy.setTint(0xffd36e);
@@ -403,6 +464,14 @@
}); });
} }
returnEnemyHome(enemy, speed) {
const distance = Phaser.Math.Distance.Between(
enemy.x, enemy.y, enemy.getData("originX"), enemy.getData("originY")
);
if (distance > 8) this.physics.moveTo(enemy, enemy.getData("originX"), enemy.getData("originY"), speed);
else enemy.setVelocity(0);
}
updateRedactor(enemy, time, distance) { updateRedactor(enemy, time, distance) {
const health = enemy.getData("health"); const health = enemy.getData("health");
const phase = health > 20 ? 1 : health > 10 ? 2 : 3; const phase = health > 20 ? 1 : health > 10 ? 2 : 3;
@@ -477,7 +546,7 @@
} }
damagePlayer(amount, fromX, fromY) { damagePlayer(amount, fromX, fromY) {
if (this.areaId === "town" && isTownSafe(this.player.x, this.player.y)) return; if (Systems.isCombatSafe(this.areaId, this.player.x, this.player.y)) return;
const result = Systems.takeDamage(controller.getState(), amount, this.time.now, this.lastHitAt); const result = Systems.takeDamage(controller.getState(), amount, this.time.now, this.lastHitAt);
if (!result.hit) return; if (!result.hit) return;
this.lastHitAt = this.time.now; this.lastHitAt = this.time.now;
@@ -485,6 +554,7 @@
controller.audio.play("damage"); controller.audio.play("damage");
const direction = Systems.normalizedVector(this.player.x - fromX, this.player.y - fromY, 220); const direction = Systems.normalizedVector(this.player.x - fromX, this.player.y - fromY, 220);
this.player.setVelocity(direction.x, direction.y).setTint(0xffb2b2); this.player.setVelocity(direction.x, direction.y).setTint(0xffb2b2);
this.controlLockedUntil = this.time.now + 180;
this.time.delayedCall(160, () => { if (this.player.active) this.player.clearTint(); }); this.time.delayedCall(160, () => { if (this.player.active) this.player.clearTint(); });
if (!controller.reducedMotion()) this.cameras.main.shake(90, 0.003); if (!controller.reducedMotion()) this.cameras.main.shake(90, 0.003);
if (result.defeated) { if (result.defeated) {
@@ -501,8 +571,8 @@
persistPosition(checkpoint) { persistPosition(checkpoint) {
if (!this.player) return; if (!this.player) return;
let state = StateWithSafe(controller.getState(), this.areaId, this.player.x, this.player.y, this.lastFacing); let state = StateWithSafe(controller.getState(), this.areaId, this.player.x, this.player.y, this.lastFacing);
if (checkpoint || this.areaId === "town") { if (checkpoint || Systems.isCombatSafe(this.areaId, this.player.x, this.player.y)) {
const safe = Systems.nearestSafeSpawn(this.areaId, this.player.x, this.player.y); const safe = Systems.nearestNamedSafeSpawn(this.areaId, this.player.x, this.player.y);
state = root.ArchiveWorldState.withCheckpoint(state, safe.area, safe.spawn, safe.x, safe.y); state = root.ArchiveWorldState.withCheckpoint(state, safe.area, safe.spawn, safe.x, safe.y);
} }
controller.setState(state, null, true); controller.setState(state, null, true);
@@ -538,9 +608,5 @@
return String(value).replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); return String(value).replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
} }
function isTownSafe(x, y) {
return y > 835 || (x > 570 && x < 875 && y > 405 && y < 690);
}
root.ArchiveWorldScenes = Object.freeze({ createSceneClasses }); root.ArchiveWorldScenes = Object.freeze({ createSceneClasses });
}(typeof globalThis !== "undefined" ? globalThis : this)); }(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -18,6 +18,30 @@
return { x: (x / length) * speed, y: (y / length) * speed }; return { x: (x / length) * speed, y: (y / length) * speed };
} }
function moveToward(value, target, maximumDelta) {
if (Math.abs(target - value) <= maximumDelta) return target;
return value + Math.sign(target - value) * maximumDelta;
}
function approachVelocity(currentX, currentY, inputX, inputY, movement, deltaMs, hasBoots) {
const config = movement || {};
const speed = Math.max(1, Number(config.speed) || 160);
const moving = Boolean(Number(inputX) || Number(inputY));
const target = moving ? normalizedVector(inputX, inputY, speed) : { x: 0, y: 0 };
const rate = moving
? (hasBoots ? Number(config.bootAcceleration) || 1700 : Number(config.acceleration) || 1300)
: Number(config.deceleration) || 1900;
const step = Math.max(0, Math.min(50, Number(deltaMs) || 0)) / 1000 * rate;
let x = moveToward(Number(currentX) || 0, target.x, step);
let y = moveToward(Number(currentY) || 0, target.y, step);
const length = Math.hypot(x, y);
if (length > speed) {
x = x / length * speed;
y = y / length * speed;
}
return { x, y };
}
function discover(state, landmarkId) { function discover(state, landmarkId) {
const next = copy(state); const next = copy(state);
if (!next || !Data.LANDMARK_IDS.includes(landmarkId)) return next; if (!next || !Data.LANDMARK_IDS.includes(landmarkId)) return next;
@@ -164,11 +188,22 @@
return !area.collisions.some((item) => pointInRect(x, y, item, 10)); return !area.collisions.some((item) => pointInRect(x, y, item, 10));
} }
function isCombatSafe(areaId, x, y) {
const area = Data.AREAS[areaId];
return Boolean(area && (area.safeZones || []).some((item) => pointInRect(x, y, item, 0)));
}
function nearestSafeSpawn(areaId, x, y) { function nearestSafeSpawn(areaId, x, y) {
const area = Data.AREAS[areaId] || Data.AREAS.town; const area = Data.AREAS[areaId] || Data.AREAS.town;
const points = Object.entries(area.safeSpawns || { entrance: area.spawn }); const points = Object.entries(area.safeSpawns || { entrance: area.spawn });
const safeCurrent = isSafe(areaId, x, y); const safeCurrent = isSafe(areaId, x, y);
if (safeCurrent) return { area: areaId, spawn: "saved", x, y }; if (safeCurrent) return { area: areaId, spawn: "saved", x, y };
return nearestNamedSafeSpawn(areaId, x, y);
}
function nearestNamedSafeSpawn(areaId, x, y) {
const area = Data.AREAS[areaId] || Data.AREAS.town;
const points = Object.entries(area.safeSpawns || { entrance: area.spawn });
const best = points.sort((a, b) => const best = points.sort((a, b) =>
Math.hypot(a[1].x - x, a[1].y - y) - Math.hypot(b[1].x - x, b[1].y - y))[0]; Math.hypot(a[1].x - x, a[1].y - y) - Math.hypot(b[1].x - x, b[1].y - y))[0];
return { area: areaId, spawn: best[0], x: best[1].x, y: best[1].y }; return { area: areaId, spawn: best[0], x: best[1].x, y: best[1].y };
@@ -186,7 +221,8 @@
} }
return Object.freeze({ return Object.freeze({
normalizedVector, discover, startQuest, progressQuest, completeQuest, updateStory, addItem, itemQuantity, normalizedVector, approachVelocity, discover, startQuest, progressQuest, completeQuest, updateStory, addItem, itemQuantity,
consumeItem, grantXp, takeDamage, respawn, recordBoss, isSafe, nearestSafeSpawn, currentObjective consumeItem, grantXp, takeDamage, respawn, recordBoss, isSafe, isCombatSafe,
nearestSafeSpawn, nearestNamedSafeSpawn, currentObjective
}); });
})); }));

View File

@@ -80,6 +80,8 @@
else if (questState.status === "active") { else if (questState.status === "active") {
action = quest.kind === "combat" action = quest.kind === "combat"
? `<p class="rpg-panel__note">Combat progress: ${questState.count} / ${quest.target}</p><button type="button" data-action="return">Return to the fight</button>` ? `<p class="rpg-panel__note">Combat progress: ${questState.count} / ${quest.target}</p><button type="button" data-action="return">Return to the fight</button>`
: id === "playroom"
? `<button type="button" data-action="travel" data-area="playroom">Enter the lantern maze</button>`
: quest.kind === "boss" : quest.kind === "boss"
? `<button type="button" data-action="travel" data-area="workshop">Enter the repair yard</button>` ? `<button type="button" data-action="travel" data-area="workshop">Enter the repair yard</button>`
: `<button type="button" data-action="challenge" data-id="${place.questId}">Continue challenge</button>`; : `<button type="button" data-action="challenge" data-id="${place.questId}">Continue challenge</button>`;

View File

@@ -51,7 +51,7 @@
const loaded = loadState(); const loaded = loadState();
if (loaded.state) { if (loaded.state) {
controller.state = loaded.state; controller.state = developmentAreaOverride(loaded.state);
renderHud(); renderHud();
startGame(); startGame();
if (loaded.message) status(loaded.message); if (loaded.message) status(loaded.message);
@@ -60,6 +60,15 @@
} }
} }
function developmentAreaOverride(state) {
const local = ["localhost", "127.0.0.1"].includes(window.location.hostname);
const params = new URLSearchParams(window.location.search);
const areaId = params.get("collisionDebug") === "1" ? params.get("area") : null;
if (!local || !Data.AREAS[areaId]) return state;
const spawn = Data.AREAS[areaId].spawn;
return State.withPosition(state, areaId, spawn.x, spawn.y, "north", "entrance");
}
function loadState() { function loadState() {
try { try {
const rawV2 = localStorage.getItem(State.STORAGE_KEY); const rawV2 = localStorage.getItem(State.STORAGE_KEY);

View File

@@ -772,3 +772,9 @@ at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/build-logs/gite
2026-07-30T01:01:00.6776757+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python. 2026-07-30T01:01:00.6776757+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-30T01:01:00.7510165+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0 2026-07-30T01:01:00.7510165+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-30T01:01:01.0172871+01:00 [INFO] Sent authoring server test notification. 2026-07-30T01:01:01.0172871+01:00 [INFO] Sent authoring server test notification.
2026-07-30T09:08:53.9348501+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-30T09:08:53.9423657+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-30T09:08:55.0348149+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-30T09:08:55.0548108+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-30T09:08:55.1356472+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-30T09:08:55.4335778+01:00 [INFO] Sent authoring server test notification.

View File

@@ -142,12 +142,12 @@
<noscript><p class="archive-world__noscript">The RPG needs JavaScript, but every destination remains available in the plain directory above.</p></noscript> <noscript><p class="archive-world__noscript">The RPG needs JavaScript, but every destination remains available in the plain directory above.</p></noscript>
</main> </main>
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=archive-world-2.0.1" defer></script> <script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=archive-world-2.1.1" defer></script>
<script src="/assets/scripts/pages/archive-world-data.js?v=archive-world-2.0.1" defer></script> <script src="/assets/scripts/pages/archive-world-data.js?v=archive-world-2.1.1" defer></script>
<script src="/assets/scripts/pages/archive-world-state.js?v=archive-world-2.0.1" defer></script> <script src="/assets/scripts/pages/archive-world-state.js?v=archive-world-2.1.1" defer></script>
<script src="/assets/scripts/pages/archive-world-systems.js?v=archive-world-2.0.1" defer></script> <script src="/assets/scripts/pages/archive-world-systems.js?v=archive-world-2.1.1" defer></script>
<script src="/assets/scripts/pages/archive-world-audio.js?v=archive-world-2.0.1" defer></script> <script src="/assets/scripts/pages/archive-world-audio.js?v=archive-world-2.1.1" defer></script>
<script src="/assets/scripts/pages/archive-world-ui.js?v=archive-world-2.0.1" defer></script> <script src="/assets/scripts/pages/archive-world-ui.js?v=archive-world-2.1.1" defer></script>
<script src="/assets/scripts/pages/archive-world-scenes.js?v=archive-world-2.0.1" defer></script> <script src="/assets/scripts/pages/archive-world-scenes.js?v=archive-world-2.1.1" defer></script>
<script src="/assets/scripts/pages/archive-world.js?v=archive-world-2.0.1" defer></script> <script src="/assets/scripts/pages/archive-world.js?v=archive-world-2.1.1" defer></script>
#+END_EXPORT #+END_EXPORT

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@ See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts: * Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">29-07-2026 21:47</span>@@ - [[file:career/career-list.org][Career List]] @@html:<span class="post-date">30-07-2026 01:00</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/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/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>@@ - [[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

@@ -104,13 +104,13 @@ flowchart TD
n43 --> n49 n43 --> n49
n50["Tag: website"] n50["Tag: website"]
n43 --> n50 n43 --> n50
n51["Tag: update"] n51["Tag: life"]
n43 --> n51 n43 --> n51
n52["Tag: life"] n52["Tag: update"]
n43 --> n52 n43 --> n52
n53["Tag: education"] n53["Tag: insights"]
n43 --> n53 n43 --> n53
n54["Tag: insights"] n54["Tag: education"]
n43 --> n54 n43 --> n54
n55["Tag: reading"] n55["Tag: reading"]
n43 --> n55 n43 --> n55
@@ -224,10 +224,10 @@ flowchart TD
click n48 "tags/notes.html" "Tag: notes" click n48 "tags/notes.html" "Tag: notes"
click n49 "tags/review.html" "Tag: review" click n49 "tags/review.html" "Tag: review"
click n50 "tags/website.html" "Tag: website" click n50 "tags/website.html" "Tag: website"
click n51 "tags/update.html" "Tag: update" click n51 "tags/life.html" "Tag: life"
click n52 "tags/life.html" "Tag: life" click n52 "tags/update.html" "Tag: update"
click n53 "tags/education.html" "Tag: education" click n53 "tags/insights.html" "Tag: insights"
click n54 "tags/insights.html" "Tag: insights" click n54 "tags/education.html" "Tag: education"
click n55 "tags/reading.html" "Tag: reading" click n55 "tags/reading.html" "Tag: reading"
click n56 "tags/emacs.html" "Tag: emacs" click n56 "tags/emacs.html" "Tag: emacs"
click n57 "tags/maths.html" "Tag: maths" click n57 "tags/maths.html" "Tag: maths"
@@ -318,10 +318,10 @@ flowchart TD
- [[file:tags/notes.org][Tag: notes]] - [[file:tags/notes.org][Tag: notes]]
- [[file:tags/review.org][Tag: review]] - [[file:tags/review.org][Tag: review]]
- [[file:tags/website.org][Tag: website]] - [[file:tags/website.org][Tag: website]]
- [[file:tags/update.org][Tag: update]]
- [[file:tags/life.org][Tag: life]] - [[file:tags/life.org][Tag: life]]
- [[file:tags/education.org][Tag: education]] - [[file:tags/update.org][Tag: update]]
- [[file:tags/insights.org][Tag: insights]] - [[file:tags/insights.org][Tag: insights]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/reading.org][Tag: reading]] - [[file:tags/reading.org][Tag: reading]]
- [[file:tags/emacs.org][Tag: emacs]] - [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/maths.org][Tag: maths]] - [[file:tags/maths.org][Tag: maths]]

View File

@@ -11,6 +11,24 @@ test("movement normalization prevents faster diagonals", () => {
assert.equal(Math.round(Math.hypot(diagonal.x, diagonal.y)), 175); assert.equal(Math.round(Math.hypot(diagonal.x, diagonal.y)), 175);
}); });
test("movement acceleration is frame-rate aware, responsive, and speed capped", () => {
const movement = data.AREAS.town.movement;
const first = systems.approachVelocity(0, 0, 1, 1, movement, 16, false);
assert.ok(Math.hypot(first.x, first.y) > 0);
assert.ok(Math.hypot(first.x, first.y) < movement.speed);
let velocity = { x: 0, y: 0 };
for (let frame = 0; frame < 120; frame += 1) {
velocity = systems.approachVelocity(velocity.x, velocity.y, 1, 1, movement, 16, false);
}
assert.equal(Math.round(Math.hypot(velocity.x, velocity.y)), movement.speed);
for (let frame = 0; frame < 20; frame += 1) {
velocity = systems.approachVelocity(velocity.x, velocity.y, 0, 0, movement, 16, false);
}
assert.deepEqual(velocity, { x: 0, y: 0 });
});
test("landmark discoveries deduplicate and complete at eight", () => { test("landmark discoveries deduplicate and complete at eight", () => {
let current = state.fresh("Ada", "brass"); let current = state.fresh("Ada", "brass");
current = systems.discover(current, "gate"); current = systems.discover(current, "gate");
@@ -83,3 +101,27 @@ test("invalid collision positions resolve to a named safe spawn", () => {
assert.equal(systems.isSafe(safe.area, safe.x, safe.y), true); assert.equal(systems.isSafe(safe.area, safe.x, safe.y), true);
assert.ok(["gate", "square", "library", "study", "inn", "workshop", "playroom", "museum", "garden"].includes(safe.spawn)); assert.ok(["gate", "square", "library", "study", "inn", "workshop", "playroom", "museum", "garden"].includes(safe.spawn));
}); });
test("all declared safe spawns and enemy homes are outside collision geometry", () => {
Object.entries(data.AREAS).forEach(([areaId, area]) => {
Object.entries(area.safeSpawns).forEach(([spawnId, spawn]) => {
assert.equal(systems.isSafe(areaId, spawn.x, spawn.y), true, `${areaId}:${spawnId}`);
});
area.enemies.forEach((entry) => {
const spawn = Array.isArray(entry)
? { type: entry[0], x: entry[1], y: entry[2] }
: entry;
assert.equal(systems.isSafe(areaId, spawn.x, spawn.y), true, `${areaId}:${spawn.type}`);
assert.equal(systems.isCombatSafe(areaId, spawn.x, spawn.y), false, `${areaId}:${spawn.type} is in a safe zone`);
});
});
});
test("town landmarks and NPC interaction points remain reachable", () => {
Object.values(data.LANDMARKS).forEach((landmark) => {
assert.equal(systems.isSafe("town", landmark.x, landmark.y), true, `landmark:${landmark.id}`);
});
data.AREAS.town.npcs.forEach(([npcId, x, y]) => {
assert.equal(systems.isSafe("town", x, y), true, `npc:${npcId}`);
});
});