Refactor org web platform and remove legacy code
All checks were successful
Build Org Website / build (push) Successful in 39s

This commit is contained in:
gitea-actions
2026-07-30 11:29:20 +01:00
parent dc1ca5c00e
commit fe8acac707
74 changed files with 2776 additions and 3386 deletions

View File

@@ -274,7 +274,7 @@ The published HTML is static, but some browser modules rely on same-origin APIs:
| Notes board | `pages/notes.js` | `/api/notes` |
| Wird tracker | `pages/wird-tracker.js` | `/api/wird`, `/api/wird/motalah`, `/api/calibre/books` |
| Competency board | `pages/competency-status-board.js` | `/api/competencies/items...` |
| Archive World progress | `pages/archive-world.js` | Device-local `localStorage` state |
| Princess Lima RPG progress | `pages/princess-lima-game.js` | Device-local `localStorage` state |
These APIs are not implemented here. Production routing must serve `output/` and proxy `/api/` to the appropriate backend. When changing an endpoint contract, coordinate the static client and its external service.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -1,69 +0,0 @@
(function (root) {
"use strict";
const KEYS = Object.freeze({
ambient: ["ambient-v2", "ambience"],
vault: ["vault-ambient", "ambience"],
step: ["step-v2", "effects"],
attack: ["attack", "effects"],
damage: ["damage", "effects"],
defeat: ["enemy-defeat", "effects"],
item: ["item", "effects"],
quest: ["quest", "effects"],
dialogue: ["dialogue", "effects"],
puzzle: ["puzzle", "effects"],
boss: ["boss-attack", "effects"],
victory: ["victory", "effects"],
portal: ["portal-v2", "effects"]
});
function create(getState) {
let scene = null;
let ambience = null;
let unlocked = false;
function attach(nextScene, area) {
scene = nextScene;
if (ambience && ambience.isPlaying) ambience.stop();
ambience = scene.sound.add(area === "town" ? "ambient-v2" : "vault-ambient", { loop: true });
apply();
}
function unlock() {
unlocked = true;
if (scene && scene.sound.locked && scene.sound.unlock) scene.sound.unlock();
apply();
}
function apply() {
if (!scene) return;
const state = getState();
const enabled = Boolean(unlocked && state && state.settings.soundEnabled);
scene.sound.mute = !enabled;
if (ambience) {
ambience.setVolume(state ? state.settings.ambience : 0.35);
if (enabled && !ambience.isPlaying) ambience.play();
if (!enabled && ambience.isPlaying) ambience.pause();
}
}
function play(name, config) {
if (!scene || !unlocked) return;
const state = getState();
const spec = KEYS[name];
if (!state || !state.settings.soundEnabled || !spec || !scene.cache.audio.exists(spec[0])) return;
const volume = state.settings[spec[1]] * ((config && config.volume) || 1);
scene.sound.play(spec[0], { volume, rate: config && config.rate || 1 });
}
function stop() {
if (ambience && ambience.isPlaying) ambience.stop();
ambience = null;
scene = null;
}
return Object.freeze({ attach, unlock, apply, play, stop, isUnlocked: () => unlocked });
}
root.ArchiveWorldAudio = Object.freeze({ create, KEYS });
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -1,249 +0,0 @@
(function (root) {
"use strict";
function rect(x, y, width, height, depth) {
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) {
return Object.freeze({ id, title, eyebrow, x, y, npc, description, links, questId, area: area || null });
}
const LANDMARKS = Object.freeze({
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.",
[["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"]], "gate"),
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.",
[["Posts", "/posts/posts-list.html"], ["Categories", "/home/categories.html"], ["Posts introduction", "/posts/posts-intro.html"]], "library", "vault"),
study: landmark("study", "Guild Study", "Work", 720, 300, "vale",
"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"),
inn: landmark("inn", "Kitchen Inn", "Daily life", 1110, 305, "mara",
"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"),
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.",
[["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, 835, "pip",
"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"),
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.",
[["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",
"Gardener Rowan tends standing stones that preserve connections no catalogue can hold alone.",
[["Notes wall", "/home/notes.html"], ["Categories", "/home/categories.html"], ["Recently updated", "/recently-updated.html"], ["Sitemap", "/sitemap.html"]], "garden")
});
const NPCS = Object.freeze({
orin: Object.freeze({ name: "Gatekeeper Orin", frame: 0, landmark: "gate",
intro: "Your name held when the gate tried to erase it. That Living Bookmark chose well.",
hint: "Read the three arrival plaques from oldest memory to newest: Home, Contact, Return." }),
sera: Object.freeze({ name: "Head Archivist Sera", frame: 1, landmark: "library",
intro: "Books are not silent. These shelves are shouting their symbols out of order.",
hint: "A story begins with a seed, grows a branch, becomes a page, then earns its star." }),
vale: Object.freeze({ name: "Instructor Vale", frame: 2, landmark: "study",
intro: "A lesson remembered under pressure becomes a skill. Hold this square against the Nulls.",
hint: "Use Space to strike. Keep moving after a Scribe raises its quill." }),
mara: Object.freeze({ name: "Innkeeper Mara", frame: 3, landmark: "inn",
intro: "Three guests left memories for safekeeping. The ink took their labels, not their meaning.",
hint: "The sailor remembers rain, the gardener remembers a seed, and the child remembers a red kite." }),
pell: Object.freeze({ name: "Inventor Pell", frame: 4, landmark: "workshop",
intro: "My recall engine needs three cogs. The last one is under a Sentinel's very unreasonable boot.",
hint: "The Sentinel pauses after its charge. That is the safe moment to attack." }),
pip: Object.freeze({ name: "Gamekeeper Pip", frame: 5, landmark: "playroom",
intro: "The maze is fair, even if the Redactor is not. Light the lamps from smallest story to largest.",
hint: "Try lanterns two, four, one, three. The maze forgives every mistake." }),
lima: Object.freeze({ name: "Curator Lima", frame: 6, landmark: "museum",
intro: "Memories become dangerous only when stripped of context. Help me refocus the Museum Lens.",
hint: "Align the lens from near memory to far memory: self, neighbour, town, archive." }),
rowan: Object.freeze({ name: "Gardener Rowan", frame: 7, landmark: "garden",
intro: "Connections grow when tended in the right season. Plant, water, remember, then share.",
hint: "Seed before rain, rain before bloom, bloom before the path opens." }),
nell: Object.freeze({ name: "Nell, a lost courier", frame: 8, landmark: null,
intro: "I know I was carrying a letter. I cannot remember whether I meant to deliver it or keep it.",
hint: "The kind answer is not always the easy answer." }),
otho: Object.freeze({ name: "Otho", frame: 9, landmark: null, intro: "The lamps know more names than I do today.", hint: "" }),
remy: Object.freeze({ name: "Remy", frame: 10, landmark: null, intro: "I leave breadcrumbs. The Redactor hates breadcrumbs.", hint: "" })
});
const QUESTS = Object.freeze({
gate: Object.freeze({ title: "A Name at the Gate", landmark: "gate", kind: "sequence",
objective: "Restore Orin's three arrival plaques.", reward: "Gate Sigil · Living Bookmark · 80 Memory",
sequence: ["home", "contact", "return"], options: ["return", "home", "contact"] }),
library: Object.freeze({ title: "The Shouting Shelf", landmark: "library", kind: "sequence",
objective: "Put the shelf symbols back into narrative order.", reward: "Library Sigil · Archivist's Key · 90 Memory",
sequence: ["seed", "branch", "page", "star"], options: ["page", "seed", "star", "branch"] }),
study: Object.freeze({ title: "Lesson Under Pressure", landmark: "study", kind: "combat",
objective: "Defeat three Nulls threatening the Guild Study.", reward: "Study Sigil · +90 Memory",
target: 3 }),
inn: Object.freeze({ title: "Borrowed Memories", landmark: "inn", kind: "matching",
objective: "Match rain, seed, and kite memories to their owners.", reward: "Inn Sigil · Recall Stew · 80 Memory",
matches: { rain: "sailor", seed: "gardener", kite: "child" } }),
workshop: Object.freeze({ title: "The Recall Engine", landmark: "workshop", kind: "boss",
objective: "Enter the repair yard and defeat the Redacted Sentinel.", reward: "Workshop Sigil · Bookmark upgrade · 120 Memory" }),
playroom: Object.freeze({ title: "Six Patient Lights", landmark: "playroom", kind: "sequence",
objective: "Solve Pip's lantern route inside the Playroom maze.", reward: "Playroom Sigil · Swiftstep Boots · 90 Memory",
sequence: ["2", "4", "1", "3"], options: ["1", "2", "3", "4"] }),
museum: Object.freeze({ title: "Context Through Glass", landmark: "museum", kind: "sequence",
objective: "Align the Museum Lens from nearest memory to farthest.", reward: "Museum Sigil · Museum Lens · 100 Memory",
sequence: ["self", "neighbour", "town", "archive"], options: ["archive", "self", "town", "neighbour"] }),
garden: Object.freeze({ title: "A Path That Remembers", landmark: "garden", kind: "sequence",
objective: "Wake the standing stones in the order of growth.", reward: "Garden Sigil · Garden Seed · 80 Memory",
sequence: ["seed", "rain", "bloom", "share"], options: ["rain", "share", "seed", "bloom"] }),
lost_letter: Object.freeze({ title: "The Letter Nell Kept", landmark: "gate", kind: "choice",
objective: "Help Nell decide what to do with the unaddressed letter.", reward: "Lore fragment · 40 Memory" })
});
const ITEMS = Object.freeze({
living_bookmark: Object.freeze({ name: "Living Bookmark", type: "equipment", icon: 0, description: "A blade-shaped bookmark that restores severed connections." }),
lantern: Object.freeze({ name: "Lantern of Recall", type: "quest", icon: 1, description: "Its light outlines things the Redactor tried to remove." }),
archive_key: Object.freeze({ name: "Archivist's Key", type: "key", icon: 2, description: "Opens the sealed stair beneath the Grand Library." }),
swiftstep_boots: Object.freeze({ name: "Swiftstep Boots", type: "equipment", icon: 3, description: "Quicker starts and gentler stops without changing top speed." }),
ink_vial: Object.freeze({ name: "Ink Vial", type: "consumable", icon: 4, description: "Restores 35 health.", stack: 9 }),
memory_fragment: Object.freeze({ name: "Memory Fragment", type: "collectable", icon: 5, description: "A small truth with its edges intact.", stack: 12 }),
garden_seed: Object.freeze({ name: "Garden Seed", type: "quest", icon: 6, description: "A seed that remembers every garden it came from." }),
workshop_cog: Object.freeze({ name: "Workshop Cog", type: "quest", icon: 7, description: "A precision cog for Pell's recall engine.", stack: 3 }),
museum_lens: Object.freeze({ name: "Museum Lens", type: "equipment", icon: 8, description: "Reveals erased details and hidden paths." }),
recall_stew: Object.freeze({ name: "Recall Stew", type: "consumable", icon: 9, description: "Restores all health.", stack: 3 }),
lore_page: Object.freeze({ name: "Lore Fragment", type: "lore", icon: 10, description: "Context the Redactor failed to consume.", stack: 16 }),
archive_sigil: Object.freeze({ name: "Archive Sigil", type: "sigil", icon: 12, description: "One of eight seals that reopen the Archive Vault." })
});
const AREA_FRAMES = Object.freeze({ vault: 0, workshop: 1, playroom: 2, boss: 3 });
const AREAS = Object.freeze({
town: Object.freeze({
width: 1448, height: 1086, background: "town", spawn: { x: 724, y: 1015 },
movement: Object.freeze({ speed: 162, acceleration: 1280, deceleration: 1850, bootAcceleration: 1720 }),
safeSpawns: Object.freeze({
gate: { x: 724, y: 1015 }, square: { x: 724, y: 555 }, library: { x: 270, y: 370 },
study: { x: 720, y: 350 }, inn: { x: 1110, y: 370 }, workshop: { x: 1060, y: 625 },
playroom: { x: 1120, y: 835 }, museum: { x: 270, y: 830 }, garden: { x: 370, 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([
/* Outer town wall, with a deliberate walkable opening through the gate. */
rect(0, 0, 1448, 62), rect(0, 0, 54, 1086), rect(1394, 0, 54, 1086),
rect(0, 862, 590, 98), rect(590, 840, 82, 132), rect(776, 840, 86, 132), rect(862, 862, 586, 98),
/* Major buildings. Their steps and front doors remain approachable. */
rect(188, 62, 270, 212, 7), rect(606, 55, 238, 218, 7), rect(964, 58, 292, 218, 7),
rect(1010, 418, 330, 142, 7), rect(1002, 655, 340, 155, 7), rect(218, 620, 220, 178, 7),
/* 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([
["orin", 724, 875], ["sera", 270, 335], ["vale", 720, 315], ["mara", 1110, 335],
["pell", 1060, 590], ["pip", 1060, 835], ["lima", 335, 830], ["rowan", 335, 545],
["nell", 760, 720], ["otho", 675, 520], ["remy", 790, 585]
]),
enemies: Object.freeze([
enemy("ink_blot", 635, 350, { leash: 95, engage: 210, requiresSigil: "gate", questId: "study" }),
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({
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 } }),
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([])
}),
workshop: Object.freeze({
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 } }),
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(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({
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 } }),
safeZones: Object.freeze([rect(300, 445, 124, 80)]),
collisions: Object.freeze([
rect(0, 0, 724, 38), rect(0, 0, 28, 543), rect(696, 0, 28, 543),
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([
enemy("lost_footnote", 90, 180, { leash: 60, engage: 150 }),
enemy("ink_blot", 650, 350, { leash: 55, engage: 145 })
])
}),
boss: Object.freeze({
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 } }),
safeZones: Object.freeze([rect(310, 438, 104, 70)]),
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 })
])
})
});
const ENEMIES = Object.freeze({
ink_blot: Object.freeze({ name: "Ink Blot", frame: 11, health: 2, speed: 48, damage: 10, xp: 12, behaviour: "chase" }),
lost_footnote: Object.freeze({ name: "Lost Footnote", frame: 12, health: 2, speed: 75, damage: 8, xp: 15, behaviour: "wander" }),
null_scribe: Object.freeze({ name: "Null Scribe", frame: 13, health: 3, speed: 42, damage: 12, xp: 20, behaviour: "ranged" }),
sentinel: Object.freeze({ name: "Redacted Sentinel", frame: 14, health: 12, speed: 82, damage: 16, xp: 100, behaviour: "charge", boss: true }),
redactor: Object.freeze({ name: "The Redactor", frame: 15, health: 30, speed: 48, damage: 16, xp: 250, behaviour: "redactor", boss: true })
});
const api = Object.freeze({
LANDMARKS, NPCS, QUESTS, ITEMS, AREAS, AREA_FRAMES, ENEMIES,
LANDMARK_IDS: Object.freeze(Object.keys(LANDMARKS)),
QUEST_IDS: Object.freeze(Object.keys(QUESTS)),
ITEM_IDS: Object.freeze(Object.keys(ITEMS)),
AREA_IDS: Object.freeze(Object.keys(AREAS)),
BOSS_IDS: Object.freeze(["sentinel", "redactor"])
});
root.ArchiveWorldData = api;
if (typeof module === "object" && module.exports) module.exports = api;
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -1,608 +0,0 @@
(function (root) {
"use strict";
const Data = root.ArchiveWorldData;
const Systems = root.ArchiveWorldSystems;
function createSceneClasses(controller) {
class BootScene extends Phaser.Scene {
constructor() { super("ArchiveBoot"); }
preload() {
const base = "/assets/images/play/archive-world/v2/";
this.load.image("town", `${base}archive-town.webp`);
this.load.spritesheet("areas", `${base}areas.webp`, { frameWidth: 724, frameHeight: 543 });
["brass", "moss", "berry"].forEach((palette) => {
this.load.spritesheet(`traveler-${palette}`, `${base}traveler-${palette}.png`, { frameWidth: 256, frameHeight: 256 });
});
this.load.spritesheet("cast", `${base}cast-atlas.png`, { frameWidth: 313, frameHeight: 313 });
this.load.spritesheet("items", `${base}item-atlas.png`, { frameWidth: 313, frameHeight: 313 });
const audio = "/assets/audio/archive-world/v2/";
[
["ambient-v2", "town-ambient.ogg"], ["vault-ambient", "vault-ambient.ogg"], ["step-v2", "step.wav"],
["attack", "attack.wav"], ["damage", "damage.wav"], ["enemy-defeat", "enemy-defeat.wav"],
["item", "item.wav"], ["quest", "quest.wav"], ["dialogue", "dialogue.wav"], ["puzzle", "puzzle.wav"],
["boss-attack", "boss-attack.wav"], ["victory", "victory.wav"], ["portal-v2", "portal.wav"]
].forEach(([key, file]) => this.load.audio(key, `${audio}${file}`));
}
create() {
this.scene.start("ArchiveWorld", { area: controller.getState().position.area || "town" });
}
}
class WorldScene extends Phaser.Scene {
constructor() {
super("ArchiveWorld");
this.areaId = "town";
this.nearest = null;
this.lastFacing = "north";
this.lastStepAt = 0;
this.lastAttackAt = 0;
this.lastHitAt = 0;
this.enemySerial = 0;
this.bossPuzzle = [];
this.bossPuzzleSolved = false;
this.controlLockedUntil = 0;
this.stepDistance = 0;
this.previousPlayerPosition = null;
}
init(data) {
this.areaId = Data.AREAS[data && data.area] ? data.area : "town";
}
create() {
controller.scene = this;
const area = Data.AREAS[this.areaId];
this.area = area;
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);
else this.add.image(0, 0, "areas", area.frame).setOrigin(0).setDepth(0);
this.collisions = this.physics.add.staticGroup();
const debug = new URLSearchParams(window.location.search).get("collisionDebug") === "1"
&& ["localhost", "127.0.0.1"].includes(window.location.hostname);
area.collisions.forEach((item) => {
const block = this.add.rectangle(item.x + item.width / 2, item.y + item.height / 2, item.width, item.height, 0xff3b5c, debug ? 0.25 : 0)
.setDepth(debug ? 50 : item.depth || 2);
this.physics.add.existing(block, true);
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.npcCollisions = this.physics.add.staticGroup();
const state = controller.getState();
const safe = Systems.nearestSafeSpawn(this.areaId,
state.position.area === this.areaId ? state.position.x : area.spawn.x,
state.position.area === this.areaId ? state.position.y : area.spawn.y);
this.lastFacing = state.position.facing || "north";
this.player = this.physics.add.sprite(safe.x, safe.y, `traveler-${state.palette}`, frameFor(this.lastFacing))
.setScale(0.3).setDepth(20);
this.player.body.setSize(72, 42).setOffset(92, 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.interactables = [];
this.markers = [];
this.createWorldObjects();
this.physics.add.collider(this.player, this.npcCollisions);
this.createEnemies();
this.createInput();
this.projectiles = this.physics.add.group();
this.physics.add.collider(this.projectiles, this.collisions, (projectile) => projectile.destroy());
this.physics.add.overlap(this.player, this.projectiles, (_player, projectile) => {
projectile.destroy();
this.damagePlayer(projectile.getData("damage") || 10, projectile.x, projectile.y);
});
this.physics.add.overlap(this.player, this.enemies, (_player, enemy) => {
this.damagePlayer(enemy.getData("spec").damage, enemy.x, enemy.y);
});
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,
controller.reducedMotion() ? 1 : 0.24, controller.reducedMotion() ? 1 : 0.24);
this.cameras.main.setDeadzone(72, 48);
controller.audio.attach(this, this.areaId);
controller.renderHud();
controller.status(this.areaId === "town"
? "Archive Town is listening. Speak with Gatekeeper Orin or explore any landmark."
: `${pretty(this.areaId)} entered. The doorway behind you returns to Archive Town.`);
this.persistPosition(true);
}
createAnimations() {
const palette = controller.getState().palette;
const texture = `traveler-${palette}`;
["south", "west", "east", "north"].forEach((face, row) => {
const walkKey = `${texture}-walk-${face}`;
if (!this.anims.exists(walkKey)) {
this.anims.create({
key: walkKey,
frames: this.anims.generateFrameNumbers(texture, { start: row * 6, end: row * 6 + 5 }),
frameRate: controller.reducedMotion() ? 6 : 9,
repeat: -1
});
}
});
}
createInput() {
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({
up: Phaser.Input.Keyboard.KeyCodes.W, down: Phaser.Input.Keyboard.KeyCodes.S,
left: Phaser.Input.Keyboard.KeyCodes.A, right: Phaser.Input.Keyboard.KeyCodes.D,
interact: Phaser.Input.Keyboard.KeyCodes.E, enter: Phaser.Input.Keyboard.KeyCodes.ENTER,
use: Phaser.Input.Keyboard.KeyCodes.Q, menu: Phaser.Input.Keyboard.KeyCodes.ESC,
cycle: Phaser.Input.Keyboard.KeyCodes.TAB
});
}
createWorldObjects() {
if (this.areaId === "town") {
Object.values(Data.LANDMARKS).forEach((place) => {
const marker = this.add.circle(place.x, place.y, 24, 0xf1c46f, 0.18)
.setStrokeStyle(3, 0xffedaa, 0.9).setDepth(6);
marker.setData("interaction", { type: "landmark", id: place.id, x: place.x, y: place.y });
this.markers.push(marker);
this.interactables.push(marker.getData("interaction"));
if (!controller.reducedMotion()) this.tweens.add({ targets: marker, alpha: 0.45, scale: 1.14, yoyo: true, repeat: -1, duration: 1100 });
});
this.area.npcs.forEach(([id, x, y]) => {
const npc = this.npcCollisions.create(x, y, "cast", Data.NPCS[id].frame)
.setScale(0.23).setDepth(y + 60);
npc.refreshBody();
npc.body.setSize(82, 44).setOffset(115, 214);
npc.setData("interaction", { type: "npc", id, x, y });
this.interactables.push(npc.getData("interaction"));
});
this.addChest("town-garden", 205, 565, "memory_fragment", 2);
this.addChest("town-wall", 1235, 840, "ink_vial", 2);
} else {
this.addPortal("town", this.area.width / 2, this.area.height - 38, "Return to Archive Town");
if (this.areaId === "vault") {
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);
}
if (this.areaId === "playroom") {
this.interactables.push({ type: "challenge", id: "playroom", x: this.area.width / 2, y: 255 });
this.add.circle(this.area.width / 2, 255, 22, 0xf8d879, 0.45).setDepth(5);
}
if (this.areaId === "boss") this.createBossPedestals();
}
}
addPortal(area, x, y, label) {
const portal = this.add.circle(x, y, 28, 0x87d4c0, 0.24).setStrokeStyle(3, 0xbff8e9, 0.8).setDepth(5);
const interaction = { type: "portal", id: area, x, y, label };
portal.setData("interaction", interaction);
this.interactables.push(interaction);
}
addChest(id, x, y, item, quantity) {
if (controller.getState().openedChests.includes(id)) return;
const chest = this.add.sprite(x, y, "items", 11).setScale(0.2).setDepth(y + 10);
const interaction = { type: "chest", id, x, y, item, quantity };
chest.setData("interaction", interaction);
chest.setData("sprite", chest);
this.interactables.push(interaction);
}
createBossPedestals() {
const labels = ["self", "neighbour", "town", "archive"];
const positions = [[220, 180], [505, 180], [505, 350], [220, 350]];
labels.forEach((value, index) => {
const [x, y] = positions[index];
const pedestal = this.add.circle(x, y, 20, 0x34506b, 0.7).setStrokeStyle(3, 0xe3c16f).setDepth(4);
this.interactables.push({ type: "boss-memory", id: value, x, y, sprite: pedestal });
});
}
createEnemies() {
this.enemies = this.physics.add.group();
this.physics.add.collider(this.enemies, this.collisions);
this.physics.add.collider(this.enemies, this.npcCollisions);
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;
this.spawnEnemy(type, x, y, spawn);
});
}
spawnEnemy(type, x, y, spawn) {
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);
enemy.body.setSize(spec.boss ? 155 : 115, spec.boss ? 85 : 65).setOffset(spec.boss ? 79 : 99, spec.boss ? 196 : 200);
enemy.setData({
id: `${type}-${++this.enemySerial}`, type, spec, health: spec.health,
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);
return enemy;
}
update(time, delta) {
if (!this.player) return;
if (controller.locked()) {
this.player.setVelocity(0);
this.player.anims.stop();
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 dy = 0;
if (this.cursors.left.isDown || this.keys.left.isDown || controller.move.left) dx -= 1;
if (this.cursors.right.isDown || this.keys.right.isDown || controller.move.right) dx += 1;
if (this.cursors.up.isDown || this.keys.up.isDown || controller.move.up) dy -= 1;
if (this.cursors.down.isDown || this.keys.down.isDown || controller.move.down) dy += 1;
const moving = dx !== 0 || dy !== 0;
const state = controller.getState();
this.gateBlocks = this.gateBlocks.filter((entry) => {
if (!state.sigils.includes(entry.gate.requiresSigil)) return true;
entry.block.destroy();
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.player.anims.play(`traveler-${state.palette}-walk-${this.lastFacing}`, true);
this.stepDistance += traveled;
if (state.settings.soundEnabled && this.stepDistance >= 44) {
controller.audio.play("step", { rate: 0.96 + Math.random() * 0.08 });
this.lastStepAt = time;
this.stepDistance = 0;
}
} else {
this.player.anims.stop();
this.player.setFrame(frameFor(this.lastFacing));
if (!moving) this.stepDistance = 0;
}
this.player.setDepth(this.player.y + 60);
this.updateEnemies(time, delta);
this.updateNearest();
this.handleKeys(time);
if (moving && time - controller.lastSaveAt > 700) {
controller.lastSaveAt = time;
this.persistPosition(false);
}
}
handleKeys(time) {
if (Phaser.Input.Keyboard.JustDown(this.keys.interact) || Phaser.Input.Keyboard.JustDown(this.keys.enter)) this.interact();
if (Phaser.Input.Keyboard.JustDown(this.cursors.space)) this.attack(time);
if (Phaser.Input.Keyboard.JustDown(this.keys.use)) controller.useSelectedItem();
if (Phaser.Input.Keyboard.JustDown(this.keys.cycle)) {
this.keys.cycle.reset();
controller.cycleItem();
}
if (Phaser.Input.Keyboard.JustDown(this.keys.menu)) controller.openMenu();
}
updateNearest() {
this.nearest = Systems.nearestInteraction(this.interactables, this.player.x, this.player.y);
controller.setPrompt(this.nearest ? promptFor(this.nearest) : "");
}
interact() {
if (controller.locked()) return;
const item = this.nearest;
if (!item) return controller.status("Nothing nearby answers the Living Bookmark.");
if (item.type === "landmark") controller.openLandmark(item.id);
else if (item.type === "npc") controller.openNpc(item.id);
else if (item.type === "portal") {
if (item.id === "boss" && controller.getState().sigils.length < 8) {
controller.status("All eight Archive Sigils are needed to reveal the vault core.");
} else this.travel(item.id);
} else if (item.type === "challenge") controller.openChallenge(item.id);
else if (item.type === "chest") this.openChest(item);
else if (item.type === "boss-memory") this.activateBossMemory(item);
}
openChest(item) {
let state = controller.getState();
if (state.openedChests.includes(item.id)) return;
state.openedChests.push(item.id);
state = Systems.addItem(state, item.item, item.quantity);
controller.setState(state, `${Data.ITEMS[item.item].name} collected.`);
controller.audio.play("item");
this.interactables = this.interactables.filter((entry) => entry !== item);
const sprite = this.children.list.find((child) => child.getData && child.getData("interaction") === item);
if (sprite) sprite.destroy();
}
travel(area) {
controller.audio.play("portal");
this.persistPosition(false);
controller.travel(area);
}
attack(time) {
if (time - this.lastAttackAt < 330 || controller.locked()) return;
this.lastAttackAt = time;
controller.audio.play("attack");
const direction = faceVector(this.lastFacing);
const range = controller.getState().attack > 1 ? 74 : 62;
const hit = this.add.rectangle(this.player.x + direction.x * 48, this.player.y + direction.y * 38, range, range, 0xf8df83, 0.32).setDepth(100);
this.physics.add.existing(hit);
const struck = new Set();
this.physics.overlap(hit, this.enemies, (_zone, enemy) => {
if (struck.has(enemy)) return;
struck.add(enemy);
this.hitEnemy(enemy, controller.getState().attack, direction);
});
this.time.delayedCall(controller.reducedMotion() ? 80 : 130, () => hit.destroy());
}
hitEnemy(enemy, damage, direction) {
if (!enemy.active) return;
if (enemy.getData("type") === "redactor" && enemy.getData("phase") >= 3 && !this.bossPuzzleSolved) {
controller.status("The Redactor's weak point is hidden. Restore the four memory pedestals.");
return;
}
enemy.setData("health", enemy.getData("health") - damage);
enemy.setVelocity(direction.x * 170, direction.y * 170);
enemy.setTint(0xffd6d6);
this.time.delayedCall(90, () => { if (enemy.active) enemy.clearTint(); });
if (enemy.getData("health") <= 0) this.defeatEnemy(enemy);
this.updateBossHud(enemy);
}
defeatEnemy(enemy) {
const type = enemy.getData("type");
const spec = enemy.getData("spec");
const x = enemy.x;
const y = enemy.y;
enemy.destroy();
controller.audio.play(type === "redactor" ? "victory" : "defeat");
let state = Systems.grantXp(controller.getState(), spec.xp);
if (type === "sentinel" || type === "redactor") state = Systems.recordBoss(state, type);
if (enemy.getData("questId") === "study" && state.quests.study.status === "active") {
state = Systems.progressQuest(state, "study", 1);
if (state.quests.study.count >= Data.QUESTS.study.target) state = Systems.completeQuest(state, "study");
}
if (Math.random() < 0.55 && !spec.boss) state = Systems.addItem(state, "ink_vial", 1);
controller.setState(state, `${spec.name} restored into harmless ink. +${spec.xp} Memory.`);
this.add.circle(x, y, 10, 0xbde4d6, 0.7).setDepth(30);
if (type === "redactor") {
controller.hideBoss();
this.time.delayedCall(500, () => controller.openEnding());
}
}
updateEnemies(time) {
this.enemies.getChildren().forEach((enemy) => {
if (!enemy.active) return;
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 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 (distance < enemy.getData("engage")) this.physics.moveToObject(enemy, this.player, spec.speed);
else this.returnEnemyHome(enemy, spec.speed);
} else if (spec.behaviour === "wander") {
if (distance < enemy.getData("engage") && time > enemy.getData("nextAction")) {
enemy.setData("nextAction", time + 650 + Math.random() * 800);
const angle = Math.random() * Math.PI * 2;
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") {
if (distance < enemy.getData("engage") && time > enemy.getData("nextAction")) {
enemy.setData("nextAction", time + (controller.getState().settings.assist ? 1900 : 1350));
this.fireProjectile(enemy, spec.damage);
} else if (distance < 170) this.physics.moveToObject(enemy, this.player, -spec.speed);
else this.returnEnemyHome(enemy, spec.speed);
} else if (spec.behaviour === "charge") {
if (time > enemy.getData("nextAction")) {
enemy.setTint(0xffd36e);
enemy.setVelocity(0);
enemy.setData("nextAction", time + 1800);
this.time.delayedCall(controller.getState().settings.assist ? 850 : 550, () => {
if (!enemy.active) return;
enemy.clearTint();
this.physics.moveToObject(enemy, this.player, spec.speed * 2.1);
controller.audio.play("boss");
});
}
} else if (spec.behaviour === "redactor") {
this.updateRedactor(enemy, time, distance);
}
enemy.setDepth(enemy.y + 50);
});
}
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) {
const health = enemy.getData("health");
const phase = health > 20 ? 1 : health > 10 ? 2 : 3;
if (phase !== enemy.getData("phase")) {
enemy.setData("phase", phase);
if (phase === 2) {
controller.status("The Redactor tears open lost footnotes. Keep to the clear floor.");
this.spawnEnemy("ink_blot", 150, 250);
this.spawnEnemy("lost_footnote", 575, 250);
} else {
controller.status("Final phase: activate Self, Neighbour, Town, then Archive to reveal the weak point.");
enemy.setFrame(15).setTint(0x9e7cc1);
}
}
if (time > enemy.getData("nextAction")) {
enemy.setData("nextAction", time + (controller.getState().settings.assist ? 2100 : 1450));
if (phase === 1) this.fireProjectile(enemy, 14);
else if (phase === 2) {
this.fireRadial(enemy, 8, 12);
controller.audio.play("boss");
} else if (!this.bossPuzzleSolved) {
this.fireRadial(enemy, 5, 10);
} else if (distance < 320) this.physics.moveToObject(enemy, this.player, 65);
}
}
fireProjectile(enemy, damage) {
enemy.setTint(0xead58a);
this.time.delayedCall(controller.getState().settings.assist ? 650 : 380, () => {
if (!enemy.active) return;
enemy.clearTint();
const shot = this.add.circle(enemy.x, enemy.y, 9, 0x16121d, 0.95).setStrokeStyle(2, 0xf0bfd0).setDepth(40);
this.physics.add.existing(shot);
shot.setData("damage", damage);
this.projectiles.add(shot);
this.physics.moveToObject(shot, this.player, 175);
this.time.delayedCall(3600, () => { if (shot.active) shot.destroy(); });
controller.audio.play("boss", { volume: 0.6 });
});
}
fireRadial(enemy, count, damage) {
for (let index = 0; index < count; index += 1) {
const angle = (Math.PI * 2 * index) / count;
const shot = this.add.circle(enemy.x, enemy.y, 8, 0x151018, 0.95).setStrokeStyle(2, 0xd991aa).setDepth(40);
this.physics.add.existing(shot);
shot.setData("damage", damage);
shot.body.setVelocity(Math.cos(angle) * 135, Math.sin(angle) * 135);
this.projectiles.add(shot);
this.time.delayedCall(4000, () => { if (shot.active) shot.destroy(); });
}
}
activateBossMemory(item) {
if (this.bossPuzzleSolved) return;
const order = ["self", "neighbour", "town", "archive"];
this.bossPuzzle.push(item.id);
const valid = this.bossPuzzle.every((value, index) => value === order[index]);
if (!valid) {
this.bossPuzzle = [];
controller.status("The memory chain breaks, but the sigils hold. Begin with Self.");
return;
}
item.sprite.setFillStyle(0xf3d67d, 0.9);
if (this.bossPuzzle.length === order.length) {
this.bossPuzzleSolved = true;
controller.status("Context restored: The Redactor's weak point is revealed.");
controller.audio.play("puzzle");
const boss = this.enemies.getChildren().find((enemy) => enemy.getData("type") === "redactor");
if (boss) boss.clearTint().setFrame(15);
} else controller.status(`${pretty(item.id)} restored. ${this.bossPuzzle.length} / 4 memories connected.`);
}
damagePlayer(amount, fromX, fromY) {
if (Systems.isCombatSafe(this.areaId, this.player.x, this.player.y)) return;
const result = Systems.takeDamage(controller.getState(), amount, this.time.now, this.lastHitAt);
if (!result.hit) return;
this.lastHitAt = this.time.now;
controller.setState(result.state, `The ink struck for ${Math.round(amount * (result.state.settings.assist ? 0.5 : 1))} damage.`);
controller.audio.play("damage");
const direction = Systems.normalizedVector(this.player.x - fromX, this.player.y - fromY, 220);
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(); });
if (!controller.reducedMotion()) this.cameras.main.shake(90, 0.003);
if (result.defeated) {
this.player.setVelocity(0);
controller.openDefeat();
}
}
updateBossHud(enemy) {
if (!enemy || !enemy.getData("spec").boss) return;
controller.showBoss(enemy.getData("spec").name, enemy.getData("health"), enemy.getData("spec").health);
}
persistPosition(checkpoint) {
if (!this.player) return;
let state = StateWithSafe(controller.getState(), this.areaId, this.player.x, this.player.y, this.lastFacing);
if (checkpoint || Systems.isCombatSafe(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);
}
controller.setState(state, null, true);
}
}
return [BootScene, WorldScene];
}
function StateWithSafe(state, area, x, y, facing) {
const safe = Systems.nearestSafeSpawn(area, x, y);
return root.ArchiveWorldState.withPosition(state, safe.area, safe.x, safe.y, facing, safe.spawn);
}
function frameFor(face) {
return { south: 0, west: 6, east: 12, north: 18 }[face] || 18;
}
function faceVector(face) {
return { north: { x: 0, y: -1 }, south: { x: 0, y: 1 }, west: { x: -1, y: 0 }, east: { x: 1, y: 0 } }[face];
}
function promptFor(item) {
if (item.type === "portal") return `${item.label}. Press E or Explore.`;
if (item.type === "chest") return "A sealed archive chest is nearby. Press E or Explore.";
if (item.type === "boss-memory") return `${pretty(item.id)} pedestal. Press E or Explore.`;
if (item.type === "challenge") return "The lantern console is nearby. Press E or Explore.";
const title = item.type === "npc" ? Data.NPCS[item.id].name : Data.LANDMARKS[item.id].title;
return `${title} is nearby. Press E or Explore.`;
}
function pretty(value) {
return String(value).replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
root.ArchiveWorldScenes = Object.freeze({ createSceneClasses });
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -1,237 +0,0 @@
(function (root, factory) {
"use strict";
const data = root.ArchiveWorldData || (typeof require === "function" ? require("./archive-world-data.js") : null);
const api = factory(data);
if (typeof module === "object" && module.exports) module.exports = api;
root.ArchiveWorldState = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data) {
"use strict";
const VERSION = 2;
const STORAGE_KEY = "zxh_archive_world_v2";
const LEGACY_KEY = "zxh_archive_world_v1";
const PALETTES = Object.freeze(["brass", "moss", "berry"]);
const FACES = Object.freeze(["north", "south", "east", "west"]);
const SPAWN = Object.freeze({ area: "town", spawn: "gate", x: 724, y: 1015 });
const LANDMARK_IDS = Data ? Data.LANDMARK_IDS : Object.freeze(["gate", "library", "study", "inn", "workshop", "playroom", "museum", "garden"]);
const QUEST_IDS = Data ? Data.QUEST_IDS : Object.freeze(LANDMARK_IDS.concat("lost_letter"));
const ITEM_IDS = Data ? Data.ITEM_IDS : Object.freeze(["living_bookmark", "lantern", "archive_key", "swiftstep_boots", "ink_vial", "memory_fragment", "garden_seed", "workshop_cog", "museum_lens", "recall_stew", "lore_page", "archive_sigil"]);
const AREA_IDS = Data ? Data.AREA_IDS : Object.freeze(["town", "vault", "workshop", "playroom", "boss"]);
const BOSS_IDS = Object.freeze(["sentinel", "redactor"]);
const LEVELS = Object.freeze([0, 80, 200, 380, 620]);
function validateName(value) {
const name = String(value || "").trim().replace(/\s+/g, " ");
return name.length >= 1 && name.length <= 20 ? name : null;
}
function validatePalette(value) {
return PALETTES.includes(value) ? value : null;
}
function finite(value, fallback, min, max) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
}
function unique(values, allowed) {
return Array.isArray(values) ? Array.from(new Set(values.filter((id) => allowed.includes(id)))) : [];
}
function questDefaults() {
return Object.fromEntries(QUEST_IDS.map((id) => [id, { status: "locked", step: 0, count: 0 }]));
}
function fresh(name, palette) {
return {
version: VERSION,
name: validateName(name) || "",
palette: validatePalette(palette) || "brass",
health: 100,
maxHealth: 100,
level: 1,
xp: 0,
attack: 1,
position: { area: SPAWN.area, spawn: SPAWN.spawn, x: SPAWN.x, y: SPAWN.y, facing: "north" },
checkpoint: { area: SPAWN.area, spawn: SPAWN.spawn, x: SPAWN.x, y: SPAWN.y },
discovered: [],
complete: false,
story: { stage: "arrival", ending: false, midpointSeen: false },
quests: questDefaults(),
inventory: [{ id: "living_bookmark", quantity: 1 }, { id: "ink_vial", quantity: 2 }],
selectedItem: "ink_vial",
equipment: { bookmark: true, boots: false, lens: false },
sigils: [],
defeatedBosses: [],
openedChests: [],
solvedPuzzles: [],
npcFlags: {},
settings: {
soundEnabled: false,
ambience: 0.35,
music: 0.3,
effects: 0.65,
assist: false,
reducedMotion: null
}
};
}
function normalizePosition(candidate, fallback) {
const area = candidate && AREA_IDS.includes(candidate.area) ? candidate.area : fallback.area;
const areaData = Data && Data.AREAS[area];
const defaultSpawn = areaData ? areaData.spawn : { x: fallback.x, y: fallback.y };
const width = areaData ? areaData.width : 1448;
const height = areaData ? areaData.height : 1086;
return {
area,
spawn: String(candidate && candidate.spawn || fallback.spawn || "entrance").slice(0, 30),
x: finite(candidate && candidate.x, defaultSpawn.x, 24, width - 24),
y: finite(candidate && candidate.y, defaultSpawn.y, 24, height - 24),
facing: candidate && FACES.includes(candidate.facing) ? candidate.facing : "north"
};
}
function normalizeInventory(value) {
const quantities = new Map();
if (Array.isArray(value)) value.forEach((entry) => {
if (!entry || !ITEM_IDS.includes(entry.id)) return;
const cap = Data && Data.ITEMS[entry.id] && Data.ITEMS[entry.id].stack || 1;
quantities.set(entry.id, Math.min(cap, (quantities.get(entry.id) || 0) + Math.floor(finite(entry.quantity, 1, 1, cap))));
});
if (!quantities.has("living_bookmark")) quantities.set("living_bookmark", 1);
return Array.from(quantities, ([id, quantity]) => ({ id, quantity }));
}
function normalizeQuests(value) {
const defaults = questDefaults();
QUEST_IDS.forEach((id) => {
const source = value && value[id];
if (!source) return;
defaults[id] = {
status: ["locked", "active", "complete"].includes(source.status) ? source.status : "locked",
step: Math.floor(finite(source.step, 0, 0, 20)),
count: Math.floor(finite(source.count, 0, 0, 99))
};
});
return defaults;
}
function normalize(candidate) {
if (!candidate || candidate.version !== VERSION) return null;
const name = validateName(candidate.name);
const palette = validatePalette(candidate.palette);
if (!name || !palette) return null;
const discovered = unique(candidate.discovered, LANDMARK_IDS);
const sigils = unique(candidate.sigils, LANDMARK_IDS);
const xp = Math.floor(finite(candidate.xp, 0, 0, 99999));
const level = levelForXp(xp);
const expectedMax = 100 + (level >= 2 ? 20 : 0) + (level >= 4 ? 20 : 0);
const maxHealth = finite(candidate.maxHealth, expectedMax, expectedMax, expectedMax);
const position = normalizePosition(candidate.position, SPAWN);
const checkpoint = normalizePosition(candidate.checkpoint, SPAWN);
const inventory = normalizeInventory(candidate.inventory);
const selected = ITEM_IDS.includes(candidate.selectedItem) ? candidate.selectedItem : "ink_vial";
return {
version: VERSION,
name,
palette,
health: finite(candidate.health, maxHealth, 0, maxHealth),
maxHealth,
level,
xp,
attack: sigils.includes("workshop") ? 2 : 1,
position,
checkpoint: { area: checkpoint.area, spawn: checkpoint.spawn, x: checkpoint.x, y: checkpoint.y },
discovered,
complete: LANDMARK_IDS.every((id) => discovered.includes(id)),
story: {
stage: ["arrival", "first_act", "midpoint", "escalation", "vault", "postgame"].includes(candidate.story && candidate.story.stage)
? candidate.story.stage : "arrival",
ending: candidate.story && candidate.story.ending === true,
midpointSeen: candidate.story && candidate.story.midpointSeen === true
},
quests: normalizeQuests(candidate.quests),
inventory,
selectedItem: selected,
equipment: {
bookmark: true,
boots: candidate.equipment && candidate.equipment.boots === true,
lens: candidate.equipment && candidate.equipment.lens === true
},
sigils,
defeatedBosses: unique(candidate.defeatedBosses, BOSS_IDS),
openedChests: Array.isArray(candidate.openedChests) ? Array.from(new Set(candidate.openedChests.filter((id) => typeof id === "string"))).slice(0, 64) : [],
solvedPuzzles: unique(candidate.solvedPuzzles, QUEST_IDS),
npcFlags: candidate.npcFlags && typeof candidate.npcFlags === "object" && !Array.isArray(candidate.npcFlags)
? Object.fromEntries(Object.entries(candidate.npcFlags).filter(([key, value]) => /^[a-z0-9_-]{1,40}$/.test(key) && ["string", "number", "boolean"].includes(typeof value)).slice(0, 64))
: {},
settings: {
soundEnabled: candidate.settings && candidate.settings.soundEnabled === true,
ambience: finite(candidate.settings && candidate.settings.ambience, 0.35, 0, 1),
music: finite(candidate.settings && candidate.settings.music, 0.3, 0, 1),
effects: finite(candidate.settings && candidate.settings.effects, 0.65, 0, 1),
assist: candidate.settings && candidate.settings.assist === true,
reducedMotion: candidate.settings && typeof candidate.settings.reducedMotion === "boolean" ? candidate.settings.reducedMotion : null
}
};
}
function migrateV1(candidate) {
if (!candidate || candidate.version !== 1) return null;
const name = validateName(candidate.name);
const palette = validatePalette(candidate.palette);
if (!name || !palette) return null;
const migrated = fresh(name, palette);
migrated.discovered = unique(candidate.discovered, LANDMARK_IDS);
migrated.complete = LANDMARK_IDS.every((id) => migrated.discovered.includes(id));
migrated.position = normalizePosition({
area: "town", spawn: "gate",
x: candidate.position && candidate.position.x,
y: candidate.position && candidate.position.y,
facing: "north"
}, SPAWN);
migrated.checkpoint = { area: "town", spawn: "gate", x: SPAWN.x, y: SPAWN.y };
migrated.settings.soundEnabled = candidate.soundEnabled === true;
return normalize(migrated);
}
function parse(raw) {
if (!raw) return null;
try {
const candidate = JSON.parse(raw);
return candidate.version === 1 ? migrateV1(candidate) : normalize(candidate);
} catch (_error) {
return null;
}
}
function levelForXp(xp) {
let level = 1;
LEVELS.forEach((threshold, index) => { if (xp >= threshold) level = index + 1; });
return Math.min(5, level);
}
function withPosition(state, area, x, y, facing, spawn) {
const current = normalize(state);
if (!current) return null;
current.position = normalizePosition({ area, x, y, facing, spawn }, current.position);
return current;
}
function withCheckpoint(state, area, spawn, x, y) {
const current = normalize(state);
if (!current) return null;
const next = normalizePosition({ area, spawn, x, y }, SPAWN);
current.checkpoint = { area: next.area, spawn: next.spawn, x: next.x, y: next.y };
return current;
}
return Object.freeze({
VERSION, STORAGE_KEY, LEGACY_KEY, PALETTES, LANDMARK_IDS, QUEST_IDS, ITEM_IDS, AREA_IDS, BOSS_IDS, LEVELS, SPAWN,
validateName, validatePalette, fresh, normalize, parse, migrateV1, normalizePosition, normalizeInventory,
normalizeQuests, levelForXp, withPosition, withCheckpoint
});
}));

View File

@@ -1,267 +0,0 @@
(function (root, factory) {
"use strict";
const Data = root.ArchiveWorldData || (typeof require === "function" ? require("./archive-world-data.js") : null);
const State = root.ArchiveWorldState || (typeof require === "function" ? require("./archive-world-state.js") : null);
const api = factory(Data, State);
root.ArchiveWorldSystems = api;
if (typeof module === "object" && module.exports) module.exports = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data, State) {
"use strict";
function copy(state) {
return State.normalize(JSON.parse(JSON.stringify(state)));
}
function normalizedVector(x, y, speed) {
const length = Math.hypot(Number(x) || 0, Number(y) || 0);
if (!length) return { x: 0, y: 0 };
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) {
const next = copy(state);
if (!next || !Data.LANDMARK_IDS.includes(landmarkId)) return next;
if (!next.discovered.includes(landmarkId)) next.discovered.push(landmarkId);
next.complete = Data.LANDMARK_IDS.every((id) => next.discovered.includes(id));
return State.normalize(next);
}
function startQuest(state, questId) {
const next = copy(state);
if (!next || !Data.QUESTS[questId]) return next;
if (next.quests[questId].status === "locked") next.quests[questId] = { status: "active", step: 0, count: 0 };
if (next.story.stage === "arrival" && questId === "gate") next.story.stage = "first_act";
return State.normalize(next);
}
function progressQuest(state, questId, amount) {
const next = startQuest(state, questId);
if (!next || next.quests[questId].status === "complete") return next;
next.quests[questId].count = Math.min(99, next.quests[questId].count + Math.max(1, Number(amount) || 1));
return State.normalize(next);
}
function completeQuest(state, questId) {
let next = startQuest(state, questId);
if (!next || next.quests[questId].status === "complete") return next;
const quest = Data.QUESTS[questId];
next.quests[questId] = { status: "complete", step: 99, count: next.quests[questId].count };
if (Data.LANDMARK_IDS.includes(quest.landmark) && !next.sigils.includes(quest.landmark)) next.sigils.push(quest.landmark);
if (!next.solvedPuzzles.includes(questId)) next.solvedPuzzles.push(questId);
const rewards = {
gate: [["lantern", 1], ["ink_vial", 1]],
library: [["archive_key", 1], ["lore_page", 1]],
study: [["ink_vial", 2]],
inn: [["recall_stew", 1]],
workshop: [["workshop_cog", 3]],
playroom: [["swiftstep_boots", 1]],
museum: [["museum_lens", 1], ["lore_page", 1]],
garden: [["garden_seed", 1], ["memory_fragment", 1]],
lost_letter: [["lore_page", 1]]
};
(rewards[questId] || []).forEach(([id, quantity]) => { next = addItem(next, id, quantity); });
if (questId === "playroom") next.equipment.boots = true;
if (questId === "museum") next.equipment.lens = true;
if (questId === "workshop" && !next.defeatedBosses.includes("sentinel")) next.defeatedBosses.push("sentinel");
next = grantXp(next, questId === "workshop" ? 120 : questId === "museum" ? 100 : questId === "lost_letter" ? 40 : 90);
return updateStory(next);
}
function updateStory(state) {
const next = copy(state);
if (!next) return next;
if (next.sigils.length >= 4 && !next.story.midpointSeen) next.story.stage = "midpoint";
if (next.story.midpointSeen && next.sigils.length < 8) next.story.stage = "escalation";
if (next.sigils.length === 8 && !next.story.ending) next.story.stage = "vault";
if (next.story.ending) next.story.stage = "postgame";
return State.normalize(next);
}
function addItem(state, itemId, quantity) {
const next = copy(state);
if (!next || !Data.ITEMS[itemId]) return next;
const cap = Data.ITEMS[itemId].stack || 1;
const found = next.inventory.find((item) => item.id === itemId);
if (found) found.quantity = Math.min(cap, found.quantity + Math.max(1, Math.floor(Number(quantity) || 1)));
else next.inventory.push({ id: itemId, quantity: Math.min(cap, Math.max(1, Math.floor(Number(quantity) || 1))) });
return State.normalize(next);
}
function itemQuantity(state, itemId) {
const found = state && state.inventory && state.inventory.find((item) => item.id === itemId);
return found ? found.quantity : 0;
}
function consumeItem(state, itemId) {
let next = copy(state);
const item = Data.ITEMS[itemId];
if (!next || !item || item.type !== "consumable" || itemQuantity(next, itemId) < 1 || next.health >= next.maxHealth) {
return { state: next, used: false, amount: 0 };
}
const amount = itemId === "recall_stew" ? next.maxHealth : 35;
next.health = Math.min(next.maxHealth, next.health + amount);
const found = next.inventory.find((entry) => entry.id === itemId);
found.quantity -= 1;
next.inventory = next.inventory.filter((entry) => entry.quantity > 0 || entry.id === "living_bookmark");
next = State.normalize(next);
return { state: next, used: true, amount };
}
function grantXp(state, amount) {
const next = copy(state);
if (!next) return next;
const oldLevel = next.level;
next.xp = Math.min(99999, next.xp + Math.max(0, Math.floor(Number(amount) || 0)));
next.level = State.levelForXp(next.xp);
next.maxHealth = 100 + (next.level >= 2 ? 20 : 0) + (next.level >= 4 ? 20 : 0);
if (next.level > oldLevel) next.health = next.maxHealth;
return State.normalize(next);
}
function takeDamage(state, amount, now, lastHitAt) {
const next = copy(state);
const invulnerability = next && next.settings.assist ? 1400 : 850;
if (!next || Number(now) - Number(lastHitAt || 0) < invulnerability) return { state: next, hit: false, defeated: false };
const scale = next.settings.assist ? 0.5 : 1;
next.health = Math.max(0, next.health - Math.max(1, Math.round(amount * scale)));
return { state: State.normalize(next), hit: true, defeated: next.health <= 0 };
}
function respawn(state) {
const next = copy(state);
if (!next) return next;
next.health = Math.max(50, Math.ceil(next.maxHealth * 0.6));
next.position = {
area: next.checkpoint.area, spawn: next.checkpoint.spawn,
x: next.checkpoint.x, y: next.checkpoint.y, facing: "north"
};
return State.normalize(next);
}
function recordBoss(state, bossId) {
let next = copy(state);
if (!next || !Data.BOSS_IDS.includes(bossId)) return next;
if (!next.defeatedBosses.includes(bossId)) next.defeatedBosses.push(bossId);
if (bossId === "sentinel") next = completeQuest(next, "workshop");
if (bossId === "redactor") {
next.story.ending = true;
next.story.midpointSeen = true;
next = grantXp(next, 250);
next = updateStory(next);
}
return State.normalize(next);
}
function pointInRect(x, y, item, padding) {
const pad = Number(padding) || 0;
return x >= item.x - pad && x <= item.x + item.width + pad && y >= item.y - pad && y <= item.y + item.height + pad;
}
function isSafe(areaId, x, y) {
const area = Data.AREAS[areaId];
if (!area || x < 24 || y < 24 || x > area.width - 24 || y > area.height - 24) return false;
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 interactionRadius(item) {
return {
npc: 46,
landmark: 64,
portal: 58,
chest: 48,
challenge: 52,
"boss-memory": 50
}[item && item.type] || 44;
}
function nearestInteraction(items, x, y) {
let nearest = null;
let best = Infinity;
(Array.isArray(items) ? items : []).forEach((item) => {
if (!item || !Number.isFinite(item.x) || !Number.isFinite(item.y)) return;
const distance = Math.hypot(item.x - x, item.y - y);
if (distance <= interactionRadius(item) && distance < best) {
nearest = item;
best = distance;
}
});
return nearest;
}
function safeSpawnForState(state) {
const position = state && state.position;
const gate = Data.AREAS.town.gates && Data.AREAS.town.gates[0];
const gateLocked = state && Array.isArray(state.sigils) && !state.sigils.includes("gate");
if (position && position.area === "town" && gate && gateLocked && position.y < gate.y + gate.height) {
return nearestNamedSafeSpawn("town", Data.AREAS.town.safeSpawns.gate.x, Data.AREAS.town.safeSpawns.gate.y);
}
return nearestSafeSpawn(
position && position.area || "town",
position && position.x,
position && position.y
);
}
function nearestSafeSpawn(areaId, x, y) {
const area = Data.AREAS[areaId] || Data.AREAS.town;
const safeCurrent = isSafe(areaId, 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) =>
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 };
}
function currentObjective(state) {
if (!state) return "Choose a traveler.";
if (state.story.ending) return "Archive Town remembers. Explore freely.";
if (state.sigils.length === 8) return "Enter the Grand Library Vault and confront the Redactor.";
const active = Data.QUEST_IDS.find((id) => state.quests[id] && state.quests[id].status === "active");
if (active) return Data.QUESTS[active].objective;
if (!state.sigils.includes("gate")) return "Speak with Gatekeeper Orin at the Town Gate.";
if (state.sigils.length >= 4 && !state.story.midpointSeen) return "Bring four sigils to Curator Lima.";
return `Recover the Archive Sigils (${state.sigils.length} / 8).`;
}
return Object.freeze({
normalizedVector, approachVelocity, discover, startQuest, progressQuest, completeQuest, updateStory, addItem, itemQuantity,
consumeItem, grantXp, takeDamage, respawn, recordBoss, isSafe, isCombatSafe,
interactionRadius, nearestInteraction, safeSpawnForState,
nearestSafeSpawn, nearestNamedSafeSpawn, currentObjective
});
}));

View File

@@ -1,298 +0,0 @@
(function (root) {
"use strict";
const Data = root.ArchiveWorldData;
const Systems = root.ArchiveWorldSystems;
function create(container, callbacks) {
const overlay = container.querySelector("[data-world-overlay]");
const panel = container.querySelector("[data-world-panel]");
const title = container.querySelector("[data-world-panel-title]");
const eyebrow = container.querySelector("[data-world-panel-eyebrow]");
const body = container.querySelector("[data-world-panel-body]");
const closeButton = container.querySelector("[data-world-panel-close]");
let returnFocus = null;
let sequence = [];
closeButton.addEventListener("click", close);
overlay.addEventListener("click", (event) => { if (event.target === overlay) close(); });
panel.addEventListener("keydown", trapFocus);
body.addEventListener("click", onAction);
body.addEventListener("submit", onSubmit);
function trapFocus(event) {
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key !== "Tab") return;
const focusable = Array.from(panel.querySelectorAll("a[href], button:not([disabled]), select, input, [tabindex]:not([tabindex='-1'])"));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function show(kind, heading, label, html) {
returnFocus = document.activeElement;
sequence = [];
overlay.dataset.panelKind = kind;
eyebrow.textContent = label;
title.textContent = heading;
body.innerHTML = html;
overlay.hidden = false;
container.dataset.panelOpen = "true";
callbacks.onLock(true);
window.setTimeout(() => {
const target = panel.querySelector("[autofocus], button, a, select");
if (target) target.focus();
}, 0);
}
function close() {
if (overlay.hidden) return;
overlay.hidden = true;
container.dataset.panelOpen = "false";
body.replaceChildren();
callbacks.onLock(false);
const focus = returnFocus && document.contains(returnFocus) ? returnFocus : container.querySelector("#archive-world-game");
if (focus) focus.focus();
}
function openLandmark(id, state) {
const place = Data.LANDMARKS[id];
if (!place) return;
const quest = Data.QUESTS[place.questId];
const questState = state.quests[place.questId];
const discovered = state.discovered.includes(id);
const sigil = state.sigils.includes(id);
const npc = Data.NPCS[place.npc];
const links = place.links.map(([label, href]) => `<a href="${href}" data-world-destination>${label}</a>`).join("");
let action = "";
if (questState.status === "locked") action = `<button type="button" data-action="start-quest" data-id="${place.questId}">Accept quest</button>`;
else if (questState.status === "active") {
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>`
: id === "playroom"
? `<button type="button" data-action="travel" data-area="playroom">Enter the lantern maze</button>`
: quest.kind === "boss"
? `<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>`;
} else action = `<p class="rpg-panel__complete">✓ Quest complete · Archive Sigil restored</p>`;
if (id === "library" && state.sigils.length === 8) {
action += state.defeatedBosses.includes("redactor")
? `<button type="button" data-action="travel" data-area="vault">Visit the restored vault</button>`
: `<button type="button" data-action="travel" data-area="vault">Open the Archive Vault</button>`;
} else if (place.area && questState.status === "complete") {
action += `<button type="button" data-action="travel" data-area="${place.area}">Revisit ${place.title}</button>`;
}
show("landmark", place.title, place.eyebrow, `
<div class="rpg-panel__landmark-icon" aria-hidden="true">${sigil ? "✦" : "◇"}</div>
<p>${place.description}</p>
<blockquote><strong>${npc.name}:</strong> ${npc.intro}</blockquote>
<dl class="rpg-panel__facts">
<div><dt>Discovered</dt><dd>${discovered ? "Recorded" : "New location"}</dd></div>
<div><dt>Quest</dt><dd>${quest.title}</dd></div>
<div><dt>Objective</dt><dd>${quest.objective}</dd></div>
<div><dt>Reward</dt><dd>${quest.reward}</dd></div>
</dl>
<div class="rpg-panel__actions">${action}</div>
<nav class="rpg-panel__destinations" aria-label="${place.title} website destinations">
<h3>Archive destinations</h3>${links}
</nav>
<p class="rpg-panel__hint">E/Enter confirms · Escape returns to town</p>
`);
}
function openNpc(id, state) {
const npc = Data.NPCS[id];
if (!npc) return;
if (npc.landmark) return openLandmark(npc.landmark, state);
const side = state.quests.lost_letter;
let action = "";
if (id === "nell" && side.status !== "complete") {
action = `<button type="button" data-action="start-choice">Help with the letter</button>`;
}
show("dialogue", npc.name, "Conversation", `<blockquote>${npc.intro}</blockquote>${action}<button type="button" data-action="return">Farewell</button>`);
}
function openChallenge(questId, state) {
const quest = Data.QUESTS[questId];
const npc = Data.NPCS[Data.LANDMARKS[quest.landmark].npc];
if (!quest) return;
if (quest.kind === "sequence") {
const options = quest.options.map((value) =>
`<button type="button" data-action="sequence" data-id="${questId}" data-value="${value}">${pretty(value)}</button>`).join("");
show("puzzle", quest.title, "Archive puzzle", `
<p>${quest.objective}</p><p class="rpg-panel__sequence" data-sequence-status>Choose the first symbol.</p>
<div class="rpg-panel__puzzle-buttons">${options}</div>
<details><summary>Hint from ${npc.name}</summary><p>${npc.hint}</p></details>
`);
} else if (quest.kind === "matching") {
const ownerOptions = `<option value="">Choose…</option><option value="sailor">Sailor</option><option value="gardener">Gardener</option><option value="child">Child</option>`;
show("puzzle", quest.title, "Memory matching", `
<form data-matching="${questId}">
<p>Return each memory to its owner. Every owner receives one memory.</p>
<label>Rain memory<select name="rain">${ownerOptions}</select></label>
<label>Seed memory<select name="seed">${ownerOptions}</select></label>
<label>Red kite memory<select name="kite">${ownerOptions}</select></label>
<p data-match-status aria-live="polite"></p>
<button type="submit">Restore the labels</button>
</form>
<details><summary>Hint from ${npc.name}</summary><p>${npc.hint}</p></details>
`);
}
}
function openMenu(state, tab) {
const active = tab || "quests";
const nav = ["quests", "inventory", "journal", "controls", "settings"].map((name) =>
`<button type="button" data-action="menu-tab" data-tab="${name}" ${name === active ? 'aria-current="page"' : ""}>${pretty(name)}</button>`).join("");
let content = "";
if (active === "quests") {
const rows = Data.QUEST_IDS.filter((id) => state.quests[id].status !== "locked").map((id) => {
const q = Data.QUESTS[id];
return `<li class="is-${state.quests[id].status}"><strong>${q.title}</strong><span>${state.quests[id].status === "complete" ? "Complete" : q.objective}</span><small>${q.reward}</small></li>`;
}).join("") || "<li>Speak with Gatekeeper Orin to begin.</li>";
content = `<h3>Quest log</h3><p class="rpg-panel__objective">${Systems.currentObjective(state)}</p><ul class="rpg-menu-list">${rows}</ul>`;
} else if (active === "inventory") {
const rows = state.inventory.map((entry) => {
const item = Data.ITEMS[entry.id];
const use = item.type === "consumable" ? `<button type="button" data-action="use-item" data-id="${entry.id}">Use</button>` : "";
return `<li><span class="rpg-item-icon rpg-item-icon--${item.icon}" aria-hidden="true"></span><div><strong>${item.name}${entry.quantity > 1 ? ` ×${entry.quantity}` : ""}</strong><small>${item.description}</small></div>${use}</li>`;
}).join("");
content = `<h3>Inventory</h3><ul class="rpg-menu-list rpg-inventory">${rows}</ul>`;
} else if (active === "journal") {
content = `<h3>Discovery journal</h3><ul class="rpg-menu-list">${Data.LANDMARK_IDS.map((id) =>
`<li><strong>${state.discovered.includes(id) ? "✦" : "◇"} ${Data.LANDMARKS[id].title}</strong><span>${state.sigils.includes(id) ? "Sigil restored" : state.discovered.includes(id) ? "Discovered" : "Unexplored"}</span></li>`).join("")}</ul>`;
} else if (active === "controls") {
content = `<h3>Controls</h3><dl class="rpg-controls-list">
<div><dt>Move</dt><dd>WASD / arrow keys / direction pad</dd></div>
<div><dt>Attack</dt><dd>Space / Attack</dd></div><div><dt>Explore</dt><dd>E or Enter / Explore</dd></div>
<div><dt>Use item</dt><dd>Q / Item</dd></div><div><dt>Cycle item</dt><dd>Tab</dd></div>
<div><dt>Pause</dt><dd>Escape / Menu</dd></div></dl>`;
} else {
content = `<h3>Settings</h3>
<label class="rpg-toggle"><input type="checkbox" data-setting="assist" ${state.settings.assist ? "checked" : ""}> Assist mode</label>
<label>Ambience <input type="range" min="0" max="1" step=".05" value="${state.settings.ambience}" data-volume="ambience"></label>
<label>Music <input type="range" min="0" max="1" step=".05" value="${state.settings.music}" data-volume="music"></label>
<label>Effects <input type="range" min="0" max="1" step=".05" value="${state.settings.effects}" data-volume="effects"></label>
<button type="button" data-action="toggle-fullscreen">Toggle fullscreen</button>
<button type="button" data-action="confirm-reset" class="rpg-danger">Reset all Archive World progress</button>`;
}
show("menu", "Traveler's folio", "Paused", `<nav class="rpg-menu-tabs" aria-label="Game menu">${nav}</nav><div class="rpg-menu-content">${content}</div>`);
}
function openDefeat() {
show("defeat", "The Bookmark holds your place", "Defeated", `<p>The Redactor could not remove what you have already restored.</p><button type="button" data-action="respawn" autofocus>Return to the last safe place</button>`);
}
function openEnding(state) {
show("ending", "Context restored", "The Archive remembers", `
<p>The eight sigils do not destroy the Redactor. They give it back the connections it removed.</p>
<blockquote>“I was built to preserve everything,” it remembers. “I mistook emptiness for safety.”</blockquote>
<p>${state.name} closes the Living Bookmark. Across town, lamps relight, signs regain their names, and every unfinished story is allowed to remain unfinished.</p>
<button type="button" data-action="return-town" autofocus>Return to the restored town</button>
`);
}
function openReset() {
show("confirm", "Reset this traveler?", "Confirmation", `<p>This clears both Archive World save versions on this device. It cannot be undone.</p><button type="button" data-action="reset">Reset everything</button><button type="button" data-action="return">Keep my progress</button>`);
}
function onAction(event) {
const button = event.target.closest("[data-action]");
if (!button) return;
const action = button.dataset.action;
if (action === "return") return close();
if (action === "start-quest") {
callbacks.onStartQuest(button.dataset.id);
const quest = Data.QUESTS[button.dataset.id];
close();
if (!["combat", "boss"].includes(quest.kind)) window.setTimeout(() => openChallenge(button.dataset.id, callbacks.getState()), 0);
} else if (action === "challenge") {
openChallenge(button.dataset.id, callbacks.getState());
} else if (action === "sequence") {
const quest = Data.QUESTS[button.dataset.id];
sequence.push(button.dataset.value);
const valid = sequence.every((value, index) => value === quest.sequence[index]);
const status = body.querySelector("[data-sequence-status]");
if (!valid) {
sequence = [];
status.textContent = "The sequence faded. Begin again; there is no penalty.";
} else if (sequence.length === quest.sequence.length) {
callbacks.onCompleteQuest(button.dataset.id);
openLandmark(quest.landmark, callbacks.getState());
} else {
status.textContent = `${sequence.length} of ${quest.sequence.length} symbols hold.`;
}
} else if (action === "travel") {
const area = button.dataset.area;
close();
callbacks.onTravel(area);
} else if (action === "menu-tab") {
openMenu(callbacks.getState(), button.dataset.tab);
} else if (action === "use-item") {
callbacks.onUseItem(button.dataset.id);
openMenu(callbacks.getState(), "inventory");
} else if (action === "start-choice") {
show("choice", "The Letter Nell Kept", "Optional story", `<p>Nell asks whether an unaddressed letter should be opened, delivered to the Museum, or kept private.</p>
<button type="button" data-action="finish-choice" data-choice="museum">Preserve it unopened at the Museum</button>
<button type="button" data-action="finish-choice" data-choice="open">Open it together</button>
<button type="button" data-action="finish-choice" data-choice="keep">Let Nell keep it</button>`);
} else if (action === "finish-choice") {
callbacks.onChoice(button.dataset.choice);
close();
} else if (action === "respawn") {
close();
callbacks.onRespawn();
} else if (action === "return-town") {
close();
callbacks.onTravel("town");
} else if (action === "toggle-fullscreen") {
callbacks.onFullscreen();
} else if (action === "confirm-reset") {
openReset();
} else if (action === "reset") {
callbacks.onReset();
}
}
function onSubmit(event) {
const form = event.target.closest("[data-matching]");
if (!form) return;
event.preventDefault();
const quest = Data.QUESTS[form.dataset.matching];
const values = Object.fromEntries(new FormData(form));
const correct = Object.entries(quest.matches).every(([memory, owner]) => values[memory] === owner)
&& new Set(Object.values(values)).size === Object.keys(quest.matches).length;
const status = form.querySelector("[data-match-status]");
if (!correct) {
status.textContent = "Those memories do not settle. Try another matching.";
return;
}
callbacks.onCompleteQuest(form.dataset.matching);
openLandmark(quest.landmark, callbacks.getState());
}
function pretty(value) {
return String(value).replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
return Object.freeze({
close, isOpen: () => !overlay.hidden, openLandmark, openNpc, openChallenge, openMenu, openDefeat, openEnding,
refreshMenu: (state) => { if (!overlay.hidden && overlay.dataset.panelKind === "menu") openMenu(state, "quests"); }
});
}
root.ArchiveWorldUI = Object.freeze({ create });
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -1,422 +0,0 @@
(function () {
"use strict";
const Data = window.ArchiveWorldData;
const State = window.ArchiveWorldState;
const Systems = window.ArchiveWorldSystems;
const UI = window.ArchiveWorldUI;
const Scenes = window.ArchiveWorldScenes;
const Audio = window.ArchiveWorldAudio;
const controller = {
root: null,
state: null,
game: null,
scene: null,
ui: null,
audio: null,
lock: false,
move: { up: false, down: false, left: false, right: false },
prompt: "",
lastSaveAt: 0,
systemReducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
getState: () => controller.state,
locked: () => controller.lock,
reducedMotion: () => controller.state && controller.state.settings.reducedMotion !== null
? controller.state.settings.reducedMotion : controller.systemReducedMotion
};
document.addEventListener("DOMContentLoaded", init);
function init() {
const root = document.querySelector('.archive-world[data-play-page="archive-world"]');
if (!root || !Data || !State || !Systems || !UI || !Scenes || !Audio) return;
controller.root = root;
controller.audio = Audio.create(() => controller.state);
bindSetup();
bindControls();
bindSettings();
controller.ui = UI.create(root, {
getState: () => controller.state,
onLock: (locked) => { controller.lock = locked; },
onStartQuest: startQuest,
onCompleteQuest: completeQuest,
onTravel: travel,
onUseItem: useItem,
onChoice: completeChoice,
onRespawn: respawn,
onFullscreen: toggleFullscreen,
onReset: resetAll
});
const loaded = loadState();
if (loaded.state) {
controller.state = developmentAreaOverride(loaded.state);
renderHud();
startGame();
if (loaded.message) status(loaded.message);
} else {
openSetup(loaded.message);
}
}
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() {
try {
const rawV2 = localStorage.getItem(State.STORAGE_KEY);
if (rawV2) {
const parsed = State.parse(rawV2);
return parsed
? { state: ensureSafePosition(parsed), message: null }
: { state: null, message: "The saved Archive World data was malformed, so a clean traveler setup is ready." };
}
const rawV1 = localStorage.getItem(State.LEGACY_KEY);
if (rawV1) {
const migrated = State.parse(rawV1);
if (migrated) {
persistState(migrated);
return { state: ensureSafePosition(migrated), message: "Your original Archive World traveler was safely migrated to the new adventure." };
}
return { state: null, message: "The older save could not be restored, so a clean traveler setup is ready." };
}
} catch (_error) {
return { state: null, message: "Local saving is unavailable. You can still play during this visit." };
}
return { state: null, message: null };
}
function ensureSafePosition(state) {
if (state.health <= 0) state = Systems.respawn(state);
const safe = Systems.safeSpawnForState(state);
return State.withPosition(state, safe.area, safe.x, safe.y, state.position.facing, safe.spawn);
}
function bindSetup() {
const dialog = controller.root.querySelector("[data-world-setup]");
const form = controller.root.querySelector("[data-world-setup-form]");
if (!dialog || !form) return;
dialog.addEventListener("cancel", (event) => event.preventDefault());
form.addEventListener("submit", (event) => {
event.preventDefault();
const formData = new FormData(form);
const name = State.validateName(formData.get("name"));
const palette = State.validatePalette(formData.get("palette"));
const error = controller.root.querySelector("[data-world-setup-error]");
if (!name || !palette) {
error.textContent = "Choose a palette and enter a name between 1 and 20 characters.";
return;
}
controller.state = State.fresh(name, palette);
persistState(controller.state);
dialog.close();
renderHud();
startGame();
});
}
function openSetup(message) {
const dialog = controller.root.querySelector("[data-world-setup]");
if (!dialog) {
status(message || "Traveler setup could not open. Reload the page to try again; the plain directory remains available.");
return;
}
if (message) {
controller.root.querySelector("[data-world-setup-error]").textContent = message;
status(message);
}
window.setTimeout(() => {
dialog.showModal();
dialog.querySelector("input[name=name]").focus();
}, 0);
}
function bindControls() {
controller.root.querySelectorAll("[data-world-move]").forEach((button) => {
const direction = button.dataset.worldMove;
const down = (event) => {
event.preventDefault();
controller.move[direction] = true;
controller.root.dataset.touching = "true";
};
const up = (event) => {
event.preventDefault();
controller.move[direction] = false;
controller.root.dataset.touching = "false";
};
button.addEventListener("pointerdown", down);
["pointerup", "pointercancel", "pointerleave"].forEach((name) => button.addEventListener(name, up));
});
bindClick("[data-world-interact]", () => {
if (controller.scene) controller.scene.interact();
});
bindClick("[data-world-attack]", () => {
if (controller.scene) controller.scene.attack(controller.scene.time.now);
});
bindClick("[data-world-item]", useSelectedItem);
bindClick("[data-world-menu]", openMenu);
bindClick("[data-world-quests]", () => controller.ui.openMenu(controller.state, "quests"));
bindClick("[data-world-inventory]", () => controller.ui.openMenu(controller.state, "inventory"));
bindClick("[data-world-journal-open]", () => controller.ui.openMenu(controller.state, "journal"));
bindClick("[data-world-sound]", toggleSound);
bindClick("[data-world-fullscreen]", toggleFullscreen);
bindClick("[data-world-reset]", () => controller.ui.openMenu(controller.state, "settings"));
document.addEventListener("fullscreenchange", updateFullscreen);
document.addEventListener("visibilitychange", () => {
if (document.hidden && controller.scene) controller.scene.persistPosition(false);
});
window.addEventListener("pagehide", () => {
if (controller.scene) controller.scene.persistPosition(false);
});
if (!document.fullscreenEnabled) controller.root.querySelectorAll("[data-world-fullscreen]").forEach((button) => { button.hidden = true; });
}
function bindSettings() {
controller.root.addEventListener("change", (event) => {
if (!controller.state) return;
if (event.target.matches("[data-setting='assist']")) {
controller.state.settings.assist = event.target.checked;
setState(controller.state, event.target.checked ? "Assist mode enabled." : "Assist mode disabled.");
} else if (event.target.matches("[data-volume]")) {
controller.state.settings[event.target.dataset.volume] = Number(event.target.value);
setState(controller.state, null, true);
controller.audio.apply();
}
});
}
function startGame() {
if (!window.Phaser) {
status("The world engine could not load. Every destination remains available in the plain directory.");
return;
}
const sceneClasses = Scenes.createSceneClasses(controller);
controller.game = new Phaser.Game({
type: Phaser.CANVAS,
parent: "archive-world-game",
width: 960,
height: 640,
backgroundColor: "#101b18",
pixelArt: true,
roundPixels: true,
physics: { default: "arcade", arcade: { debug: false } },
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
scene: sceneClasses,
input: { keyboard: true, mouse: true, touch: true },
render: { antialias: false, pixelArt: true, roundPixels: true }
});
controller.root.dataset.gameReady = "true";
controller.root.querySelector("#archive-world-game").focus();
window.archiveWorldGame = controller.game;
}
function startQuest(id) {
setState(Systems.startQuest(controller.state, id), `${Data.QUESTS[id].title} added to the quest log.`);
controller.audio.play("quest");
}
function completeQuest(id) {
let next = Systems.completeQuest(controller.state, id);
if (id === "museum" && next.sigils.length >= 4) {
next.story.midpointSeen = true;
next = Systems.updateStory(next);
status("Midpoint revelation: the Redactor was built to preserve the archive, but learned to mistake emptiness for safety.");
}
setState(next, `${Data.QUESTS[id].title} complete. The ${Data.LANDMARKS[Data.QUESTS[id].landmark].title} Sigil is restored.`);
controller.audio.play("quest");
}
function completeChoice(choice) {
let next = Systems.startQuest(controller.state, "lost_letter");
next.npcFlags.nellChoice = choice;
next = Systems.completeQuest(next, "lost_letter");
setState(next, "Nell remembers that choosing what to preserve is part of the story.");
controller.audio.play("dialogue");
}
function openLandmark(id) {
const wasNew = !controller.state.discovered.includes(id);
setState(Systems.discover(controller.state, id), wasNew ? `${Data.LANDMARKS[id].title} added to the discovery journal.` : null);
if (wasNew) controller.audio.play("quest", { volume: 0.7 });
controller.ui.openLandmark(id, controller.state);
}
function openNpc(id) {
controller.audio.play("dialogue", { volume: 0.45 });
controller.ui.openNpc(id, controller.state);
}
function openChallenge(id) {
if (controller.state.quests[id].status === "locked") controller.state = Systems.startQuest(controller.state, id);
controller.ui.openChallenge(id, controller.state);
}
function openMenu() {
if (!controller.state || controller.ui.isOpen()) return;
controller.ui.openMenu(controller.state, "quests");
}
function travel(area) {
if (!Data.AREAS[area]) return;
const spawn = Data.AREAS[area].spawn;
controller.state = State.withPosition(controller.state, area, spawn.x, spawn.y, "north", "entrance");
controller.state = State.withCheckpoint(controller.state, area, "entrance", spawn.x, spawn.y);
persistState(controller.state);
if (controller.scene) controller.scene.scene.restart({ area });
}
function useSelectedItem() {
useItem(controller.state.selectedItem);
}
function useItem(id) {
const result = Systems.consumeItem(controller.state, id);
if (!result.used) {
status(controller.state.health >= controller.state.maxHealth ? "Health is already full." : "That item cannot be used now.");
return;
}
setState(result.state, `${Data.ITEMS[id].name} restored ${result.amount} health.`);
controller.audio.play("item");
}
function cycleItem() {
const consumables = controller.state.inventory.filter((entry) => Data.ITEMS[entry.id].type === "consumable");
if (!consumables.length) return status("No usable items are in the inventory.");
const index = consumables.findIndex((entry) => entry.id === controller.state.selectedItem);
controller.state.selectedItem = consumables[(index + 1) % consumables.length].id;
setState(controller.state, `${Data.ITEMS[controller.state.selectedItem].name} selected.`);
}
function respawn() {
const next = Systems.respawn(controller.state);
controller.state = next;
persistState(next);
travel(next.position.area);
status("The Living Bookmark returns you to a safe place. Story and items were preserved.");
}
function toggleSound() {
if (!controller.state) return;
controller.state.settings.soundEnabled = !controller.state.settings.soundEnabled;
if (controller.state.settings.soundEnabled) controller.audio.unlock();
controller.audio.apply();
setState(controller.state, controller.state.settings.soundEnabled ? "Archive World audio enabled." : "Audio muted.");
}
async function toggleFullscreen() {
if (!document.fullscreenEnabled) return;
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await controller.root.querySelector("[data-world-shell]").requestFullscreen();
} catch (_error) {
status("Fullscreen is unavailable in this browser.");
}
}
function updateFullscreen() {
const active = Boolean(document.fullscreenElement);
controller.root.querySelectorAll("[data-world-fullscreen]").forEach((button) => {
button.textContent = active ? "Exit fullscreen" : "Fullscreen";
button.setAttribute("aria-pressed", String(active));
});
if (controller.game) controller.game.scale.refresh();
if (!active) controller.root.querySelector("#archive-world-game").focus();
}
function resetAll() {
try {
localStorage.removeItem(State.STORAGE_KEY);
localStorage.removeItem(State.LEGACY_KEY);
} catch (_error) {
// Reload still provides a fresh in-memory state.
}
window.location.reload();
}
function setState(next, message, quiet) {
controller.state = State.normalize(next) || controller.state;
if (!quiet) renderHud();
persistState(controller.state);
if (message) status(message);
}
function persistState(state) {
try {
localStorage.setItem(State.STORAGE_KEY, JSON.stringify(State.normalize(state)));
} catch (_error) {
status("Progress could not be saved on this device.");
}
}
function renderHud() {
if (!controller.state) return;
const state = controller.state;
text("[data-world-player]", state.name);
text("[data-world-level]", `Lv ${state.level}`);
text("[data-world-health-text]", `${Math.ceil(state.health)} / ${state.maxHealth}`);
const healthBar = controller.root.querySelector("[data-world-health-bar]");
if (healthBar) healthBar.style.width = `${(state.health / state.maxHealth) * 100}%`;
text("[data-world-objective]", Systems.currentObjective(state));
text("[data-world-discovery-count]", `${state.discovered.length} / 8`);
text("[data-world-sigil-count]", `${state.sigils.length} / 8`);
text("[data-world-sound]", state.settings.soundEnabled ? "Sound: on" : "Sound: muted");
const selected = Data.ITEMS[state.selectedItem];
text("[data-world-selected-item]", selected ? `${selected.name} ×${Systems.itemQuantity(state, state.selectedItem)}` : "None");
const badge = controller.root.querySelector("[data-world-badge]");
if (badge) badge.hidden = !state.complete;
controller.root.classList.toggle("is-restored", state.story.ending);
}
function setPrompt(message) {
if (message === controller.prompt) return;
controller.prompt = message;
text("[data-world-prompt]", message || "Follow the paths, signs, and warm window light.");
}
function status(message) {
text("[data-world-status]", message);
}
function showBoss(name, health, maximum) {
const boss = controller.root.querySelector("[data-world-boss]");
boss.hidden = false;
text("[data-world-boss-name]", name);
boss.querySelector("[data-world-boss-bar]").style.width = `${Math.max(0, health / maximum) * 100}%`;
}
function hideBoss() {
controller.root.querySelector("[data-world-boss]").hidden = true;
}
function openDefeat() {
controller.lock = true;
controller.ui.openDefeat();
}
function openEnding() {
controller.ui.openEnding(controller.state);
}
function text(selector, value) {
const node = controller.root.querySelector(selector);
if (node) node.textContent = value;
}
function bindClick(selector, handler) {
const node = controller.root.querySelector(selector);
if (node) node.addEventListener("click", handler);
}
Object.assign(controller, {
setState, renderHud, status, setPrompt, showBoss, hideBoss, openDefeat, openEnding,
openLandmark, openNpc, openChallenge, openMenu, travel, useSelectedItem, cycleItem
});
}());

View File

@@ -76,7 +76,7 @@
caption: "A wooden moon rolls beneath the cabinet whenever nobody is looking.",
curated: [
["Play hub", "/play/play.html"],
["The Archive World", "/play/rpg.html"],
["Rescue Princess Lima", "/play/rpg.html"],
["The Rain Index", "/play/the-rain-index.html"]
],
include: ["/play/"],

View File

@@ -0,0 +1,66 @@
(function (root) {
"use strict";
const TRACKS = Object.freeze({
village: "village-theme", forest: "forest-theme", ruins: "forest-theme",
mountain: "mountain-theme", camp: "mountain-theme",
fortressExterior: "fortress-theme", fortressInterior: "fortress-theme",
bossArena: "boss-theme", chamber: "victory-theme"
});
function create(getState) {
let scene = null;
let ambience = null;
let unlocked = false;
function attach(nextScene, region) {
scene = nextScene;
if (ambience) ambience.stop();
const key = TRACKS[region] || "village-theme";
ambience = scene.cache.audio.exists(key) ? scene.sound.add(key, { loop: true }) : null;
apply();
}
function unlock() {
unlocked = true;
if (scene && scene.sound.locked && scene.sound.unlock) scene.sound.unlock();
apply();
}
function apply() {
if (!scene) return;
const state = getState();
const enabled = Boolean(unlocked && state && state.settings.soundEnabled);
scene.sound.mute = !enabled;
if (ambience) {
ambience.setVolume((state.settings.master || 0) * (state.settings.music || 0));
if (enabled && !ambience.isPlaying) ambience.play();
if (!enabled && ambience.isPlaying) ambience.pause();
}
}
function play(key, volume) {
const state = getState();
if (!scene || !unlocked || !state.settings.soundEnabled || !scene.cache.audio.exists(key)) return;
scene.sound.play(key, { volume: state.settings.master * state.settings.effects * (volume || 1) });
}
function suspend() {
if (ambience && ambience.isPlaying) ambience.pause();
}
function resume() {
apply();
}
function stop() {
if (ambience) ambience.stop();
ambience = null;
scene = null;
}
return Object.freeze({ attach, unlock, apply, play, suspend, resume, stop, isUnlocked: () => unlocked });
}
root.PrincessLimaAudio = Object.freeze({ create, TRACKS });
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -0,0 +1,247 @@
(function (root, factory) {
"use strict";
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
root.PrincessLimaData = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
const WIDTH = 1280;
const HEIGHT = 720;
function rect(x, y, width, height, kind) {
return Object.freeze({ shape: "rect", x, y, width, height, kind: kind || "wall" });
}
function circle(x, y, radius, kind) {
return Object.freeze({ shape: "circle", x, y, radius, kind: kind || "rock" });
}
function exit(id, x, y, width, height, target, spawn, requirement, label) {
return Object.freeze({ id, x, y, width, height, target, spawn, requirement: requirement || null, label });
}
function enemy(type, x, y, options) {
return Object.freeze(Object.assign({ type, x, y, leash: 170, quest: null, boss: false }, options || {}));
}
const ITEMS = Object.freeze({
village_sword: Object.freeze({ name: "Wayfarer Sword", type: "weapon", unique: true, description: "A balanced village-forged blade.", attack: 1 }),
tempered_sword: Object.freeze({ name: "Tempered Sword", type: "weapon", unique: true, description: "Bram's reforged blade. It breaks shadow armour.", attack: 2 }),
buckler: Object.freeze({ name: "Oak Buckler", type: "armour", unique: true, description: "Reduces incoming damage.", defence: 2 }),
trail_boots: Object.freeze({ name: "Trail Boots", type: "equipment", unique: true, description: "Quicker acceleration over rough ground." }),
forest_charm: Object.freeze({ name: "Forest Charm", type: "key", unique: true, description: "Proof that the Whispering Woods accepted your passage." }),
mountain_key: Object.freeze({ name: "Mountain Key", type: "key", unique: true, description: "Opens the old lift gate." }),
fortress_emblem: Object.freeze({ name: "Fortress Emblem", type: "key", unique: true, description: "Taken from the camp captain." }),
healing_tonic: Object.freeze({ name: "Healing Tonic", type: "consumable", stack: 9, description: "Restores 40 health.", heal: 40 }),
royal_draught: Object.freeze({ name: "Royal Draught", type: "consumable", stack: 3, description: "Fully restores health.", heal: 999 }),
silver_leaf: Object.freeze({ name: "Silver Leaf", type: "collectable", stack: 12, description: "A moonlit forest herb." }),
moon_coin: Object.freeze({ name: "Moon Coin", type: "currency", stack: 99, description: "Accepted by travelling merchants." }),
prison_key: Object.freeze({ name: "Prison Key", type: "quest", unique: true, protected: true, description: "Unlocks the fortress cells." }),
bridge_gear: Object.freeze({ name: "Bridge Gear", type: "quest", unique: true, protected: true, description: "Repairs the mountain bridge winch." }),
sun_crystal: Object.freeze({ name: "Sun Crystal", type: "quest", unique: true, protected: true, description: "Weakens the Shadow Lord's veil." })
});
const QUESTS = Object.freeze({
aftermath: Object.freeze({ title: "After the Black Riders", chapter: 1, region: "Broken Village", main: true, reward: [["village_sword", 1]], description: "Learn what happened to Princess Lima.", target: 1 }),
village_defence: Object.freeze({ title: "The Second Raid", chapter: 1, region: "Broken Village", main: true, reward: [["healing_tonic", 2], ["buckler", 1]], description: "Defend the square from three attackers.", target: 3 }),
healer_herbs: Object.freeze({ title: "Silver for the Wounded", chapter: 1, region: "Broken Village", main: false, reward: [["healing_tonic", 2]], description: "Bring two Silver Leaves to Healer Nia.", target: 2 }),
find_guide: Object.freeze({ title: "The Missing Guide", chapter: 2, region: "Whispering Woods", main: true, reward: [["forest_charm", 1], ["trail_boots", 1]], description: "Follow the standing stones and rescue Tovin.", target: 3 }),
ruins_light: Object.freeze({ title: "Light Beneath the Roots", chapter: 2, region: "Sunken Ruins", main: true, reward: [["sun_crystal", 1]], description: "Wake the ruin braziers in the marked order.", target: 4 }),
wolf_miniboss: Object.freeze({ title: "The Briar Wolf", chapter: 2, region: "Whispering Woods", main: true, reward: [["mountain_key", 1]], description: "Defeat the corrupted Briar Wolf.", target: 1 }),
repair_bridge: Object.freeze({ title: "A Road Across the Sky", chapter: 3, region: "Mountain Pass", main: true, reward: [["bridge_gear", 1], ["tempered_sword", 1]], description: "Restart both winches and repair the bridge.", target: 2 }),
stone_guardian: Object.freeze({ title: "Guardian of the Pass", chapter: 3, region: "Mountain Pass", main: true, reward: [["royal_draught", 1]], description: "Defeat the awakened Stone Guardian.", target: 1 }),
free_scout: Object.freeze({ title: "The Captured Scout", chapter: 3, region: "Blackridge Camp", main: true, reward: [["fortress_emblem", 1]], description: "Free Scout Elowen and defeat the camp captain.", target: 2 }),
free_prisoners: Object.freeze({ title: "No One Left in Shadow", chapter: 4, region: "Shadow Fortress", main: true, reward: [["prison_key", 1]], description: "Open the two prison cells.", target: 2 }),
break_wards: Object.freeze({ title: "The Three Shadow Wards", chapter: 4, region: "Shadow Fortress", main: true, reward: [["royal_draught", 1]], description: "Disable the three fortress wards.", target: 3 }),
defeat_malrec: Object.freeze({ title: "The Last Shadow", chapter: 4, region: "Throne of Night", main: true, reward: [], description: "Defeat Lord Malrec and rescue Princess Lima.", target: 1 })
});
const ENEMIES = Object.freeze({
slime: Object.freeze({ name: "Marsh Slime", health: 3, damage: 8, speed: 58, behaviour: "chase", frame: 9, xp: 8 }),
wolf: Object.freeze({ name: "Grey Wolf", health: 4, damage: 10, speed: 88, behaviour: "chase", frame: 10, xp: 12 }),
bandit: Object.freeze({ name: "Road Bandit", health: 5, damage: 12, speed: 66, behaviour: "chase", frame: 11, xp: 15 }),
bat: Object.freeze({ name: "Cave Bat", health: 3, damage: 9, speed: 96, behaviour: "wander", frame: 12, xp: 10 }),
guard: Object.freeze({ name: "Shadow Guard", health: 7, damage: 14, speed: 58, behaviour: "guard", frame: 13, xp: 20 }),
briar_wolf: Object.freeze({ name: "Briar Wolf", health: 22, damage: 15, speed: 92, behaviour: "charge", frame: 10, xp: 90, boss: true, phases: 2 }),
stone_guardian: Object.freeze({ name: "Stone Guardian", health: 30, damage: 18, speed: 45, behaviour: "slam", frame: 14, xp: 140, boss: true, phases: 2 }),
captain: Object.freeze({ name: "Captain Veyr", health: 24, damage: 17, speed: 64, behaviour: "guard", frame: 13, xp: 110, boss: true, phases: 2 }),
malrec: Object.freeze({ name: "Lord Malrec", health: 48, damage: 18, speed: 66, behaviour: "final", frame: 8, xp: 300, boss: true, phases: 3 })
});
const NPCS = Object.freeze({
elder: Object.freeze({ name: "Elder Corin", frame: 5, dialogue: ["The black riders took Princess Lima toward the northern fortress.", "We are small, traveller, but we are not helpless. Speak to Bram. Take a blade."] }),
bram: Object.freeze({ name: "Blacksmith Bram", frame: 6, dialogue: ["This sword was meant for a royal guard. Today, it chooses you.", "Bring the mountain forge back to life and I can temper it."] }),
nia: Object.freeze({ name: "Healer Nia", frame: 7, dialogue: ["The wounded need Silver Leaf. It grows where moonlight reaches the forest floor.", "Keep a tonic ready. Courage is easier with a second chance."] }),
tovin: Object.freeze({ name: "Guide Tovin", frame: 7, dialogue: ["I followed the riders until the Briar Wolf cornered me.", "Wake the stones from youngest tree to oldest. The true path will answer."] }),
elowen: Object.freeze({ name: "Scout Elowen", frame: 11, dialogue: ["Malrec's guards sealed the pass, but their captain carries the fortress emblem.", "Princess Lima is alive. She refused Malrec's bargain."] }),
prisoner: Object.freeze({ name: "Resistance Prisoner", frame: 5, dialogue: ["The wards feed the throne room. Break all three before facing Malrec."] }),
lima: Object.freeze({ name: "Princess Lima", frame: 4, dialogue: ["You crossed a kingdom for someone you had never met.", "Let us go home—not as legend and princess, but as two people who chose to help."] }),
malrec: Object.freeze({ name: "Lord Malrec", frame: 8, dialogue: ["Lima's oath could command every border lord. With it, I would end their endless quarrels.", "If the kingdom will not accept peace, shadow will make it obey."] })
});
const MAPS = Object.freeze({
village: Object.freeze({
name: "Broken Village", chapter: 1, palette: ["#263b2d", "#596b3a", "#b49355", "#3b2d2a"],
spawns: Object.freeze({ start: { x: 160, y: 570 }, square: { x: 640, y: 420 }, forestRoad: { x: 1110, y: 350 } }),
obstacles: Object.freeze([
rect(0, 0, 1280, 34, "edge"), rect(0, 686, 1280, 34, "edge"), rect(0, 0, 34, 720, "edge"), rect(1246, 0, 34, 720, "edge"),
rect(80, 70, 250, 170, "house"), rect(440, 58, 250, 175, "house"), rect(865, 70, 260, 175, "house"),
rect(60, 285, 360, 30, "fence"), rect(850, 285, 350, 30, "fence"),
circle(210, 430, 54, "well"), circle(1035, 475, 48, "rubble")
]),
exits: Object.freeze([exit("forest-road", 1210, 300, 60, 120, "forest", "villagePath", "village_defended", "Road to the Whispering Woods")]),
npcs: Object.freeze([["elder", 640, 330], ["bram", 520, 285], ["nia", 760, 285]]),
enemies: Object.freeze([
enemy("slime", 360, 500, { quest: "village_defence", requires: "aftermath_complete" }),
enemy("bandit", 620, 545, { quest: "village_defence", requires: "aftermath_complete" }),
enemy("slime", 900, 520, { quest: "village_defence", requires: "aftermath_complete" })
]),
pickups: Object.freeze([["silver_leaf", 365, 360], ["silver_leaf", 915, 370], ["moon_coin", 1090, 570]])
}),
forest: Object.freeze({
name: "Whispering Woods", chapter: 2, palette: ["#122d24", "#28513c", "#6f8a4d", "#a7b46b"],
spawns: Object.freeze({ villagePath: { x: 90, y: 355 }, ruinsPath: { x: 1110, y: 570 }, mountainPath: { x: 1120, y: 120 } }),
obstacles: Object.freeze([
rect(0, 0, 1280, 30, "edge"), rect(0, 690, 1280, 30, "edge"), rect(0, 0, 30, 720, "edge"), rect(1250, 0, 30, 720, "edge"),
rect(160, 40, 90, 245, "trees"), rect(160, 430, 90, 230, "trees"), rect(390, 170, 95, 420, "trees"),
rect(625, 35, 95, 290, "trees"), rect(625, 455, 95, 230, "trees"), rect(890, 150, 90, 420, "trees"),
circle(315, 350, 38, "stone"), circle(550, 385, 42, "stone"), circle(805, 350, 44, "stone")
]),
exits: Object.freeze([
exit("village", 20, 305, 60, 105, "village", "forestRoad", null, "Return to the village"),
exit("ruins", 1180, 525, 75, 120, "ruins", "forestDoor", "guide_found", "Sunken Ruins"),
exit("mountain", 1070, 20, 130, 65, "mountain", "forestTrail", "briar_defeated", "Mountain trail")
]),
npcs: Object.freeze([["tovin", 780, 570]]),
enemies: Object.freeze([
enemy("wolf", 320, 140), enemy("slime", 540, 610), enemy("bandit", 800, 160),
enemy("wolf", 1070, 430), enemy("briar_wolf", 1030, 105, { quest: "wolf_miniboss", boss: true, requires: "ruins_complete", leash: 260 })
]),
puzzle: Object.freeze({ id: "forest_stones", type: "sequence", sequence: ["sapling", "oak", "elder"], objects: [["sapling", 315, 350], ["oak", 550, 385], ["elder", 805, 350]] }),
pickups: Object.freeze([["silver_leaf", 325, 620], ["silver_leaf", 760, 90], ["healing_tonic", 1080, 610]])
}),
ruins: Object.freeze({
name: "Sunken Ruins", chapter: 2, palette: ["#17272d", "#31505a", "#6d7567", "#d49c55"],
spawns: Object.freeze({ forestDoor: { x: 110, y: 590 } }),
obstacles: Object.freeze([
rect(0, 0, 1280, 32, "edge"), rect(0, 688, 1280, 32, "edge"), rect(0, 0, 32, 720, "edge"), rect(1248, 0, 32, 720, "edge"),
rect(130, 100, 900, 34, "ruin-wall"), rect(130, 100, 34, 410, "ruin-wall"), rect(130, 476, 330, 34, "ruin-wall"),
rect(570, 476, 460, 34, "ruin-wall"), rect(996, 100, 34, 410, "ruin-wall"),
rect(340, 250, 110, 80, "water"), rect(700, 250, 110, 80, "water")
]),
exits: Object.freeze([exit("forest", 45, 540, 80, 120, "forest", "ruinsPath", null, "Return to the woods")]),
npcs: Object.freeze([]),
enemies: Object.freeze([enemy("bat", 300, 190), enemy("bat", 850, 190), enemy("slime", 580, 390)]),
puzzle: Object.freeze({ id: "ruin_braziers", type: "sequence", sequence: ["dawn", "noon", "dusk", "night"], objects: [["dawn", 250, 410], ["noon", 480, 190], ["dusk", 680, 410], ["night", 900, 190]] }),
pickups: Object.freeze([["moon_coin", 550, 210], ["healing_tonic", 900, 430]])
}),
mountain: Object.freeze({
name: "Mountain Pass", chapter: 3, palette: ["#202a35", "#465563", "#85909a", "#d4b06a"],
spawns: Object.freeze({ forestTrail: { x: 100, y: 590 }, campRoad: { x: 1140, y: 560 } }),
obstacles: Object.freeze([
rect(0, 0, 1280, 34, "edge"), rect(0, 686, 1280, 34, "edge"), rect(0, 0, 34, 720, "edge"), rect(1246, 0, 34, 720, "edge"),
rect(170, 60, 150, 430, "cliff"), rect(880, 70, 150, 440, "cliff"),
rect(350, 520, 110, 90, "boulder"), rect(780, 500, 105, 100, "boulder")
]),
dynamicObstacles: Object.freeze([Object.freeze({ id: "bridge", x: 450, y: 225, width: 330, height: 120, kind: "chasm", opensWith: "bridge_repaired" })]),
exits: Object.freeze([
exit("forest", 35, 530, 75, 120, "forest", "mountainPath", null, "Return to the woods"),
exit("camp", 1170, 510, 75, 125, "camp", "mountainRoad", "guardian_defeated", "Road to Blackridge Camp")
]),
npcs: Object.freeze([["bram", 350, 160]]),
enemies: Object.freeze([enemy("bat", 390, 400), enemy("guard", 830, 390), enemy("stone_guardian", 1080, 335, { quest: "stone_guardian", boss: true, requires: "bridge_repaired", leash: 280 })]),
puzzle: Object.freeze({ id: "bridge_winches", type: "set", sequence: ["west", "east"], objects: [["west", 380, 180], ["east", 845, 180]] }),
pickups: Object.freeze([["moon_coin", 400, 640], ["healing_tonic", 850, 640]])
}),
camp: Object.freeze({
name: "Blackridge Camp", chapter: 3, palette: ["#2b241f", "#584232", "#8b6a43", "#b98b50"],
spawns: Object.freeze({ mountainRoad: { x: 100, y: 600 }, fortressRoad: { x: 1160, y: 330 } }),
obstacles: Object.freeze([
rect(0, 0, 1280, 30, "edge"), rect(0, 690, 1280, 30, "edge"), rect(0, 0, 30, 720, "edge"), rect(1250, 0, 30, 720, "edge"),
rect(200, 90, 220, 150, "tent"), rect(520, 80, 220, 160, "tent"), rect(860, 80, 220, 160, "tent"),
rect(260, 430, 250, 35, "barricade"), rect(730, 430, 280, 35, "barricade"),
rect(520, 500, 20, 130, "cage"), rect(700, 500, 20, 130, "cage"), rect(520, 500, 200, 20, "cage"),
rect(520, 610, 70, 20, "cage"), rect(650, 610, 70, 20, "cage")
]),
exits: Object.freeze([
exit("mountain", 35, 550, 75, 120, "mountain", "campRoad", null, "Return to the pass"),
exit("fortress", 1170, 280, 75, 120, "fortressExterior", "campGate", "emblem_found", "Fortress road")
]),
npcs: Object.freeze([["elowen", 620, 555]]),
enemies: Object.freeze([enemy("guard", 340, 330, { quest: "free_scout" }), enemy("guard", 820, 340, { quest: "free_scout" }), enemy("captain", 1080, 530, { quest: "free_scout", boss: true, leash: 260 })]),
pickups: Object.freeze([["healing_tonic", 170, 300], ["moon_coin", 1070, 280]])
}),
fortressExterior: Object.freeze({
name: "Shadow Fortress Gate", chapter: 4, palette: ["#15131d", "#30283d", "#554a62", "#8a718f"],
spawns: Object.freeze({ campGate: { x: 120, y: 590 }, innerGate: { x: 1100, y: 625 } }),
obstacles: Object.freeze([
rect(0, 0, 1280, 30, "edge"), rect(0, 690, 1280, 30, "edge"), rect(0, 0, 30, 720, "edge"), rect(1250, 0, 30, 720, "edge"),
rect(120, 70, 1040, 85, "fortress-wall"), rect(120, 70, 95, 450, "fortress-wall"), rect(1065, 70, 95, 450, "fortress-wall"),
rect(120, 500, 400, 75, "fortress-wall"), rect(760, 500, 400, 75, "fortress-wall"),
circle(420, 320, 65, "tower"), circle(860, 320, 65, "tower")
]),
exits: Object.freeze([
exit("camp", 35, 540, 80, 120, "camp", "fortressRoad", null, "Return to Blackridge"),
exit("interior", 580, 485, 120, 90, "fortressInterior", "frontHall", "emblem_found", "Enter the fortress")
]),
npcs: Object.freeze([]),
enemies: Object.freeze([enemy("guard", 330, 590), enemy("guard", 640, 350), enemy("guard", 950, 590)]),
pickups: Object.freeze([["healing_tonic", 640, 200]])
}),
fortressInterior: Object.freeze({
name: "Shadow Fortress", chapter: 4, palette: ["#111119", "#272333", "#51465d", "#b58b66"],
spawns: Object.freeze({ frontHall: { x: 640, y: 620 }, throneDoor: { x: 640, y: 110 } }),
obstacles: Object.freeze([
rect(0, 0, 1280, 32, "edge"), rect(0, 688, 1280, 32, "edge"), rect(0, 0, 32, 720, "edge"), rect(1248, 0, 32, 720, "edge"),
rect(170, 100, 35, 470, "wall"), rect(1075, 100, 35, 470, "wall"), rect(170, 100, 360, 35, "wall"), rect(750, 100, 360, 35, "wall"),
rect(390, 260, 35, 300, "wall"), rect(855, 260, 35, 300, "wall"),
rect(205, 420, 185, 35, "cell"), rect(890, 420, 185, 35, "cell")
]),
exits: Object.freeze([
exit("outside", 580, 650, 120, 60, "fortressExterior", "innerGate", null, "Leave the fortress"),
exit("throne", 580, 70, 120, 70, "bossArena", "entrance", "wards_broken", "Throne of Night")
]),
npcs: Object.freeze([["prisoner", 285, 350], ["elowen", 995, 350]]),
enemies: Object.freeze([enemy("guard", 520, 470), enemy("guard", 760, 470), enemy("bat", 640, 220)]),
puzzle: Object.freeze({ id: "shadow_wards", type: "set", sequence: ["moon", "crown", "flame"], objects: [["moon", 270, 180], ["crown", 640, 360], ["flame", 1010, 180]] }),
pickups: Object.freeze([["prison_key", 640, 520], ["healing_tonic", 1010, 560]])
}),
bossArena: Object.freeze({
name: "Throne of Night", chapter: 4, palette: ["#0d0b14", "#21182d", "#51335f", "#b76a85"],
spawns: Object.freeze({ entrance: { x: 640, y: 620 } }),
obstacles: Object.freeze([
rect(0, 0, 1280, 42, "edge"), rect(0, 678, 1280, 42, "edge"), rect(0, 0, 42, 720, "edge"), rect(1238, 0, 42, 720, "edge"),
circle(210, 180, 52, "pillar"), circle(1070, 180, 52, "pillar"), circle(210, 540, 52, "pillar"), circle(1070, 540, 52, "pillar")
]),
exits: Object.freeze([exit("chamber", 570, 30, 140, 70, "chamber", "door", "malrec_defeated", "Princess Lima's chamber")]),
npcs: Object.freeze([["malrec", 640, 170]]),
enemies: Object.freeze([enemy("malrec", 640, 260, { quest: "defeat_malrec", boss: true, requires: "boss_started", leash: 500 })]),
puzzle: Object.freeze({ id: "sun_pedestals", type: "set", sequence: ["west", "east"], objects: [["west", 320, 360], ["east", 960, 360]] }),
pickups: Object.freeze([])
}),
chamber: Object.freeze({
name: "The Dawn Chamber", chapter: 4, palette: ["#293346", "#58687e", "#d2b878", "#f1e4c4"],
spawns: Object.freeze({ door: { x: 640, y: 610 } }),
obstacles: Object.freeze([
rect(0, 0, 1280, 34, "edge"), rect(0, 686, 1280, 34, "edge"), rect(0, 0, 34, 720, "edge"), rect(1246, 0, 34, 720, "edge"),
rect(180, 90, 250, 80, "balcony"), rect(850, 90, 250, 80, "balcony"), circle(640, 270, 70, "dais")
]),
exits: Object.freeze([]),
npcs: Object.freeze([["lima", 640, 180]]),
enemies: Object.freeze([]),
pickups: Object.freeze([])
})
});
const MAP_IDS = Object.freeze(Object.keys(MAPS));
const QUEST_IDS = Object.freeze(Object.keys(QUESTS));
const ITEM_IDS = Object.freeze(Object.keys(ITEMS));
const ENEMY_IDS = Object.freeze(Object.keys(ENEMIES));
const NPC_IDS = Object.freeze(Object.keys(NPCS));
return Object.freeze({
WIDTH, HEIGHT, ITEMS, QUESTS, ENEMIES, NPCS, MAPS,
MAP_IDS, QUEST_IDS, ITEM_IDS, ENEMY_IDS, NPC_IDS
});
}));

View File

@@ -0,0 +1,363 @@
(function () {
"use strict";
const Data = window.PrincessLimaData;
const State = window.PrincessLimaState;
const Systems = window.PrincessLimaSystems;
const Scenes = window.PrincessLimaScenes;
const UI = window.PrincessLimaUI;
const Audio = window.PrincessLimaAudio;
const controller = {
root: null,
game: null,
scene: null,
state: null,
audio: null,
ui: null,
locked: true,
transitioning: false,
move: { up: false, down: false, left: false, right: false },
lastSaveAt: 0,
systemReducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
getState: () => controller.state,
reducedMotion: () => controller.state && controller.state.settings.reducedMotion !== null
? controller.state.settings.reducedMotion : controller.systemReducedMotion,
devWarn: (message) => {
if (["localhost", "127.0.0.1"].includes(window.location.hostname)) console.warn(`[Princess Lima RPG] ${message}`);
}
};
document.addEventListener("DOMContentLoaded", init, { once: true });
function init() {
const root = document.querySelector("[data-princess-lima-rpg]");
if (!root || !Data || !State || !Systems || !Scenes || !UI || !Audio || !window.Phaser) return gracefulFailure();
controller.root = root;
controller.state = loadState();
controller.audio = Audio.create(() => controller.state);
controller.ui = UI.create(root, {
getState: () => controller.state,
onLock: (value) => { controller.locked = value; },
onUseTonic: useTonic,
onSetting: updateSetting,
onFullscreen: toggleFullscreen,
onReset: resetSave,
onRespawn: respawn
});
bindInterface();
startEngine();
window.addEventListener("error", (event) => {
controller.devWarn(event.message);
status("The game recovered from an unexpected problem. Open the pause menu if controls do not respond.");
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) controller.audio.suspend();
else controller.audio.resume();
});
}
function loadState() {
try {
const raw = localStorage.getItem(State.STORAGE_KEY);
if (!raw) return null;
const parsed = State.parse(raw);
if (!parsed) {
queueStatus("The old save was invalid, so it was ignored safely.");
return null;
}
const params = new URLSearchParams(window.location.search);
const debugRegion = localDebug() && Data.MAPS[params.get("region")] ? params.get("region") : null;
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 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);
} catch (_error) {
queueStatus("Local saving is unavailable. You can still play this session.");
return null;
}
}
function startEngine() {
try {
const classes = Scenes.createSceneClasses(controller);
controller.game = new Phaser.Game({
type: Phaser.AUTO,
width: Data.WIDTH,
height: Data.HEIGHT,
parent: "princess-lima-game",
pixelArt: true,
roundPixels: true,
backgroundColor: "#0a0b12",
physics: { default: "arcade", arcade: { debug: localDebug(), gravity: { x: 0, y: 0 } } },
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, width: Data.WIDTH, height: Data.HEIGHT },
scene: classes,
render: { antialias: false, pixelArt: true }
});
} catch (error) {
controller.devWarn(error.message);
gracefulFailure("The game engine could not start. You can return safely to the website.");
}
}
function ready() {
controller.root.dataset.ready = "true";
const continueButton = controller.root.querySelector("[data-lima-continue]");
continueButton.disabled = !controller.state;
continueButton.textContent = controller.state ? `Continue · Chapter ${controller.state.chapter}` : "Continue";
controller.root.querySelector("[data-lima-loading]").hidden = true;
controller.root.querySelector("[data-lima-menu]").hidden = false;
flushQueuedStatus();
}
function bindInterface() {
const root = controller.root;
root.querySelector("[data-lima-new]").addEventListener("click", () => openSetup());
root.querySelector("[data-lima-continue]").addEventListener("click", () => {
if (controller.state) beginAdventure();
});
root.querySelector("[data-lima-credits]").addEventListener("click", () => controller.ui.show(
"credits", "Credits",
"<p>Designed and built for zainezq.com. Original fantasy artwork and locally generated audio. Powered by locally vendored Phaser.</p>"
));
root.querySelector("[data-lima-setup-form]").addEventListener("submit", submitSetup);
root.querySelector("[data-lima-setup-cancel]").addEventListener("click", closeSetup);
root.querySelectorAll("[data-lima-move]").forEach((button) => {
const direction = button.dataset.limaMove;
const on = (event) => { event.preventDefault(); controller.move[direction] = true; focusGame(); };
const off = (event) => { event.preventDefault(); controller.move[direction] = false; };
button.addEventListener("pointerdown", on);
button.addEventListener("pointerup", off);
button.addEventListener("pointercancel", off);
button.addEventListener("pointerleave", off);
});
bindButton("[data-lima-attack]", () => controller.scene && controller.scene.attack(controller.scene.time.now));
bindButton("[data-lima-interact]", () => controller.scene && controller.scene.interact());
bindButton("[data-lima-item]", useTonic);
bindButton("[data-lima-pause]", () => openPanel("pause"));
bindButton("[data-lima-inventory]", () => openPanel("inventory"));
bindButton("[data-lima-quests]", () => openPanel("quests"));
bindButton("[data-lima-sound]", toggleSound);
bindButton("[data-lima-fullscreen]", toggleFullscreen);
document.addEventListener("fullscreenchange", updateFullscreenButton);
}
function bindButton(selector, handler) {
controller.root.querySelectorAll(selector).forEach((button) => button.addEventListener("click", handler));
}
function openSetup() {
controller.root.querySelector("[data-lima-menu]").hidden = true;
controller.root.querySelector("[data-lima-setup]").hidden = false;
controller.root.querySelector("[data-lima-setup] input[name=name]").focus();
}
function closeSetup() {
controller.root.querySelector("[data-lima-setup]").hidden = true;
controller.root.querySelector("[data-lima-menu]").hidden = false;
}
function submitSetup(event) {
event.preventDefault();
const form = event.currentTarget;
const name = State.validName(form.elements.name.value);
const appearance = form.elements.appearance.value;
const error = controller.root.querySelector("[data-lima-setup-error]");
if (!name || !State.APPEARANCES.includes(appearance)) {
error.textContent = "Enter a name from 1 to 20 characters and choose an appearance.";
return;
}
controller.state = State.fresh(name, appearance);
save();
closeSetup();
beginAdventure();
}
function beginAdventure() {
controller.audio.unlock();
controller.root.querySelector("[data-lima-menu]").hidden = true;
controller.root.querySelector("[data-lima-hud]").hidden = false;
controller.root.querySelector("[data-lima-touch]").hidden = false;
controller.root.querySelector("[data-lima-status-stack]").hidden = false;
controller.locked = false;
controller.game.scene.start("LimaWorld", { region: controller.state.region });
focusGame();
}
function travel(region, spawn) {
if (controller.transitioning || !Data.MAPS[region]) return;
controller.transitioning = true;
persistPosition(false);
const point = Data.MAPS[region].spawns[spawn] || Object.values(Data.MAPS[region].spawns)[0];
let next = State.withPosition(controller.state, region, spawn, point.x, point.y, "south");
next = State.withCheckpoint(next, region, spawn, point.x, point.y);
setState(next, `Travelling to ${Data.MAPS[region].name}`);
controller.audio.play("door");
try {
controller.scene.scene.restart({ region });
} catch (error) {
controller.devWarn(`Transition recovered: ${error.message}`);
controller.transitioning = false;
controller.game.scene.start("LimaWorld", { region });
}
}
function reloadRegion() {
if (controller.scene) controller.scene.scene.restart({ region: controller.state.region });
}
function persistPosition(checkpoint) {
if (!controller.scene || !controller.scene.player || !controller.state) return;
const safe = Systems.nearestSafeSpawn(controller.state, controller.scene.regionId, controller.scene.player.x, controller.scene.player.y);
let next = State.withPosition(controller.state, safe.region, safe.spawn, safe.x, safe.y, controller.scene.facing);
if (checkpoint) next = State.withCheckpoint(next, safe.region, safe.spawn, safe.x, safe.y);
controller.state = next;
save();
}
function setState(next, message) {
const normalized = State.normalize(next);
if (!normalized) return;
controller.state = normalized;
save();
updateHud();
controller.audio.apply();
if (message) status(message);
}
function save() {
if (!controller.state) return;
try { localStorage.setItem(State.STORAGE_KEY, JSON.stringify(controller.state)); } catch (_error) { /* Session play remains available. */ }
}
function resetSave() {
try { localStorage.removeItem(State.STORAGE_KEY); } catch (_error) { /* Nothing else to clear. */ }
controller.state = null;
window.location.reload();
}
function respawn() {
controller.state = Systems.respawn(controller.state);
save();
controller.ui.close();
controller.locked = false;
controller.game.scene.start("LimaWorld", { region: controller.state.region });
}
function useTonic() {
if (!controller.state) return;
const result = Systems.useItem(controller.state, "healing_tonic");
if (!result.used) return status(controller.state.health >= controller.state.maxHealth ? "Health is already full." : "No Healing Tonics remain.");
setState(result.state, `Healing Tonic restores ${result.amount} health.`);
}
function updateSetting(key, value) {
if (!controller.state || !(key in controller.state.settings)) return;
controller.state.settings[key] = value;
setState(controller.state);
controller.root.classList.toggle("is-high-contrast", controller.state.settings.highContrast);
}
function toggleSound() {
if (!controller.state) return;
controller.audio.unlock();
controller.state.settings.soundEnabled = !controller.state.settings.soundEnabled;
setState(controller.state, controller.state.settings.soundEnabled ? "Audio enabled." : "Audio muted.");
}
async function toggleFullscreen() {
const shell = controller.root;
try {
if (!document.fullscreenElement) await shell.requestFullscreen();
else await document.exitFullscreen();
} catch (_error) {
status("Fullscreen is not available in this browser.");
}
}
function updateFullscreenButton() {
const button = controller.root.querySelector("[data-lima-fullscreen]");
const active = Boolean(document.fullscreenElement);
button.setAttribute("aria-pressed", String(active));
button.textContent = active ? "Exit Fullscreen" : "Fullscreen";
if (!active) focusGame();
}
function openDialogue(id, done) {
controller.ui.openDialogue(id, done);
}
function openPanel(kind) {
if (controller.state) controller.ui.openPanel(kind, controller.state);
}
function gameOver() {
controller.locked = true;
controller.ui.gameOver(controller.state);
}
function openEnding() {
controller.locked = true;
controller.ui.ending(controller.state);
}
function updateHud() {
if (!controller.state || !controller.root) return;
text("[data-lima-player]", controller.state.player.name);
text("[data-lima-health]", `${Math.ceil(controller.state.health)} / ${controller.state.maxHealth}`);
text("[data-lima-region]", Data.MAPS[controller.state.region].name);
text("[data-lima-chapter]", `Chapter ${controller.state.chapter}`);
text("[data-lima-objective]", Systems.currentObjective(controller.state));
text("[data-lima-tonics]", `Tonic ×${Systems.quantity(controller.state, "healing_tonic")}`);
text("[data-lima-sound]", controller.state.settings.soundEnabled ? "Sound On" : "Sound Muted");
const healthBar = controller.root.querySelector(".lima-hud__healthbar i");
if (healthBar) healthBar.style.width = `${controller.state.health / controller.state.maxHealth * 100}%`;
controller.root.classList.toggle("is-high-contrast", controller.state.settings.highContrast);
}
function prompt(message) {
text("[data-lima-prompt]", message || "Explore the road ahead.");
}
function status(message) {
text("[data-lima-status]", message);
}
function boss(name, health, maximum) {
const hud = controller.root.querySelector("[data-lima-boss]");
hud.hidden = false;
text("[data-lima-boss-name]", name);
hud.querySelector("i").style.width = `${Math.max(0, health / maximum) * 100}%`;
}
function text(selector, value) {
const node = controller.root && controller.root.querySelector(selector);
if (node) node.textContent = value;
}
function focusGame() {
const game = controller.root.querySelector("[data-lima-game]");
if (game) game.focus();
}
function localDebug() {
const params = new URLSearchParams(window.location.search);
return ["localhost", "127.0.0.1"].includes(window.location.hostname) && params.get("collisionDebug") === "1";
}
let queuedStatus = "";
function queueStatus(message) { queuedStatus = message; }
function flushQueuedStatus() { if (queuedStatus) status(queuedStatus); }
function gracefulFailure(message) {
const loading = document.querySelector("[data-lima-loading]");
if (loading) loading.innerHTML = `<strong>The adventure could not start.</strong><span>${message || "A required local game file is unavailable."}</span><a href="/">Exit to Website</a>`;
}
Object.assign(controller, {
ready, travel, reloadRegion, persistPosition, setState, updateHud, status, prompt, boss,
openDialogue, openPanel, gameOver, openEnding, useTonic
});
}());

View File

@@ -0,0 +1,535 @@
(function (root) {
"use strict";
const Data = root.PrincessLimaData;
const Systems = root.PrincessLimaSystems;
const State = root.PrincessLimaState;
function createSceneClasses(controller) {
class BootScene extends Phaser.Scene {
constructor() { super("LimaBoot"); }
preload() {
this.load.image("lima-title", "/assets/images/play/princess-lima/title-landscape.png");
this.load.spritesheet("lima-cast", "/assets/images/play/princess-lima/cast-atlas.png", { frameWidth: 314, frameHeight: 314 });
const audio = "/assets/audio/princess-lima/";
[
"village-theme", "forest-theme", "mountain-theme", "fortress-theme", "boss-theme", "victory-theme",
"step", "attack", "damage", "defeat", "pickup", "quest", "puzzle", "door", "victory"
].forEach((key) => this.load.audio(key, `${audio}${key}.wav`));
this.load.on("loaderror", (file) => controller.devWarn(`Optional asset failed: ${file.key}`));
}
create() {
this.add.image(Data.WIDTH / 2, Data.HEIGHT / 2, "lima-title").setDisplaySize(Data.WIDTH, Data.HEIGHT);
controller.ready();
}
}
class WorldScene extends Phaser.Scene {
constructor() {
super("LimaWorld");
this.regionId = "village";
this.facing = "east";
this.lastAttack = 0;
this.lastHit = 0;
this.stepDistance = 0;
this.previous = null;
this.puzzleInput = [];
this.enemySerial = 0;
}
init(data) {
this.regionId = Data.MAPS[data && data.region] ? data.region : controller.getState().region;
}
create() {
controller.scene = this;
controller.transitioning = false;
this.map = Data.MAPS[this.regionId];
this.physics.world.setBounds(0, 0, Data.WIDTH, Data.HEIGHT);
this.drawMap();
this.solids = this.physics.add.staticGroup();
Systems.activeObstacles(controller.getState(), this.regionId).forEach((shape) => this.addSolid(shape));
this.interactables = [];
this.createExits();
this.createPuzzle();
this.createPickups();
this.createNpcs();
this.createPlayer();
this.createEnemies();
this.createInput();
this.projectiles = this.physics.add.group();
this.physics.add.collider(this.projectiles, this.solids, (projectile) => projectile.destroy());
this.physics.add.overlap(this.player, this.projectiles, (_player, projectile) => {
this.hurtPlayer(projectile.getData("damage") || 10, projectile.x, projectile.y);
projectile.destroy();
});
this.cameras.main.setBounds(0, 0, Data.WIDTH, Data.HEIGHT);
this.cameras.main.startFollow(this.player, true, controller.reducedMotion() ? 1 : 0.2, controller.reducedMotion() ? 1 : 0.2);
this.cameras.main.setDeadzone(90, 60);
controller.audio.attach(this, this.regionId);
controller.updateHud();
controller.status(`${this.map.name}. ${Systems.currentObjective(controller.getState())}`);
controller.persistPosition(true);
if (this.regionId === "bossArena" && !controller.getState().defeatedBosses.includes("malrec")) {
controller.openDialogue("malrec", () => {
let state = controller.getState();
state.flags.boss_started = true;
state = Systems.startQuest(state, "defeat_malrec");
controller.setState(state, "The final battle begins.");
this.refreshRequiredEnemies();
});
}
}
drawMap() {
const [ground, path, solid, accent] = this.map.palette;
const groundColor = Phaser.Display.Color.HexStringToColor(ground).color;
const pathColor = Phaser.Display.Color.HexStringToColor(path).color;
this.cameras.main.setBackgroundColor(ground);
this.add.rectangle(Data.WIDTH / 2, Data.HEIGHT / 2, Data.WIDTH, Data.HEIGHT, groundColor).setDepth(0);
const grid = this.add.graphics().setDepth(1);
grid.lineStyle(1, pathColor, 0.18);
for (let x = 0; x <= Data.WIDTH; x += 40) grid.lineBetween(x, 0, x, Data.HEIGHT);
for (let y = 0; y <= Data.HEIGHT; y += 40) grid.lineBetween(0, y, Data.WIDTH, y);
this.add.rectangle(Data.WIDTH / 2, Data.HEIGHT / 2, Data.WIDTH - 110, 116, pathColor, 0.55).setDepth(1);
this.add.rectangle(Data.WIDTH / 2, Data.HEIGHT / 2, 110, Data.HEIGHT - 90, pathColor, 0.38).setDepth(1);
this.map.obstacles.forEach((shape) => this.drawShape(shape, solid, accent));
(this.map.dynamicObstacles || []).forEach((shape) => {
if (!controller.getState().unlockedRoutes.includes(shape.opensWith)) this.drawShape(shape, solid, accent);
else if (shape.id === "bridge") this.add.rectangle(shape.x + shape.width / 2, shape.y + shape.height / 2, shape.width, 74, 0x8d6d46).setDepth(2);
});
this.add.text(24, 20, `${this.map.name} · Chapter ${this.map.chapter}`, {
fontFamily: "monospace", fontSize: "18px", color: "#fff5d6",
backgroundColor: "#090b12cc", padding: { x: 10, y: 6 }
}).setScrollFactor(0).setDepth(900);
}
drawShape(shape, solid, accent) {
const main = Phaser.Display.Color.HexStringToColor(solid).color;
const edge = Phaser.Display.Color.HexStringToColor(accent).color;
if (shape.shape === "circle") {
this.add.circle(shape.x, shape.y, shape.radius, main).setStrokeStyle(4, edge, 0.85).setDepth(3);
} else {
this.add.rectangle(shape.x + shape.width / 2, shape.y + shape.height / 2, shape.width, shape.height, main)
.setStrokeStyle(4, edge, 0.75).setDepth(3);
}
}
addSolid(shape) {
let object;
if (shape.shape === "circle") {
object = this.add.circle(shape.x, shape.y, shape.radius, 0xffffff, 0);
this.physics.add.existing(object, true);
object.body.setCircle(shape.radius);
} else {
object = this.add.rectangle(shape.x + shape.width / 2, shape.y + shape.height / 2, shape.width, shape.height, 0xffffff, 0);
this.physics.add.existing(object, true);
}
this.solids.add(object);
}
createPlayer() {
const state = controller.getState();
const saved = state.region === this.regionId ? state.position : { x: this.map.spawns[Object.keys(this.map.spawns)[0]].x, y: this.map.spawns[Object.keys(this.map.spawns)[0]].y };
const safe = Systems.nearestSafeSpawn(state, this.regionId, saved.x, saved.y);
this.facing = state.position.facing || "east";
this.player = this.physics.add.sprite(safe.x, safe.y, "lima-cast", playerFrame(this.facing)).setScale(0.3).setDepth(100);
this.player.body.setSize(70, 42).setOffset(122, 215);
this.player.setCollideWorldBounds(true).setMaxVelocity(176, 176);
this.physics.add.collider(this.player, this.solids);
this.physics.add.collider(this.player, this.npcGroup);
this.previous = { x: this.player.x, y: this.player.y };
}
createNpcs() {
this.npcGroup = this.physics.add.staticGroup();
(this.map.npcs || []).forEach(([id, x, y]) => {
const npc = this.npcGroup.create(x, y, "lima-cast", Data.NPCS[id].frame).setScale(0.29).setDepth(y + 40);
npc.refreshBody();
npc.body.setSize(70, 42).setOffset(122, 215);
this.interactables.push({ type: "npc", id, x, y, sprite: npc });
});
}
createEnemies() {
this.enemies = this.physics.add.group();
this.physics.add.collider(this.enemies, this.solids);
this.physics.add.collider(this.enemies, this.enemies);
this.physics.add.collider(this.enemies, this.npcGroup);
this.physics.add.overlap(this.player, this.enemies, (_player, enemyObject) => {
this.hurtPlayer(enemyObject.getData("spec").damage, enemyObject.x, enemyObject.y);
});
(this.map.enemies || []).forEach((spawn) => {
if (spawn.boss && controller.getState().defeatedBosses.includes(spawn.type)) return;
this.spawnEnemy(spawn);
});
}
spawnEnemy(spawn) {
const spec = Data.ENEMIES[spawn.type];
const enemyObject = this.physics.add.sprite(spawn.x, spawn.y, "lima-cast", spec.frame)
.setScale(spec.boss ? 0.34 : 0.25).setDepth(spawn.y + 30);
enemyObject.body.setSize(spec.boss ? 130 : 95, spec.boss ? 78 : 58).setOffset(spec.boss ? 92 : 110, spec.boss ? 200 : 205);
enemyObject.setData({
id: `${spawn.type}-${++this.enemySerial}`, type: spawn.type, spec, spawn,
health: spec.health, homeX: spawn.x, homeY: spawn.y, nextAction: this.time.now + 900, phase: 1
});
this.enemies.add(enemyObject);
this.applyEnemyRequirement(enemyObject);
}
applyEnemyRequirement(enemyObject) {
const requirement = enemyObject.getData("spawn").requires;
const available = !requirement || controller.getState().flags[requirement]
|| controller.getState().unlockedRoutes.includes(requirement);
enemyObject.setVisible(available);
enemyObject.body.enable = available;
}
refreshRequiredEnemies() {
this.enemies.getChildren().forEach((enemyObject) => this.applyEnemyRequirement(enemyObject));
}
createExits() {
(this.map.exits || []).forEach((item) => {
const zone = this.add.rectangle(item.x + item.width / 2, item.y + item.height / 2, item.width, item.height, 0xf6d17a, 0.13)
.setStrokeStyle(2, 0xf6d17a, 0.65).setDepth(4);
this.interactables.push({ type: "exit", id: item.id, x: item.x + item.width / 2, y: item.y + item.height / 2, data: item, sprite: zone });
});
}
createPuzzle() {
const puzzle = this.map.puzzle;
if (!puzzle || controller.getState().solvedPuzzles.includes(puzzle.id)) return;
puzzle.objects.forEach(([id, x, y], index) => {
const node = this.add.circle(x, y, 22, 0xd7a949, 0.55).setStrokeStyle(3, 0xffe8a1).setDepth(5);
this.add.text(x, y, String(index + 1), { fontFamily: "monospace", fontSize: "16px", color: "#171117" }).setOrigin(0.5).setDepth(6);
this.interactables.push({ type: "puzzle", id, puzzle, x, y, sprite: node });
});
}
createPickups() {
(this.map.pickups || []).forEach(([id, x, y], index) => {
const chestId = `${this.regionId}-${id}-${index}`;
if (controller.getState().openedChests.includes(chestId)) return;
const item = this.add.star(x, y, 5, 7, 15, 0xf4cf67, 0.9).setStrokeStyle(2, 0xfff0ae).setDepth(6);
this.interactables.push({ type: "pickup", id, chestId, x, y, sprite: item });
});
}
createInput() {
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({
up: "W", down: "S", left: "A", right: "D", interact: "E", enter: "ENTER",
item: "Q", pause: "ESC", inventory: "I", quests: "J"
});
}
update(time, delta) {
if (!this.player) return;
if (controller.locked || controller.transitioning) {
this.player.setVelocity(0);
return;
}
const traveled = Math.hypot(this.player.x - this.previous.x, this.player.y - this.previous.y);
this.previous = { x: this.player.x, y: this.player.y };
let dx = 0;
let dy = 0;
if (this.cursors.left.isDown || this.keys.left.isDown || controller.move.left) dx -= 1;
if (this.cursors.right.isDown || this.keys.right.isDown || controller.move.right) dx += 1;
if (this.cursors.up.isDown || this.keys.up.isDown || controller.move.up) dy -= 1;
if (this.cursors.down.isDown || this.keys.down.isDown || controller.move.down) dy += 1;
const velocity = Systems.approachVelocity(
this.player.body.velocity.x, this.player.body.velocity.y, dx, dy, delta,
Boolean(controller.getState().equipment.boots)
);
this.player.setVelocity(velocity.x, velocity.y);
if (dx || dy) {
this.facing = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south");
this.player.setFrame(playerFrame(this.facing));
const bob = controller.reducedMotion() ? 1 : 1 + Math.sin(time / 90) * 0.018;
this.player.setScale(0.3, 0.3 * bob);
this.stepDistance += traveled;
if (this.stepDistance >= 48) {
controller.audio.play("step", 0.45);
this.stepDistance = 0;
}
} else {
this.player.setScale(0.3);
this.stepDistance = 0;
}
this.player.setDepth(this.player.y + 80);
this.updateNearest();
this.updateEnemies(time);
this.handleKeys(time);
if ((dx || dy) && time - controller.lastSaveAt > 900) {
controller.lastSaveAt = time;
controller.persistPosition(false);
}
}
updateNearest() {
let best = Infinity;
let nearest = null;
this.interactables.forEach((item) => {
const distance = Math.hypot(item.x - this.player.x, item.y - this.player.y);
const radius = item.type === "exit" ? 70 : 54;
if (distance <= radius && distance < best) {
best = distance;
nearest = item;
}
});
this.nearest = nearest;
controller.prompt(nearest ? promptFor(nearest) : "");
}
handleKeys(time) {
if (Phaser.Input.Keyboard.JustDown(this.keys.interact) || Phaser.Input.Keyboard.JustDown(this.keys.enter)) this.interact();
if (Phaser.Input.Keyboard.JustDown(this.cursors.space)) this.attack(time);
if (Phaser.Input.Keyboard.JustDown(this.keys.item)) controller.useTonic();
if (Phaser.Input.Keyboard.JustDown(this.keys.pause)) controller.openPanel("pause");
if (Phaser.Input.Keyboard.JustDown(this.keys.inventory)) controller.openPanel("inventory");
if (Phaser.Input.Keyboard.JustDown(this.keys.quests)) controller.openPanel("quests");
}
interact() {
const item = this.nearest;
if (!item) return controller.status("There is nothing close enough to interact with.");
if (item.type === "exit") {
if (item.data.requirement && !controller.getState().unlockedRoutes.includes(item.data.requirement)) {
return controller.status(`The route is blocked. ${Systems.currentObjective(controller.getState())}`);
}
controller.travel(item.data.target, item.data.spawn);
} else if (item.type === "npc") this.interactNpc(item);
else if (item.type === "puzzle") this.activatePuzzle(item);
else if (item.type === "pickup") this.collect(item);
}
interactNpc(item) {
const dx = item.x - this.player.x;
const dy = item.y - this.player.y;
this.facing = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south");
this.player.setFrame(playerFrame(this.facing));
controller.openDialogue(item.id, () => this.resolveNpc(item.id));
}
resolveNpc(id) {
let state = controller.getState();
if (id === "elder" && !state.flags.aftermath_complete) {
state = Systems.completeQuest(Systems.startQuest(state, "aftermath"), "aftermath");
state = Systems.startQuest(state, "village_defence");
controller.setState(state, "Wayfarer Sword acquired. The second raid has begun.");
this.refreshRequiredEnemies();
} else if (id === "nia") {
state = Systems.startQuest(state, "healer_herbs");
if (Systems.quantity(state, "silver_leaf") >= 2) {
state = Systems.completeQuest(state, "healer_herbs");
controller.setState(state, "Nia brews two healing tonics.");
} else controller.setState(state, "Optional quest started: collect two Silver Leaves.");
} else if (id === "tovin") {
state = Systems.startQuest(state, "find_guide");
controller.setState(state, "Wake the three standing stones from youngest tree to oldest.");
} else if (id === "bram" && this.regionId === "mountain") {
state = Systems.startQuest(state, "repair_bridge");
controller.setState(state, "Restart the west and east bridge winches.");
} else if (id === "elowen" && this.regionId === "camp") {
state = Systems.startQuest(state, "free_scout");
state = Systems.progressQuest(state, "free_scout", 1);
controller.setState(state, "Elowen is free. Defeat Captain Veyr for his emblem.");
} else if (id === "elowen" && this.regionId === "fortressInterior") {
state = Systems.startQuest(state, "free_prisoners");
state = Systems.progressQuest(state, "free_prisoners", 1);
state = Systems.startQuest(state, "break_wards");
controller.setState(state, "Elowen's captured ally is free. Disable the fortress wards.");
} else if (id === "prisoner") {
state = Systems.startQuest(state, "free_prisoners");
state = Systems.progressQuest(state, "free_prisoners", 1);
state = Systems.startQuest(state, "break_wards");
controller.setState(state, "A prisoner is free. Disable the fortress wards.");
} else if (id === "lima" && state.defeatedBosses.includes("malrec")) {
state.rescued = true;
state.story = "complete";
controller.setState(State.normalize(state), "Princess Lima is safe.");
controller.openEnding();
}
}
activatePuzzle(item) {
const puzzle = item.puzzle;
const state = controller.getState();
if (state.solvedPuzzles.includes(puzzle.id)) return;
if (puzzle.type === "set") {
if (!this.puzzleInput.includes(item.id)) this.puzzleInput.push(item.id);
item.sprite.setFillStyle(0xf7e7a1, 0.95);
if (this.puzzleInput.length === puzzle.sequence.length) this.finishPuzzle(puzzle.id);
else controller.status(`${this.puzzleInput.length} / ${puzzle.sequence.length} mechanisms active.`);
return;
}
this.puzzleInput.push(item.id);
const valid = this.puzzleInput.every((value, index) => value === puzzle.sequence[index]);
if (!valid) {
this.puzzleInput = [];
controller.status("The sequence resets. Look for the environmental clue and try again.");
return;
}
item.sprite.setFillStyle(0xf7e7a1, 0.95);
if (this.puzzleInput.length === puzzle.sequence.length) this.finishPuzzle(puzzle.id);
else controller.status(`${item.id} answers. ${this.puzzleInput.length} / ${puzzle.sequence.length}.`);
}
finishPuzzle(id) {
let state = Systems.solvePuzzle(controller.getState(), id);
if (id === "sun_pedestals") {
state.solvedPuzzles.push(id);
state.flags.sun_veil_broken = true;
state = State.normalize(state);
}
controller.setState(state, "Puzzle complete. A sealed route opens.");
controller.audio.play("puzzle");
if (id === "bridge_winches") controller.reloadRegion();
}
collect(item) {
let state = controller.getState();
if (state.openedChests.includes(item.chestId)) return;
state.openedChests.push(item.chestId);
state = Systems.addItem(state, item.id, 1);
controller.setState(state, `${Data.ITEMS[item.id].name} collected.`);
controller.audio.play("pickup");
item.sprite.destroy();
this.interactables = this.interactables.filter((entry) => entry !== item);
}
attack(time) {
if (time - this.lastAttack < 320 || controller.locked) return;
this.lastAttack = time;
controller.audio.play("attack");
const direction = faceVector(this.facing);
const arc = this.add.arc(
this.player.x + direction.x * 46, this.player.y + direction.y * 38,
38, direction.angle - 58, direction.angle + 58, false, 0xffe29a, 0.52
).setDepth(500);
this.physics.add.existing(arc);
this.physics.overlap(arc, this.enemies, (_hit, enemyObject) => this.hitEnemy(enemyObject, direction));
this.time.delayedCall(controller.reducedMotion() ? 75 : 120, () => arc.destroy());
}
hitEnemy(enemyObject, direction) {
if (!enemyObject.active || !enemyObject.visible) return;
if (enemyObject.getData("type") === "malrec" && enemyObject.getData("phase") >= 3 && !controller.getState().flags.sun_veil_broken) {
controller.status("Malrec's final veil holds. Activate both Sun Crystal pedestals.");
return;
}
const health = enemyObject.getData("health") - controller.getState().attack;
enemyObject.setData("health", health);
enemyObject.setVelocity(direction.x * 180, direction.y * 180).setTint(0xffc5c5);
this.time.delayedCall(100, () => { if (enemyObject.active) enemyObject.clearTint(); });
if (health <= 0) this.defeatEnemy(enemyObject);
}
defeatEnemy(enemyObject) {
const type = enemyObject.getData("type");
const spawn = enemyObject.getData("spawn");
const wasBoss = enemyObject.getData("spec").boss;
enemyObject.destroy();
controller.audio.play("defeat");
let state = controller.getState();
if (spawn.quest === "village_defence") state = Systems.progressQuest(state, "village_defence", 1);
if (wasBoss) state = Systems.recordBoss(state, type);
controller.setState(state, wasBoss ? `${Data.ENEMIES[type].name} defeated. The route is open.` : `${Data.ENEMIES[type].name} defeated.`);
if (type === "malrec") {
controller.audio.play("victory");
this.time.delayedCall(500, () => controller.travel("chamber", "door"));
}
}
updateEnemies(time) {
this.enemies.getChildren().forEach((enemyObject) => {
if (!enemyObject.active || !enemyObject.body.enable) return;
const spec = enemyObject.getData("spec");
const distance = Math.hypot(enemyObject.x - this.player.x, enemyObject.y - this.player.y);
const homeDistance = Math.hypot(enemyObject.x - enemyObject.getData("homeX"), enemyObject.y - enemyObject.getData("homeY"));
if (distance > 270 || homeDistance > enemyObject.getData("spawn").leash) {
this.physics.moveTo(enemyObject, enemyObject.getData("homeX"), enemyObject.getData("homeY"), spec.speed);
return;
}
if (spec.behaviour === "wander" && time > enemyObject.getData("nextAction")) {
const angle = Math.random() * Math.PI * 2;
enemyObject.setVelocity(Math.cos(angle) * spec.speed, Math.sin(angle) * spec.speed);
enemyObject.setData("nextAction", time + 650);
} else if (["charge", "slam", "final"].includes(spec.behaviour)) this.updateBoss(enemyObject, time, distance);
else this.physics.moveToObject(enemyObject, this.player, spec.speed);
enemyObject.setDepth(enemyObject.y + 60);
});
}
updateBoss(enemyObject, time, distance) {
const spec = enemyObject.getData("spec");
const ratio = enemyObject.getData("health") / spec.health;
const phase = Systems.bossPhase(enemyObject.getData("health"), spec.health, spec.phases || 1);
enemyObject.setData("phase", phase);
controller.boss(spec.name, enemyObject.getData("health"), spec.health);
if (time < enemyObject.getData("nextAction")) return;
enemyObject.setVelocity(0).setTint(0xf5c96e);
const telegraph = controller.getState().settings.reducedMotion ? 780 : 560;
enemyObject.setData("nextAction", time + 1500);
this.time.delayedCall(telegraph, () => {
if (!enemyObject.active) return;
enemyObject.clearTint();
if (spec.behaviour === "final" && phase >= 2) this.fireRadial(enemyObject, phase === 3 ? 8 : 5);
else if (distance < 340) this.physics.moveToObject(enemyObject, this.player, spec.speed * 2.1);
});
}
fireRadial(enemyObject, count) {
for (let index = 0; index < count; index += 1) {
const angle = Math.PI * 2 * index / count;
const shot = this.add.circle(enemyObject.x, enemyObject.y, 8, 0x6f3d89).setStrokeStyle(2, 0xf1b4ff).setDepth(400);
this.physics.add.existing(shot);
shot.body.setVelocity(Math.cos(angle) * 150, Math.sin(angle) * 150);
shot.setData("damage", 13);
this.projectiles.add(shot);
this.time.delayedCall(3600, () => { if (shot.active) shot.destroy(); });
}
}
hurtPlayer(amount, fromX, fromY) {
const result = Systems.damage(controller.getState(), amount, this.time.now, this.lastHit);
if (!result.hit) return;
this.lastHit = this.time.now;
controller.setState(result.state, `You take ${result.amount} damage.`);
controller.audio.play("damage");
const push = Systems.normalizedVector(this.player.x - fromX, this.player.y - fromY, 210);
this.player.setVelocity(push.x, push.y).setTint(0xff8c8c);
this.time.delayedCall(170, () => { if (this.player.active) this.player.clearTint(); });
if (!controller.reducedMotion() && controller.getState().settings.screenShake) this.cameras.main.shake(100, 0.003);
if (result.defeated) controller.gameOver();
}
}
return [BootScene, WorldScene];
}
function playerFrame(facing) {
return { south: 0, east: 1, north: 2, west: 3 }[facing] || 0;
}
function faceVector(facing) {
return {
north: { x: 0, y: -1, angle: 270 }, south: { x: 0, y: 1, angle: 90 },
west: { x: -1, y: 0, angle: 180 }, east: { x: 1, y: 0, angle: 0 }
}[facing];
}
function promptFor(item) {
if (item.type === "exit") return `${item.data.label} · E / Enter`;
if (item.type === "npc") return `Speak with ${Data.NPCS[item.id].name} · E / Enter`;
if (item.type === "puzzle") return `Activate ${item.id} · E / Enter`;
if (item.type === "pickup") return `Collect ${Data.ITEMS[item.id].name} · E / Enter`;
return "Interact · E / Enter";
}
root.PrincessLimaScenes = Object.freeze({ createSceneClasses, playerFrame });
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -0,0 +1,189 @@
(function (root, factory) {
"use strict";
const data = root.PrincessLimaData || (typeof require === "function" ? require("./princess-lima-data.js") : null);
const api = factory(data);
if (typeof module === "object" && module.exports) module.exports = api;
root.PrincessLimaState = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data) {
"use strict";
const VERSION = 1;
const STORAGE_KEY = "zxh_princess_lima_rpg_v1";
const APPEARANCES = Object.freeze(["azure", "ember", "pine"]);
const FACES = Object.freeze(["north", "south", "east", "west"]);
const START = Object.freeze({ region: "village", spawn: "start", x: 160, y: 570, facing: "east" });
function validName(value) {
const name = String(value || "").trim().replace(/\s+/g, " ");
return name.length >= 1 && name.length <= 20 ? name : null;
}
function finite(value, fallback, min, max) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
}
function unique(values, allowed) {
return Array.isArray(values) ? Array.from(new Set(values.filter((id) => allowed.includes(id)))) : [];
}
function questDefaults() {
return Object.fromEntries(Data.QUEST_IDS.map((id) => [id, { status: "locked", count: 0, rewarded: false }]));
}
function fresh(name, appearance) {
return {
version: VERSION,
player: { name: validName(name) || "", appearance: APPEARANCES.includes(appearance) ? appearance : "azure" },
health: 100,
maxHealth: 100,
attack: 1,
defence: 0,
region: START.region,
position: { spawn: START.spawn, x: START.x, y: START.y, facing: START.facing },
checkpoint: { region: START.region, spawn: START.spawn, x: START.x, y: START.y },
chapter: 1,
story: "arrival",
quests: questDefaults(),
inventory: [{ id: "healing_tonic", quantity: 2 }],
equipment: { weapon: null, armour: null, boots: null, charm: null },
flags: {},
solvedPuzzles: [],
defeatedBosses: [],
openedChests: [],
unlockedRoutes: [],
rescued: false,
playTimeSeconds: 0,
settings: {
soundEnabled: false,
master: 0.8,
music: 0.45,
effects: 0.7,
reducedMotion: null,
screenShake: true,
highContrast: false,
textSpeed: "normal"
}
};
}
function normalizePosition(regionId, candidate, fallback) {
const region = Data.MAPS[regionId] || Data.MAPS.village;
const named = region.spawns[candidate && candidate.spawn] || region.spawns[fallback.spawn] || Object.values(region.spawns)[0];
return {
spawn: String(candidate && candidate.spawn || fallback.spawn).slice(0, 32),
x: finite(candidate && candidate.x, named.x, 24, Data.WIDTH - 24),
y: finite(candidate && candidate.y, named.y, 24, Data.HEIGHT - 24),
facing: candidate && FACES.includes(candidate.facing) ? candidate.facing : fallback.facing || "south"
};
}
function normalizeInventory(value) {
const quantities = new Map();
if (Array.isArray(value)) value.forEach((entry) => {
const item = entry && Data.ITEMS[entry.id];
if (!item) return;
const cap = item.stack || 1;
quantities.set(entry.id, Math.min(cap, (quantities.get(entry.id) || 0) + Math.floor(finite(entry.quantity, 1, 1, cap))));
});
return Array.from(quantities, ([id, quantity]) => ({ id, quantity }));
}
function normalizeQuests(value) {
const quests = questDefaults();
Data.QUEST_IDS.forEach((id) => {
const source = value && value[id];
if (!source) return;
quests[id] = {
status: ["locked", "active", "complete"].includes(source.status) ? source.status : "locked",
count: Math.floor(finite(source.count, 0, 0, 99)),
rewarded: source.rewarded === true
};
});
return quests;
}
function normalize(candidate) {
if (!candidate || candidate.version !== VERSION) return null;
const name = validName(candidate.player && candidate.player.name);
const appearance = candidate.player && APPEARANCES.includes(candidate.player.appearance) ? candidate.player.appearance : null;
if (!name || !appearance) return null;
const region = Data.MAP_IDS.includes(candidate.region) ? candidate.region : START.region;
const position = normalizePosition(region, candidate.position, START);
const checkpointRegion = candidate.checkpoint && Data.MAP_IDS.includes(candidate.checkpoint.region)
? candidate.checkpoint.region : START.region;
const checkpointPosition = normalizePosition(checkpointRegion, candidate.checkpoint, START);
const inventory = normalizeInventory(candidate.inventory);
const has = (id) => inventory.some((entry) => entry.id === id);
const equipment = {
weapon: has("tempered_sword") ? "tempered_sword" : has("village_sword") ? "village_sword" : null,
armour: has("buckler") ? "buckler" : null,
boots: has("trail_boots") ? "trail_boots" : null,
charm: has("forest_charm") ? "forest_charm" : null
};
const attack = equipment.weapon ? Data.ITEMS[equipment.weapon].attack : 1;
const defence = equipment.armour ? Data.ITEMS[equipment.armour].defence : 0;
const maxHealth = finite(candidate.maxHealth, 100, 100, 160);
return {
version: VERSION,
player: { name, appearance },
health: finite(candidate.health, maxHealth, 0, maxHealth),
maxHealth,
attack,
defence,
region,
position,
checkpoint: { region: checkpointRegion, spawn: checkpointPosition.spawn, x: checkpointPosition.x, y: checkpointPosition.y },
chapter: Math.floor(finite(candidate.chapter, 1, 1, 4)),
story: String(candidate.story || "arrival").slice(0, 48),
quests: normalizeQuests(candidate.quests),
inventory,
equipment,
flags: candidate.flags && typeof candidate.flags === "object" && !Array.isArray(candidate.flags)
? Object.fromEntries(Object.entries(candidate.flags).filter(([key, val]) => /^[a-z0-9_-]{1,48}$/.test(key) && typeof val === "boolean").slice(0, 96))
: {},
solvedPuzzles: unique(candidate.solvedPuzzles, ["forest_stones", "ruin_braziers", "bridge_winches", "shadow_wards", "sun_pedestals"]),
defeatedBosses: unique(candidate.defeatedBosses, ["briar_wolf", "stone_guardian", "captain", "malrec"]),
openedChests: Array.isArray(candidate.openedChests) ? Array.from(new Set(candidate.openedChests.filter((id) => typeof id === "string"))).slice(0, 64) : [],
unlockedRoutes: unique(candidate.unlockedRoutes, ["village_defended", "guide_found", "ruins_complete", "briar_defeated", "bridge_repaired", "guardian_defeated", "emblem_found", "wards_broken", "malrec_defeated"]),
rescued: candidate.rescued === true,
playTimeSeconds: Math.floor(finite(candidate.playTimeSeconds, 0, 0, 999999)),
settings: {
soundEnabled: candidate.settings && candidate.settings.soundEnabled === true,
master: finite(candidate.settings && candidate.settings.master, 0.8, 0, 1),
music: finite(candidate.settings && candidate.settings.music, 0.45, 0, 1),
effects: finite(candidate.settings && candidate.settings.effects, 0.7, 0, 1),
reducedMotion: candidate.settings && typeof candidate.settings.reducedMotion === "boolean" ? candidate.settings.reducedMotion : null,
screenShake: !(candidate.settings && candidate.settings.screenShake === false),
highContrast: candidate.settings && candidate.settings.highContrast === true,
textSpeed: candidate.settings && ["slow", "normal", "fast", "instant"].includes(candidate.settings.textSpeed) ? candidate.settings.textSpeed : "normal"
}
};
}
function parse(raw) {
try { return raw ? normalize(JSON.parse(raw)) : null; } catch (_error) { return null; }
}
function withPosition(state, region, spawn, x, y, facing) {
const next = normalize(state);
if (!next || !Data.MAPS[region]) return next;
next.region = region;
next.position = normalizePosition(region, { spawn, x, y, facing }, START);
return normalize(next);
}
function withCheckpoint(state, region, spawn, x, y) {
const next = normalize(state);
if (!next || !Data.MAPS[region]) return next;
const point = normalizePosition(region, { spawn, x, y, facing: "south" }, START);
next.checkpoint = { region, spawn: point.spawn, x: point.x, y: point.y };
return normalize(next);
}
return Object.freeze({
VERSION, STORAGE_KEY, APPEARANCES, FACES, START,
validName, fresh, normalize, parse, normalizeInventory, normalizeQuests,
normalizePosition, withPosition, withCheckpoint
});
}));

View File

@@ -0,0 +1,212 @@
(function (root, factory) {
"use strict";
const data = root.PrincessLimaData || (typeof require === "function" ? require("./princess-lima-data.js") : null);
const stateApi = root.PrincessLimaState || (typeof require === "function" ? require("./princess-lima-state.js") : null);
const api = factory(data, stateApi);
if (typeof module === "object" && module.exports) module.exports = api;
root.PrincessLimaSystems = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data, State) {
"use strict";
function copy(state) {
return State.normalize(JSON.parse(JSON.stringify(state)));
}
function normalizedVector(x, y, speed) {
const length = Math.hypot(Number(x) || 0, Number(y) || 0);
return length ? { x: x / length * speed, y: y / length * speed } : { x: 0, y: 0 };
}
function approachVelocity(currentX, currentY, inputX, inputY, deltaMs, boots) {
const target = normalizedVector(inputX, inputY, 176);
const moving = Boolean(inputX || inputY);
const rate = moving ? (boots ? 1900 : 1500) : 2300;
const change = Math.min(50, Math.max(0, Number(deltaMs) || 0)) / 1000 * rate;
function approach(value, goal) {
return Math.abs(goal - value) <= change ? goal : value + Math.sign(goal - value) * change;
}
const velocity = { x: approach(currentX || 0, target.x), y: approach(currentY || 0, target.y) };
const length = Math.hypot(velocity.x, velocity.y);
return length > 176 ? normalizedVector(velocity.x, velocity.y, 176) : velocity;
}
function pointInShape(x, y, shape, padding) {
const pad = Number(padding) || 0;
if (shape.shape === "circle") return Math.hypot(x - shape.x, y - shape.y) <= shape.radius + pad;
return x >= shape.x - pad && x <= shape.x + shape.width + pad
&& y >= shape.y - pad && y <= shape.y + shape.height + pad;
}
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)));
}
function isSafePosition(state, regionId, x, y) {
if (!Data.MAPS[regionId] || x < 30 || y < 30 || x > Data.WIDTH - 30 || y > Data.HEIGHT - 30) return false;
return !activeObstacles(state, regionId).some((shape) => pointInShape(x, y, shape, 14));
}
function nearestSafeSpawn(state, regionId, x, y) {
const map = Data.MAPS[regionId] || Data.MAPS.village;
if (isSafePosition(state, regionId, x, y)) return { region: regionId, spawn: "saved", x, y };
const candidates = Object.entries(map.spawns).filter(([, point]) => isSafePosition(state, regionId, point.x, point.y));
const best = candidates.sort((a, b) =>
Math.hypot(a[1].x - x, a[1].y - y) - Math.hypot(b[1].x - x, b[1].y - y))[0];
return best ? { region: regionId, spawn: best[0], x: best[1].x, y: best[1].y }
: { region: "village", spawn: "start", x: State.START.x, y: State.START.y };
}
function quantity(state, id) {
const found = state.inventory.find((entry) => entry.id === id);
return found ? found.quantity : 0;
}
function addItem(state, id, amount) {
const next = copy(state);
const item = Data.ITEMS[id];
if (!next || !item) return next;
const cap = item.stack || 1;
const existing = next.inventory.find((entry) => entry.id === id);
if (existing) existing.quantity = Math.min(cap, existing.quantity + Math.max(1, Math.floor(amount || 1)));
else next.inventory.push({ id, quantity: Math.min(cap, Math.max(1, Math.floor(amount || 1))) });
return State.normalize(next);
}
function removeItem(state, id, amount) {
const next = copy(state);
const item = Data.ITEMS[id];
if (!next || !item || item.protected) return { state: next, removed: false };
const existing = next.inventory.find((entry) => entry.id === id);
if (!existing || existing.quantity < amount) return { state: next, removed: false };
existing.quantity -= amount;
next.inventory = next.inventory.filter((entry) => entry.quantity > 0);
return { state: State.normalize(next), removed: true };
}
function useItem(state, id) {
const item = Data.ITEMS[id];
const next = copy(state);
if (!next || !item || item.type !== "consumable" || quantity(next, id) < 1 || next.health >= next.maxHealth) {
return { state: next, used: false, amount: 0 };
}
const amount = Math.min(next.maxHealth - next.health, item.heal);
next.health += amount;
const removed = removeItem(next, id, 1);
return { state: removed.state, used: true, amount };
}
function startQuest(state, id) {
const next = copy(state);
if (!next || !Data.QUESTS[id]) return next;
if (next.quests[id].status === "locked") next.quests[id] = { status: "active", count: 0, rewarded: false };
return State.normalize(next);
}
function progressQuest(state, id, amount) {
let next = startQuest(state, id);
if (!next || next.quests[id].status === "complete") return next;
next.quests[id].count = Math.min(Data.QUESTS[id].target, next.quests[id].count + Math.max(1, amount || 1));
return next.quests[id].count >= Data.QUESTS[id].target ? completeQuest(next, id) : State.normalize(next);
}
function applyQuestConsequences(state, id) {
const routes = {
village_defence: "village_defended", find_guide: "guide_found", ruins_light: "ruins_complete",
wolf_miniboss: "briar_defeated", repair_bridge: "bridge_repaired",
stone_guardian: "guardian_defeated", free_scout: "emblem_found",
break_wards: "wards_broken", defeat_malrec: "malrec_defeated"
};
if (routes[id] && !state.unlockedRoutes.includes(routes[id])) state.unlockedRoutes.push(routes[id]);
if (id === "aftermath") state.flags.aftermath_complete = true;
if (id === "find_guide") state.chapter = Math.max(state.chapter, 2);
if (id === "repair_bridge") state.chapter = Math.max(state.chapter, 3);
if (id === "free_scout") state.chapter = Math.max(state.chapter, 4);
if (id === "defeat_malrec") state.story = "rescue";
return state;
}
function completeQuest(state, id) {
let next = startQuest(state, id);
if (!next || next.quests[id].status === "complete") return next;
next.quests[id].status = "complete";
if (!next.quests[id].rewarded) {
(Data.QUESTS[id].reward || []).forEach(([itemId, amount]) => { next = addItem(next, itemId, amount); });
next.quests[id].rewarded = true;
}
next = applyQuestConsequences(next, id);
return State.normalize(next);
}
function solvePuzzle(state, id) {
const next = copy(state);
if (!next || next.solvedPuzzles.includes(id)) return next;
next.solvedPuzzles.push(id);
if (id === "forest_stones") return completeQuest(next, "find_guide");
if (id === "ruin_braziers") return completeQuest(next, "ruins_light");
if (id === "bridge_winches") return completeQuest(next, "repair_bridge");
if (id === "shadow_wards") return completeQuest(next, "break_wards");
return State.normalize(next);
}
function damage(state, amount, now, lastHitAt) {
const next = copy(state);
if (!next || Number(now) - Number(lastHitAt || 0) < 850) return { state: next, hit: false, defeated: false };
const dealt = Math.max(1, Math.round(amount - next.defence));
next.health = Math.max(0, next.health - dealt);
return { state: State.normalize(next), hit: true, defeated: next.health <= 0, amount: dealt };
}
function bossPhase(health, maximum, phases) {
const count = Math.max(1, Math.floor(Number(phases) || 1));
const ratio = Math.max(0, Number(health) || 0) / Math.max(1, Number(maximum) || 1);
if (count === 1) return 1;
if (count === 2) return ratio > 0.5 ? 1 : 2;
return ratio > 0.66 ? 1 : ratio > 0.33 ? 2 : 3;
}
function respawn(state) {
let next = copy(state);
if (!next) return next;
const safe = nearestSafeSpawn(next, next.checkpoint.region, next.checkpoint.x, next.checkpoint.y);
next.health = Math.max(50, Math.ceil(next.maxHealth * 0.65));
next.region = safe.region;
next.position = { spawn: safe.spawn, x: safe.x, y: safe.y, facing: "south" };
return State.normalize(next);
}
function recordBoss(state, id) {
let next = copy(state);
if (!next || !["briar_wolf", "stone_guardian", "captain", "malrec"].includes(id)) return next;
if (!next.defeatedBosses.includes(id)) next.defeatedBosses.push(id);
if (id === "briar_wolf") next = completeQuest(next, "wolf_miniboss");
if (id === "stone_guardian") next = completeQuest(next, "stone_guardian");
if (id === "captain") next = progressQuest(next, "free_scout", 1);
if (id === "malrec") next = completeQuest(next, "defeat_malrec");
return State.normalize(next);
}
function currentObjective(state) {
if (state.rescued) return "Princess Lima is safe. The road home is open.";
const activeMain = Data.QUEST_IDS.find((id) => Data.QUESTS[id].main && state.quests[id].status === "active");
if (activeMain) return Data.QUESTS[activeMain].description;
if (!state.flags.aftermath_complete) return "Speak with Elder Corin in the village square.";
if (!state.unlockedRoutes.includes("village_defended")) return "Defend the village from the second raid.";
if (!state.unlockedRoutes.includes("guide_found")) return "Find Guide Tovin in the Whispering Woods.";
if (!state.unlockedRoutes.includes("ruins_complete")) return "Explore the Sunken Ruins.";
if (!state.unlockedRoutes.includes("briar_defeated")) return "Defeat the Briar Wolf and open the mountain trail.";
if (!state.unlockedRoutes.includes("bridge_repaired")) return "Repair the bridge across the Mountain Pass.";
if (!state.unlockedRoutes.includes("guardian_defeated")) return "Defeat the Stone Guardian.";
if (!state.unlockedRoutes.includes("emblem_found")) return "Free Scout Elowen at Blackridge Camp.";
if (!state.unlockedRoutes.includes("wards_broken")) return "Break the three wards inside the fortress.";
if (!state.unlockedRoutes.includes("malrec_defeated")) return "Confront Lord Malrec in the throne room.";
return "Find Princess Lima beyond the throne room.";
}
return Object.freeze({
normalizedVector, approachVelocity, pointInShape, activeObstacles, isSafePosition, nearestSafeSpawn,
quantity, addItem, removeItem, useItem, startQuest, progressQuest, completeQuest, solvePuzzle,
damage, bossPhase, respawn, recordBoss, currentObjective
});
}));

View File

@@ -0,0 +1,179 @@
(function (root) {
"use strict";
const Data = root.PrincessLimaData;
const Systems = root.PrincessLimaSystems;
function create(container, actions) {
const overlay = container.querySelector("[data-lima-overlay]");
const panel = container.querySelector("[data-lima-panel]");
const title = container.querySelector("[data-lima-panel-title]");
const body = container.querySelector("[data-lima-panel-body]");
const closeButton = container.querySelector("[data-lima-panel-close]");
let returnFocus = null;
let dialogue = null;
let dialogueIndex = 0;
function lock(value) {
actions.onLock(value);
overlay.hidden = !value;
container.classList.toggle("is-overlay-open", value);
}
function show(kind, heading, html, closable) {
returnFocus = document.activeElement;
panel.dataset.kind = kind;
title.textContent = heading;
body.innerHTML = html;
closeButton.hidden = closable === false;
lock(true);
const target = body.querySelector("button, input, select") || closeButton;
target.focus();
}
function close() {
if (dialogue) return advanceDialogue();
lock(false);
body.innerHTML = "";
if (returnFocus && document.contains(returnFocus)) returnFocus.focus();
else container.querySelector("[data-lima-game]").focus();
}
function openDialogue(id, done) {
const npc = Data.NPCS[id];
if (!npc) return;
dialogue = { id, lines: npc.dialogue.slice(), done };
dialogueIndex = 0;
renderDialogue();
}
function renderDialogue() {
const npc = Data.NPCS[dialogue.id];
const line = dialogue.lines[dialogueIndex];
show("dialogue", npc.name, `
<div class="lima-dialogue">
<div class="lima-dialogue__portrait lima-sprite lima-sprite--${npc.frame}" aria-hidden="true"></div>
<p data-lima-dialogue-text>${escapeHtml(line)}</p>
</div>
<button type="button" class="lima-primary" data-lima-advance>${dialogueIndex + 1 < dialogue.lines.length ? "Continue" : "Finish"}</button>
`, false);
body.querySelector("[data-lima-advance]").addEventListener("click", advanceDialogue, { once: true });
}
function advanceDialogue() {
if (!dialogue) return;
dialogueIndex += 1;
if (dialogueIndex < dialogue.lines.length) return renderDialogue();
const done = dialogue.done;
dialogue = null;
lock(false);
body.innerHTML = "";
container.querySelector("[data-lima-game]").focus();
if (done) done();
}
function openPanel(kind, state) {
if (kind === "inventory") {
const entries = state.inventory.length ? state.inventory.map((entry) => {
const item = Data.ITEMS[entry.id];
const equipped = Object.values(state.equipment).includes(entry.id) ? " · Equipped" : "";
return `<li><strong>${escapeHtml(item.name)}</strong><span>×${entry.quantity}${equipped}</span><p>${escapeHtml(item.description)}</p></li>`;
}).join("") : "<li>No items yet.</li>";
show("inventory", "Inventory", `<ul class="lima-list">${entries}</ul><button type="button" data-lima-use-tonic>Use Healing Tonic</button>`);
const use = body.querySelector("[data-lima-use-tonic]");
use.addEventListener("click", () => { actions.onUseTonic(); openPanel("inventory", actions.getState()); });
} else if (kind === "quests") {
const quests = Data.QUEST_IDS.filter((id) => state.quests[id].status !== "locked").map((id) => {
const quest = Data.QUESTS[id];
const progress = state.quests[id];
return `<li class="${progress.status === "complete" ? "is-complete" : ""}">
<strong>${quest.main ? "Main · " : "Optional · "}${escapeHtml(quest.title)}</strong>
<span>${progress.status === "complete" ? "Complete" : `${progress.count} / ${quest.target}`}</span>
<p>${escapeHtml(quest.description)} · ${escapeHtml(quest.region)}</p>
</li>`;
}).join("");
show("quests", "Quest Log", `<p class="lima-panel-lead">${escapeHtml(Systems.currentObjective(state))}</p><ul class="lima-list">${quests || "<li>No quests yet.</li>"}</ul>`);
} else if (kind === "settings") {
show("settings", "Settings", `
<label class="lima-setting"><span>Master volume</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.master}" data-setting="master"></label>
<label class="lima-setting"><span>Music</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.music}" data-setting="music"></label>
<label class="lima-setting"><span>Effects</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.effects}" data-setting="effects"></label>
<label class="lima-check"><input type="checkbox" data-setting="reducedMotion" ${state.settings.reducedMotion ? "checked" : ""}> Reduced motion</label>
<label class="lima-check"><input type="checkbox" data-setting="screenShake" ${state.settings.screenShake ? "checked" : ""}> Screen shake</label>
<label class="lima-check"><input type="checkbox" data-setting="highContrast" ${state.settings.highContrast ? "checked" : ""}> High-contrast interface</label>
<label class="lima-setting"><span>Text speed</span><select data-setting="textSpeed">${["slow", "normal", "fast", "instant"].map((value) => `<option ${state.settings.textSpeed === value ? "selected" : ""}>${value}</option>`).join("")}</select></label>
`);
body.querySelectorAll("[data-setting]").forEach((control) => control.addEventListener("change", () => {
const value = control.type === "checkbox" ? control.checked : control.type === "range" ? Number(control.value) : control.value;
actions.onSetting(control.dataset.setting, value);
}));
} else if (kind === "pause") {
show("pause", "Paused", `
<p>${escapeHtml(Systems.currentObjective(state))}</p>
<div class="lima-menu-stack">
<button type="button" data-panel="inventory">Inventory</button>
<button type="button" data-panel="quests">Quest Log</button>
<button type="button" data-panel="settings">Settings & accessibility</button>
<button type="button" data-lima-fullscreen-panel>Toggle fullscreen</button>
<button type="button" data-lima-reset-request>Reset save</button>
<a href="/">Exit to Website</a>
</div>
`);
body.querySelectorAll("[data-panel]").forEach((button) => button.addEventListener("click", () => openPanel(button.dataset.panel, actions.getState())));
body.querySelector("[data-lima-fullscreen-panel]").addEventListener("click", actions.onFullscreen);
body.querySelector("[data-lima-reset-request]").addEventListener("click", confirmReset);
}
}
function confirmReset() {
show("confirm", "Delete this adventure?", `
<p>This permanently removes the Princess Lima save on this device.</p>
<div class="lima-confirm"><button type="button" class="lima-danger" data-confirm-reset>Delete save</button><button type="button" data-cancel-reset>Keep progress</button></div>
`, false);
body.querySelector("[data-confirm-reset]").addEventListener("click", actions.onReset, { once: true });
body.querySelector("[data-cancel-reset]").addEventListener("click", () => openPanel("pause", actions.getState()), { once: true });
}
function gameOver(state) {
show("defeat", "The road is not finished", `
<p>You awaken at the latest safe checkpoint with your quests and important items intact.</p>
<button type="button" class="lima-primary" data-respawn>Return to checkpoint</button>
`, false);
body.querySelector("[data-respawn]").addEventListener("click", actions.onRespawn, { once: true });
}
function ending(state) {
show("ending", "Princess Lima Rescued", `
<p>At dawn, the roads reopen. The villages ring their bells, the forest paths quiet, and the mountain fires become beacons instead of warnings.</p>
<p><strong>${escapeHtml(state.player.name)}</strong> is offered a place at the royal table—and chooses first to walk the repaired road home with Lima.</p>
<p class="lima-ending-note">The kingdom remains explorable from your final save.</p>
<button type="button" class="lima-primary" data-ending-continue>Continue exploring</button>
<a class="lima-button-link" href="/">Exit to Website</a>
`, false);
body.querySelector("[data-ending-continue]").addEventListener("click", close, { once: true });
}
closeButton.addEventListener("click", close);
overlay.addEventListener("click", (event) => { if (event.target === overlay && !dialogue) close(); });
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !overlay.hidden && !dialogue) {
event.preventDefault();
close();
}
if ((event.key === "Enter" || event.key === " ") && dialogue && !overlay.hidden) {
event.preventDefault();
advanceDialogue();
}
});
return Object.freeze({ show, close, openDialogue, openPanel, gameOver, ending, confirmReset });
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[character]));
}
root.PrincessLimaUI = Object.freeze({ create });
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -444,826 +444,3 @@
.sigil-canvas {
background: var(--surface);
}
.archive-world {
--world-ink: #17251e;
--world-panel: #efe2c2;
--world-paper: #fff9e9;
--world-line: #8b6841;
--world-brass: #d49a3a;
--world-moss: #527657;
--world-berry: #9d5268;
max-width: 1540px;
}
.archive-world__heading {
max-width: 900px;
}
.archive-world__shell {
position: relative;
overflow: hidden;
border: 2px solid var(--world-line);
border-radius: 12px;
background: var(--world-ink);
box-shadow: 0 22px 70px rgba(23, 37, 30, 0.28);
}
.archive-world.is-restored .archive-world__shell {
box-shadow: 0 22px 80px rgba(215, 184, 94, 0.32), 0 0 0 3px rgba(239, 213, 137, 0.22);
}
.archive-world__shell:fullscreen {
width: 100vw;
height: 100vh;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto auto auto auto;
border: 0;
border-radius: 0;
background: #101b18;
}
.archive-world__hud {
display: grid;
grid-template-columns: 1.1fr 1.4fr 0.8fr 1.15fr auto;
gap: 0.65rem;
align-items: stretch;
padding: 0.65rem;
border-bottom: 2px solid #6e5738;
background:
linear-gradient(rgba(255, 255, 255, 0.04), transparent),
#2d3f33;
color: #fff6dc;
}
.archive-world__hud > div:not(.rpg-hud__actions) {
display: grid;
align-content: center;
min-width: 0;
padding: 0.45rem 0.6rem;
border: 1px solid rgba(229, 200, 135, 0.25);
border-radius: 6px;
background: rgba(9, 20, 15, 0.28);
}
.archive-world__hud span,
.rpg-objective span {
color: #d8c9a6;
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.archive-world__hud strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.archive-world__hud small {
color: #b8d4bb;
}
.rpg-hud__health span {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.rpg-meter {
height: 9px;
overflow: hidden;
border: 1px solid #d6b777;
border-radius: 999px;
background: #211c18;
}
.rpg-meter i {
display: block;
height: 100%;
background: linear-gradient(90deg, #8dbb73, #d8db78);
transition: width 180ms ease;
}
.rpg-hud__actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-content: center;
justify-content: flex-end;
}
.archive-world__hud button,
.archive-world__controls button,
.archive-world__dialog button,
.archive-world__panel button {
border: 1px solid #d6b777;
border-radius: 6px;
background: #fff3d2;
color: #2b2116;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.archive-world__hud button {
padding: 0.48rem 0.62rem;
font-size: 0.76rem;
}
.archive-world__hud button:hover,
.archive-world__hud button:focus-visible,
.archive-world__controls button:hover,
.archive-world__controls button:focus-visible,
.archive-world__dialog button:hover,
.archive-world__dialog button:focus-visible,
.archive-world__panel button:hover,
.archive-world__panel button:focus-visible,
.archive-world__panel a:focus-visible,
.archive-world__panel select:focus-visible,
.archive-world__panel input:focus-visible {
border-color: #fff0b5;
background: #fffaf0;
box-shadow: 0 0 0 3px rgba(255, 236, 173, 0.24);
outline: none;
}
.rpg-objective {
display: flex;
gap: 0.75rem;
align-items: baseline;
padding: 0.55rem 0.8rem;
border-bottom: 1px solid rgba(245, 228, 183, 0.16);
background: #24342b;
color: #fff4cf;
}
.rpg-objective strong {
font-size: 0.88rem;
}
.archive-world__badge {
margin: 0;
padding: 0.4rem 0.65rem;
border: 1px solid #f2d483;
border-radius: 999px;
background: #604b1f;
color: #fff1b6;
font-size: 0.78rem;
font-weight: 900;
}
.archive-world__stage {
position: relative;
min-height: 0;
background: #101b18;
}
.archive-world__game {
position: relative;
width: 100%;
aspect-ratio: 3 / 2;
max-height: min(72vh, 760px);
overflow: hidden;
outline: none;
background: #17251e;
image-rendering: pixelated;
}
.archive-world__shell:fullscreen .archive-world__stage,
.archive-world__shell:fullscreen .archive-world__game {
height: 100%;
max-height: none;
aspect-ratio: auto;
}
.archive-world__game:focus-visible {
box-shadow: inset 0 0 0 4px #fff0a9;
}
.archive-world__game canvas {
display: block;
width: 100% !important;
height: 100% !important;
image-rendering: pixelated;
}
.archive-world__loading {
position: absolute;
inset: 45% 0 auto;
margin: 0;
color: #f5e4b7;
text-align: center;
font-weight: 800;
}
.archive-world[data-game-ready="true"] .archive-world__loading {
display: none;
}
.rpg-boss {
position: absolute;
z-index: 25;
top: 0.65rem;
left: 50%;
width: min(520px, 82%);
padding: 0.45rem 0.65rem;
border: 1px solid #f0bfd0;
border-radius: 7px;
background: rgba(21, 12, 20, 0.9);
color: #ffe9f0;
text-align: center;
transform: translateX(-50%);
}
.rpg-meter--boss {
height: 11px;
margin-top: 0.25rem;
border-color: #df8ca8;
}
.rpg-meter--boss i {
background: linear-gradient(90deg, #80324e, #d97691);
}
.archive-world__controls {
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
padding: 0.8rem;
border-top: 2px solid #6e5738;
background: #2d3f33;
touch-action: none;
}
.archive-world__dpad {
display: grid;
grid-template-columns: repeat(3, 46px);
grid-template-rows: repeat(2, 42px);
gap: 0.3rem;
}
.archive-world__action-pad {
display: grid;
grid-template-columns: repeat(2, minmax(84px, 1fr));
gap: 0.4rem;
}
.archive-world__dpad [data-world-move="up"] {
grid-column: 2;
}
.archive-world__dpad [data-world-move="left"] {
grid-column: 1;
}
.archive-world__dpad [data-world-move="down"] {
grid-column: 2;
}
.archive-world__dpad [data-world-move="right"] {
grid-column: 3;
}
.archive-world__controls button {
min-height: 42px;
padding: 0.5rem 0.8rem;
user-select: none;
-webkit-user-select: none;
}
.archive-world__action-pad [data-world-attack],
.archive-world__action-pad [data-world-interact] {
min-height: 54px;
background: #f5d98d;
}
.archive-world__status,
.archive-world__instructions,
.archive-world__prompt {
margin: 0;
padding: 0.65rem 1rem;
color: #f5e4b7;
text-align: center;
}
.archive-world__prompt {
padding-bottom: 0.15rem;
color: #fff0b7;
font-weight: 900;
}
.archive-world__status {
padding-top: 0.2rem;
font-weight: 800;
}
.archive-world__instructions {
padding-top: 0;
color: #cbbd9d;
font-size: 0.85rem;
}
.archive-world__overlay {
position: absolute;
z-index: 60;
inset: 0;
display: grid;
place-items: center;
padding: clamp(0.6rem, 2vw, 1.5rem);
background: rgba(9, 15, 12, 0.78);
backdrop-filter: blur(2px);
}
.archive-world__overlay[hidden] {
display: none;
}
.archive-world__panel {
width: min(720px, 100%);
max-height: 96%;
overflow: auto;
border: 3px double #9c7846;
border-radius: 6px;
background:
linear-gradient(rgba(255, 255, 255, 0.26), transparent 30%),
var(--world-panel);
color: #2a2118;
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.58);
}
.archive-world__panel > header {
position: sticky;
z-index: 2;
top: 0;
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: start;
padding: 0.85rem 1rem;
border-bottom: 2px solid #a6834f;
background: #e8d5aa;
}
.archive-world__panel h2,
.archive-world__panel h3,
.archive-world__dialog h2 {
font-family: Georgia, "Times New Roman", serif;
}
.archive-world__panel h2 {
margin: 0.15rem 0 0;
}
.archive-world__panel > header button {
min-width: 42px;
min-height: 42px;
padding: 0;
font-size: 1.4rem;
}
.archive-world__panel-body {
padding: 1rem;
}
.archive-world__panel blockquote {
margin: 0.9rem 0;
padding: 0.8rem;
border-left: 4px solid var(--world-moss);
background: rgba(255, 255, 255, 0.46);
}
.rpg-panel__landmark-icon {
float: right;
margin: 0 0 0.5rem 0.8rem;
color: #8b6828;
font-size: 3rem;
}
.rpg-panel__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.rpg-panel__facts div {
padding: 0.55rem;
border: 1px solid #c1a87d;
border-radius: 4px;
background: rgba(255, 255, 255, 0.34);
}
.rpg-panel__facts dt {
color: #66563d;
font-size: 0.7rem;
font-weight: 900;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.rpg-panel__facts dd {
margin: 0.2rem 0 0;
}
.rpg-panel__actions,
.rpg-panel__puzzle-buttons,
.rpg-menu-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin: 0.9rem 0;
}
.archive-world__panel button {
min-height: 42px;
padding: 0.6rem 0.8rem;
}
.rpg-panel__complete {
color: #2e6237;
font-weight: 900;
}
.rpg-panel__destinations {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 1rem;
padding-top: 0.8rem;
border-top: 1px solid #b99d70;
}
.rpg-panel__destinations h3 {
grid-column: 1 / -1;
margin: 0;
}
.rpg-panel__destinations a {
padding: 0.65rem;
border: 1px solid #a88654;
border-radius: 5px;
background: var(--world-paper);
color: #302516;
font-weight: 800;
text-decoration: none;
}
.rpg-panel__hint,
.rpg-panel__note {
color: #6f614d;
font-size: 0.82rem;
}
.rpg-panel__sequence {
min-height: 1.5em;
font-weight: 900;
}
.archive-world__panel form,
.archive-world__panel label {
display: grid;
gap: 0.4rem;
}
.archive-world__panel form {
gap: 0.75rem;
}
.archive-world__panel select,
.archive-world__panel input[type="range"] {
width: 100%;
}
.archive-world__panel select {
min-height: 42px;
border: 1px solid #9c7846;
border-radius: 4px;
padding: 0.45rem;
background: #fff9e9;
color: #2a2118;
font: inherit;
}
.rpg-menu-tabs {
position: sticky;
top: 76px;
z-index: 1;
padding: 0.45rem;
background: #efe2c2;
}
.rpg-menu-tabs [aria-current="page"] {
background: #385341;
color: #fff8e7;
}
.rpg-menu-list {
display: grid;
gap: 0.55rem;
padding: 0;
list-style: none;
}
.rpg-menu-list li {
display: grid;
gap: 0.18rem;
padding: 0.7rem;
border: 1px solid #b89b6c;
border-radius: 5px;
background: rgba(255, 255, 255, 0.38);
}
.rpg-menu-list li.is-complete {
border-color: #6d966c;
background: #e1edd5;
}
.rpg-menu-list small,
.rpg-menu-list span {
color: #665a46;
}
.rpg-inventory li {
grid-template-columns: 48px minmax(0, 1fr) auto;
align-items: center;
}
.rpg-item-icon {
display: block;
width: 44px;
height: 44px;
border: 1px solid #9b7948;
border-radius: 6px;
background-color: #e7d4aa;
background-image: url("/assets/images/play/archive-world/v2/item-atlas.png");
background-repeat: no-repeat;
background-size: 176px 176px;
image-rendering: pixelated;
}
.rpg-item-icon--0 { background-position: 0 0; }
.rpg-item-icon--1 { background-position: -44px 0; }
.rpg-item-icon--2 { background-position: -88px 0; }
.rpg-item-icon--3 { background-position: -132px 0; }
.rpg-item-icon--4 { background-position: 0 -44px; }
.rpg-item-icon--5 { background-position: -44px -44px; }
.rpg-item-icon--6 { background-position: -88px -44px; }
.rpg-item-icon--7 { background-position: -132px -44px; }
.rpg-item-icon--8 { background-position: 0 -88px; }
.rpg-item-icon--9 { background-position: -44px -88px; }
.rpg-item-icon--10 { background-position: -88px -88px; }
.rpg-item-icon--11 { background-position: -132px -88px; }
.rpg-item-icon--12 { background-position: 0 -132px; }
.rpg-controls-list div {
display: grid;
grid-template-columns: minmax(90px, 0.35fr) 1fr;
gap: 0.6rem;
padding: 0.45rem 0;
border-bottom: 1px solid #c9b38e;
}
.rpg-controls-list dt {
font-weight: 900;
}
.rpg-controls-list dd {
margin: 0;
}
.rpg-toggle {
grid-template-columns: auto 1fr !important;
align-items: center;
}
.rpg-danger {
border-color: #a34e57 !important;
background: #f3c8c5 !important;
}
.archive-world__dialog {
width: min(92vw, 620px);
max-height: 86vh;
overflow: auto;
border: 2px solid var(--world-line);
border-radius: 10px;
padding: 1.2rem;
background:
linear-gradient(rgba(255, 255, 255, 0.45), transparent 50%),
var(--world-panel);
color: #2b2116;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.46);
}
.archive-world__dialog::backdrop {
background: rgba(15, 24, 19, 0.78);
backdrop-filter: blur(3px);
}
.archive-world__dialog h2 {
margin-top: 0.25rem;
}
.archive-world__dialog form,
.archive-world__dialog label,
.archive-world__dialog fieldset {
display: grid;
gap: 0.65rem;
}
.archive-world__dialog input[type="text"] {
width: 100%;
border: 1px solid var(--world-line);
border-radius: 6px;
padding: 0.7rem;
background: var(--world-paper);
color: #2b2116;
font: inherit;
}
.archive-world__dialog fieldset {
grid-template-columns: repeat(3, minmax(0, 1fr));
border: 1px solid #b79b70;
}
.archive-world__dialog fieldset label {
display: flex;
gap: 0.4rem;
align-items: center;
}
.archive-world__dialog button {
padding: 0.65rem 0.85rem;
}
.palette-swatch {
width: 18px;
height: 18px;
border: 2px solid #392e22;
border-radius: 3px;
}
.palette-swatch--brass { background: #f1c46f; }
.palette-swatch--moss { background: #91c788; }
.palette-swatch--berry { background: #d894ad; }
.archive-world__error {
min-height: 1.4em;
margin: 0;
color: #8b2635;
font-weight: 800;
}
.archive-world__directory {
margin-top: 1.5rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}
.archive-world__directory summary {
padding: 0.9rem 1rem;
font-weight: 900;
cursor: pointer;
}
.archive-world__directory > p {
margin: 0;
padding: 0 1rem 0.8rem;
color: var(--muted);
}
.archive-world__directory-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
padding: 0 1rem 1rem;
}
.archive-world__directory-grid section {
padding: 0.8rem;
border: 1px solid var(--border);
border-radius: 6px;
}
.archive-world__directory-grid h2 {
margin: 0 0 0.45rem;
font-size: 1rem;
}
.archive-world__noscript {
padding: 0.8rem;
border-left: 4px solid var(--world-brass);
background: var(--surface);
}
@media (max-width: 760px) {
.play-hero,
.play-console,
.sigil-tool {
grid-template-columns: 1fr;
}
.play-orbit {
min-height: 260px;
}
.memory-board {
grid-template-columns: repeat(3, minmax(64px, 1fr));
}
.book-spine {
width: calc(50% - 0.4rem);
min-height: 160px;
}
.archive-world__hud {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rpg-hud__actions {
grid-column: 1 / -1;
justify-content: start;
}
.archive-world__game {
min-height: 360px;
max-height: 62vh;
}
.archive-world__dialog fieldset,
.rpg-panel__facts,
.rpg-panel__destinations,
.archive-world__directory-grid {
grid-template-columns: 1fr;
}
.rpg-objective {
display: grid;
gap: 0.2rem;
}
}
@media (max-width: 480px) {
.archive-world__game {
min-height: 300px;
}
.archive-world__controls {
justify-content: space-between;
gap: 0.4rem;
padding: 0.5rem;
}
.archive-world__dpad {
grid-template-columns: repeat(3, 38px);
grid-template-rows: repeat(2, 40px);
}
.archive-world__action-pad {
grid-template-columns: repeat(2, minmax(64px, 1fr));
}
.archive-world__controls button {
min-height: 40px;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
}
.archive-world__panel {
max-height: 100%;
}
.archive-world__panel-body {
padding: 0.75rem;
}
.rpg-menu-tabs {
top: 70px;
}
.rpg-inventory li {
grid-template-columns: 44px minmax(0, 1fr);
}
.rpg-inventory li button {
grid-column: 1 / -1;
}
}
@media (prefers-reduced-motion: reduce) {
.archive-world *,
.archive-world *::before,
.archive-world *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
.rpg-meter i {
transition: none;
}
}

View File

@@ -0,0 +1,626 @@
:root {
color-scheme: dark;
--lima-bg: #080a12;
--lima-panel: #111827;
--lima-panel-strong: #182238;
--lima-border: #d2a85c;
--lima-text: #fff5df;
--lima-muted: #c8c3b8;
--lima-accent: #ffd47b;
--lima-blue: #3d73a8;
--lima-danger: #bd4a56;
--lima-focus: #7ee7ff;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: var(--lima-bg);
color: var(--lima-text);
font-family: Inter, "Noto Sans", system-ui, sans-serif;
}
button,
input,
select {
font: inherit;
}
button,
.lima-button-link,
.lima-exit {
min-height: 44px;
border: 2px solid #b99150;
border-radius: 5px;
background: #202d43;
color: var(--lima-text);
padding: 0.65rem 1rem;
font-weight: 800;
cursor: pointer;
text-decoration: none;
}
button:hover,
.lima-button-link:hover,
.lima-exit:hover {
background: #2b4162;
border-color: var(--lima-accent);
}
button:active {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
:focus-visible {
outline: 4px solid var(--lima-focus);
outline-offset: 3px;
}
[hidden] {
display: none !important;
}
.lima-rpg {
position: relative;
width: 100vw;
height: 100dvh;
overflow: hidden;
background: #080a12;
isolation: isolate;
}
.lima-rpg::before {
content: "";
position: absolute;
inset: 0;
z-index: -2;
background:
linear-gradient(90deg, rgb(5 8 16 / 0.82), rgb(5 8 16 / 0.15) 60%, rgb(5 8 16 / 0.45)),
url("/assets/images/play/princess-lima/title-landscape.png") center / cover no-repeat;
}
.lima-rpg__game {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #080a12;
}
.lima-rpg__game canvas {
display: block;
max-width: 100vw;
max-height: 100dvh;
image-rendering: pixelated;
}
.lima-rpg__loading,
.lima-main-menu,
.lima-setup {
position: absolute;
z-index: 50;
left: clamp(1rem, 6vw, 6rem);
top: 50%;
width: min(31rem, calc(100vw - 2rem));
transform: translateY(-50%);
border: 3px solid var(--lima-border);
border-radius: 8px;
background: rgb(10 15 27 / 0.96);
box-shadow: 0 20px 70px rgb(0 0 0 / 0.7);
padding: clamp(1.2rem, 3vw, 2.4rem);
}
.lima-rpg__loading {
display: grid;
gap: 0.75rem;
}
.lima-rpg__loading strong {
font-size: 1.4rem;
}
.lima-main-menu__eyebrow,
.lima-panel__eyebrow {
margin: 0 0 0.4rem;
color: var(--lima-accent);
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.lima-main-menu h1 {
margin: 0;
color: #fff6d8;
font-family: Georgia, serif;
font-size: clamp(2.4rem, 6vw, 4.7rem);
line-height: 0.94;
text-shadow: 0 4px #2f1822;
}
.lima-main-menu__tagline {
max-width: 36ch;
color: var(--lima-muted);
font-size: 1.04rem;
line-height: 1.55;
}
.lima-menu-stack {
display: grid;
gap: 0.7rem;
}
.lima-menu-stack a {
display: grid;
place-items: center;
}
.lima-primary {
background: #8f5b28;
border-color: #f2c36c;
}
.lima-danger {
background: #612c38;
border-color: #e7888f;
}
.lima-exit {
position: absolute;
z-index: 80;
top: 0.75rem;
right: 0.75rem;
min-height: 36px;
padding: 0.4rem 0.75rem;
background: rgb(9 13 23 / 0.9);
font-size: 0.86rem;
}
.lima-hud {
position: absolute;
z-index: 30;
inset: 0.75rem 10rem auto 0.75rem;
display: grid;
grid-template-columns: auto minmax(12rem, 22rem) minmax(10rem, 1fr) auto;
gap: 0.55rem;
pointer-events: none;
}
.lima-hud > div {
min-height: 58px;
border: 2px solid rgb(214 174 99 / 0.72);
border-radius: 5px;
background: rgb(8 12 22 / 0.93);
padding: 0.45rem 0.65rem;
}
.lima-hud span {
display: block;
color: #c9c4b8;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.lima-hud strong {
display: block;
margin-top: 0.18rem;
color: #fff7df;
line-height: 1.2;
}
.lima-hud__healthbar,
.lima-boss__bar {
height: 8px;
margin-top: 0.38rem;
overflow: hidden;
border-radius: 999px;
background: #351d29;
}
.lima-hud__healthbar i,
.lima-boss__bar i {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(90deg, #5bba6f, #d8df73);
}
.lima-hud__actions {
display: flex;
gap: 0.4rem;
pointer-events: auto;
}
.lima-hud__actions button {
min-width: 48px;
min-height: 48px;
padding: 0.4rem;
font-size: 0.78rem;
}
.lima-status-stack {
position: absolute;
z-index: 25;
left: 50%;
bottom: 1rem;
width: min(44rem, calc(100vw - 2rem));
transform: translateX(-50%);
text-align: center;
pointer-events: none;
}
.lima-prompt,
.lima-status {
display: table;
margin: 0.35rem auto;
border: 2px solid rgb(207 172 103 / 0.72);
border-radius: 4px;
background: rgb(5 8 15 / 0.94);
color: #fff5d9;
padding: 0.48rem 0.8rem;
font-weight: 800;
}
.lima-status {
color: #d7d4cc;
font-size: 0.88rem;
}
.lima-boss {
position: absolute;
z-index: 35;
top: 5.2rem;
left: 50%;
width: min(34rem, calc(100vw - 2rem));
transform: translateX(-50%);
border: 2px solid #bd6d8b;
background: rgb(18 8 25 / 0.95);
padding: 0.55rem 0.8rem;
text-align: center;
}
.lima-boss__bar i {
background: linear-gradient(90deg, #9d3961, #e28f98);
}
.lima-touch {
position: absolute;
z-index: 40;
inset: auto 0.8rem 0.8rem;
display: flex;
justify-content: space-between;
pointer-events: none;
}
.lima-dpad,
.lima-actions {
display: grid;
gap: 0.35rem;
pointer-events: auto;
}
.lima-dpad {
grid-template-columns: repeat(3, 54px);
grid-template-rows: repeat(2, 54px);
}
.lima-dpad button {
min-height: 54px;
padding: 0;
touch-action: none;
}
.lima-dpad [data-lima-move="up"] { grid-column: 2; }
.lima-dpad [data-lima-move="left"] { grid-column: 1; grid-row: 2; }
.lima-dpad [data-lima-move="down"] { grid-column: 2; grid-row: 2; }
.lima-dpad [data-lima-move="right"] { grid-column: 3; grid-row: 2; }
.lima-actions {
grid-template-columns: repeat(2, minmax(70px, 96px));
}
.lima-overlay,
.lima-setup {
position: absolute;
z-index: 100;
}
.lima-overlay {
inset: 0;
display: grid;
place-items: center;
background: rgb(3 5 10 / 0.78);
padding: 1rem;
}
.lima-panel {
width: min(46rem, 100%);
max-height: min(82dvh, 48rem);
overflow: auto;
border: 3px solid var(--lima-border);
border-radius: 7px;
background: var(--lima-panel);
box-shadow: 0 22px 80px rgb(0 0 0 / 0.8);
}
.lima-panel > header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-bottom: 2px solid #8a6a3d;
background: var(--lima-panel-strong);
padding: 0.85rem 1rem;
}
.lima-panel h2 {
margin: 0;
color: #fff2cf;
font-family: Georgia, serif;
font-size: 1.7rem;
}
.lima-panel__body {
padding: 1rem;
line-height: 1.55;
}
.lima-panel-lead {
border-left: 4px solid var(--lima-accent);
background: #0b1220;
padding: 0.75rem;
}
.lima-list {
display: grid;
gap: 0.65rem;
margin: 0;
padding: 0;
list-style: none;
}
.lima-list li {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
border: 1px solid #536076;
border-radius: 4px;
background: #0b1220;
padding: 0.75rem;
}
.lima-list li.is-complete strong::before {
content: "✓ ";
}
.lima-list p {
grid-column: 1 / -1;
margin: 0;
color: var(--lima-muted);
}
.lima-dialogue {
display: grid;
grid-template-columns: 100px 1fr;
align-items: center;
gap: 1rem;
min-height: 9rem;
}
.lima-dialogue p {
margin: 0;
font-size: 1.1rem;
}
.lima-sprite {
width: 96px;
height: 96px;
background-image: url("/assets/images/play/princess-lima/cast-atlas.png");
background-size: 384px 384px;
background-position: 0 0;
image-rendering: pixelated;
}
.lima-sprite--4 { background-position: 0 -96px; }
.lima-sprite--5 { background-position: -96px -96px; }
.lima-sprite--6 { background-position: -192px -96px; }
.lima-sprite--7 { background-position: -288px -96px; }
.lima-sprite--8 { background-position: 0 -192px; }
.lima-sprite--11 { background-position: -288px -192px; }
.lima-setting,
.lima-check {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 52px;
border-bottom: 1px solid #39445a;
}
.lima-setting input {
width: min(18rem, 52vw);
}
.lima-check input {
width: 24px;
height: 24px;
}
.lima-confirm {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.lima-setup {
display: block;
}
.lima-setup form {
display: grid;
gap: 0.85rem;
}
.lima-setup h2 {
margin: 0;
font-family: Georgia, serif;
font-size: 2rem;
}
.lima-setup label,
.lima-setup fieldset {
display: grid;
gap: 0.45rem;
}
.lima-setup input[type="text"] {
min-height: 48px;
border: 2px solid #7b6749;
border-radius: 4px;
background: #070b13;
color: #fff;
padding: 0.6rem;
}
.lima-setup fieldset {
grid-template-columns: repeat(3, 1fr);
border: 1px solid #786649;
padding: 0.75rem;
}
.lima-setup fieldset legend {
grid-column: 1 / -1;
}
.lima-setup fieldset label {
display: flex;
align-items: center;
}
.lima-setup__error {
min-height: 1.4em;
color: #ffb1b9;
}
.lima-rpg.is-high-contrast {
--lima-panel: #000;
--lima-panel-strong: #000;
--lima-text: #fff;
--lima-muted: #fff;
--lima-border: #ffdf00;
--lima-focus: #00ffff;
}
.lima-rpg:fullscreen {
width: 100vw;
height: 100vh;
}
.lima-rpg:fullscreen .lima-exit {
top: 0.5rem;
}
@media (pointer: fine) and (min-width: 900px) {
.lima-touch {
opacity: 0.18;
}
.lima-touch:hover,
.lima-touch:focus-within {
opacity: 1;
}
}
@media (max-width: 820px) {
.lima-hud {
right: 0.65rem;
grid-template-columns: 1fr 1fr;
}
.lima-hud__objective {
grid-column: 1 / -1;
}
.lima-hud__actions {
position: fixed;
right: 0.6rem;
top: 0.6rem;
border: 0 !important;
background: transparent !important;
}
.lima-hud__actions button:not([data-lima-pause]) {
display: none;
}
.lima-main-menu,
.lima-rpg__loading,
.lima-setup {
left: 1rem;
top: 50%;
}
.lima-status-stack {
bottom: 7.4rem;
}
}
@media (max-height: 560px) and (orientation: landscape) {
.lima-hud {
inset: 0.35rem 5rem auto 0.35rem;
grid-template-columns: auto auto 1fr auto;
}
.lima-hud > div {
min-height: 48px;
padding: 0.3rem 0.5rem;
}
.lima-touch {
inset: auto 0.35rem 0.35rem;
}
.lima-status-stack {
bottom: 0.35rem;
}
.lima-status {
display: none;
}
.lima-main-menu {
padding: 1rem;
}
.lima-main-menu h1 {
font-size: 2.6rem;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition: none !important;
animation: none !important;
}
}

View File

@@ -784,3 +784,10 @@ at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/build-logs/gite
2026-07-30T10:03:41.6923349+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-30T10:03:41.7745044+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-30T10:03:42.0626713+01:00 [INFO] Sent authoring server test notification.
2026-07-30T10:23:02.5931302+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-30T10:23:02.6008378+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-30T10:23:02.9635424+01:00 [ERROR] Failed to analyze job 780: Cannot bind argument to parameter 'LogText' because it is an empty string.
2026-07-30T10:23:03.5156704+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-30T10:23:03.5358268+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-30T10:23:03.6148289+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-30T10:23:03.9852050+01:00 [INFO] Sent authoring server test notification.

View File

@@ -80,7 +80,9 @@ Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
(defun z/org-html-add-body-classes (output backend info)
"Add layout-related classes to <body> based on file metadata."
(if (org-export-derived-backend-p backend 'html)
(if (and (org-export-derived-backend-p backend 'html)
(not (and (fboundp 'z/rpg-standalone-file-p)
(z/rpg-standalone-file-p info))))
(let* ((input-file (plist-get info :input-file))
(classes
(delq nil
@@ -101,6 +103,47 @@ Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
(add-to-list 'org-export-filter-body-functions
#'z/org-html-insert-comments-into-body)
(defconst z/rpg-standalone-start "<!-- RPG-STANDALONE-START -->")
(defconst z/rpg-standalone-end "<!-- RPG-STANDALONE-END -->")
(defun z/rpg-standalone-file-p (info)
"Return non-nil when INFO describes the dedicated RPG page."
(let ((input-file (plist-get info :input-file)))
(and input-file
(string= (file-truename input-file)
(file-truename (site-path "play/rpg.org"))))))
(defun z/render-rpg-standalone (output backend info)
"Replace normal site chrome with a dedicated game document for the RPG."
(if (and (org-export-derived-backend-p backend 'html)
(z/rpg-standalone-file-p info))
(let* ((start (string-match (regexp-quote z/rpg-standalone-start) output))
(end (and start (string-match (regexp-quote z/rpg-standalone-end) output start))))
(if (and start end)
(let* ((body-start (+ start (length z/rpg-standalone-start)))
(body (substring output body-start end))
(body (replace-regexp-in-string
"<link rel=\"stylesheet\" href=\"/assets/styles/pages/princess-lima-rpg.css[^\"]*\" />"
""
body)))
(concat
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
"<meta charset=\"utf-8\" />\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\" />\n"
"<meta name=\"theme-color\" content=\"#080a12\" />\n"
"<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.0.1\" />\n"
"</head>\n<body class=\"princess-lima-page\">\n"
body
"\n</body>\n</html>\n"))
output))
output))
(add-to-list 'org-export-filter-final-output-functions
#'z/render-rpg-standalone)
(org-export-define-derived-backend 'z-html 'html
:filters-alist '((:filter-final-output . z/insert-filetags-after-title)))

View File

@@ -261,4 +261,31 @@
(result (z/org-html-add-body-classes output 'latex info)))
(should (string= output result))))))
;; ── standalone RPG export ────────────────────────────────────────────────────
(ert-deftest test/rpg-standalone-export-removes-normal-site-chrome ()
"The RPG final-output filter should return a dedicated viewport document."
(let* ((info (list :input-file (site-path "play/rpg.org")))
(output (concat
"<html><head><script src=\"search.js\"></script></head><body>"
"<div class=\"banner-header\">Normal site</div>"
z/rpg-standalone-start
"<main data-princess-lima-rpg>Game</main>"
"<script src=\"/assets/scripts/pages/princess-lima-game.js\"></script>"
z/rpg-standalone-end
"<footer>Normal footer</footer></body></html>"))
(result (z/render-rpg-standalone output 'html info)))
(should (string-match-p "class=\"princess-lima-page\"" result))
(should (string-match-p "data-princess-lima-rpg" result))
(should (string-match-p "princess-lima-game.js" result))
(should-not (string-match-p "banner-header" result))
(should-not (string-match-p "Normal footer" result))
(should-not (string-match-p "search.js" result))))
(ert-deftest test/rpg-standalone-export-ignores-other-pages ()
"Normal pages must retain their existing exported document."
(let* ((info (list :input-file (site-path "index.org")))
(output "<html><body><div class=\"banner-header\">Site</div></body></html>"))
(should (string= output (z/render-rpg-standalone output 'html info)))))
;;; build-site-tests.el ends here

View File

@@ -114,7 +114,7 @@
<section><h2>Study</h2><a href="/posts/career/career-list.html">Career</a> · <a href="/home/status.html">Competency status</a></section>
<section><h2>Kitchen</h2><a href="/blogs/blogs-list.html">Blogs</a> · <a href="/tags/review.html">Weekly reviews</a></section>
<section><h2>Workshop</h2><a href="/home/services.html">Services</a> · <a href="/home/wird-tracker.html">Wird tracker</a> · <a href="/home/backlog.html">Backlog</a></section>
<section><h2>Playroom</h2><a href="/play/play.html">Play</a> · <a href="/play/rpg.html">The Archive World</a></section>
<section><h2>Playroom</h2><a href="/play/play.html">Play</a> · <a href="/play/rpg.html">Rescue Princess Lima</a></section>
<section><h2>Attic</h2><a href="/lima/index.html">Lima archive</a> · <a href="/blogs/blogs-list.html">Older writing</a></section>
<section><h2>Garden</h2><a href="/home/notes.html">Notes wall</a> · <a href="/home/categories.html">Categories</a> · <a href="/sitemap.html">Sitemap</a></section>
</div>

View File

@@ -49,7 +49,7 @@
<a href="/play/terminal.html" data-kind="Story" data-desc="Explore a tiny command-line adventure hidden in the archive."><span>06</span><strong>Archive Terminal</strong></a>
<a href="/play/study.html" data-kind="Timer" data-desc="Run a focus timer that grows a little desk scene as time passes."><span>07</span><strong>Study Lamp</strong></a>
<a href="/play/sigil.html" data-kind="Maker" data-desc="Generate a small personal sigil from initials, colors, and motto."><span>08</span><strong>Sigil Press</strong></a>
<a href="/play/rpg.html" data-kind="RPG world" data-desc="Walk through the website as Archive Town, discover eight landmarks, and travel through its living sections."><span>09</span><strong>The Archive World</strong></a>
<a href="/play/rpg.html" data-kind="Fantasy RPG" data-desc="Cross a broken kingdom, defeat the fortress guardians, and rescue Princess Lima."><span>09</span><strong>Rescue Princess Lima</strong></a>
<a href="/play/the-rain-index.html" data-kind="Artifact" data-desc="Handle impossible paper rooms, type forgotten words, and let a rainy archive file you back."><span>10</span><strong>The Rain Index</strong></a>
<a href="/play/house.html" data-kind="Living archive" data-desc="Wander through a lived-in house where every room opens another part of the archive."><span>11</span><strong>The House of Pages</strong></a>
</nav>

241
play/rpg.org Executable file → Normal file
View File

@@ -1,153 +1,112 @@
#+TITLE: The Archive World
#+OPTIONS: num:nil title:nil toc:nil
#+TITLE: Rescue Princess Lima
#+OPTIONS: num:nil title:nil toc:nil html-preamble:nil html-postamble:nil
#+NO_SIDENOTES: t
#+DATE: <2026-07-29 Wed>
#+DATE: <2026-07-30 Thu>
#+BEGIN_EXPORT html
<main class="play-root play-page archive-world" data-play-page="archive-world">
<a class="play-back" href="/play/play.html">Back to Play</a>
<!-- RPG-STANDALONE-START -->
<main class="lima-rpg" data-princess-lima-rpg aria-label="Rescue Princess Lima role-playing game">
<a class="lima-exit" href="/">Exit to Website</a>
<header class="play-page-head archive-world__heading">
<p class="play-kicker">09 / Role Playing World</p>
<h1>The Archive World</h1>
<p>A complete story-forward RPG about names, memories, and the connections that give an archive meaning.</p>
</header>
<div id="princess-lima-game" class="lima-rpg__game" data-lima-game tabindex="0" role="application"
aria-label="Rescue Princess Lima. Move with WASD or arrow keys. Attack with Space. Interact with E or Enter. Use a healing tonic with Q. Pause with Escape.">
</div>
<section class="archive-world__shell" data-world-shell aria-labelledby="archive-world-title">
<div class="archive-world__hud">
<div class="rpg-hud__identity">
<span>Traveler</span>
<strong data-world-player>New arrival</strong>
<small data-world-level>Lv 1</small>
</div>
<div class="rpg-hud__health">
<span>Health <strong data-world-health-text>100 / 100</strong></span>
<div class="rpg-meter" aria-hidden="true"><i data-world-health-bar style="width:100%"></i></div>
</div>
<div class="rpg-hud__sigils">
<span>Archive Sigils</span>
<strong data-world-sigil-count>0 / 8</strong>
</div>
<div class="rpg-hud__item">
<span>Selected item</span>
<strong data-world-selected-item>Ink Vial ×2</strong>
</div>
<div class="rpg-hud__actions">
<button type="button" data-world-quests>Quests</button>
<button type="button" data-world-inventory>Inventory</button>
<button type="button" data-world-journal-open>Journal</button>
<button type="button" data-world-sound>Sound: muted</button>
<button type="button" data-world-fullscreen aria-pressed="false">Fullscreen</button>
<button type="button" data-world-reset>Menu</button>
</div>
</div>
<div class="rpg-objective" aria-live="polite">
<span>Main objective</span>
<strong data-world-objective>Speak with Gatekeeper Orin at the Town Gate.</strong>
</div>
<p class="archive-world__badge" data-world-badge hidden>Archive Cartographer · all eight landmarks discovered</p>
<h2 id="archive-world-title" class="visually-hidden">Archive World role-playing game</h2>
<div class="archive-world__stage">
<div
id="archive-world-game"
class="archive-world__game"
tabindex="0"
role="application"
aria-label="The Archive World. Move with arrow keys or WASD, attack with Space, interact with E or Enter, use an item with Q, and open the menu with Escape."
>
<p class="archive-world__loading">Opening the Archive Town gate…</p>
</div>
<div class="rpg-boss" data-world-boss hidden aria-live="polite">
<span data-world-boss-name>The Redactor</span>
<div class="rpg-meter rpg-meter--boss"><i data-world-boss-bar style="width:100%"></i></div>
</div>
<div class="archive-world__overlay" data-world-overlay hidden>
<section class="archive-world__panel" data-world-panel role="dialog" aria-modal="true" aria-labelledby="archive-world-panel-title">
<header>
<div>
<p class="play-kicker" data-world-panel-eyebrow>Archive</p>
<h2 id="archive-world-panel-title" data-world-panel-title>Travelers folio</h2>
</div>
<button type="button" data-world-panel-close aria-label="Close panel">×</button>
</header>
<div class="archive-world__panel-body" data-world-panel-body></div>
</section>
</div>
</div>
<div class="archive-world__controls" aria-label="Touch game controls">
<div class="archive-world__dpad">
<button type="button" data-world-move="up" aria-label="Move up"></button>
<button type="button" data-world-move="left" aria-label="Move left"></button>
<button type="button" data-world-move="down" aria-label="Move down"></button>
<button type="button" data-world-move="right" aria-label="Move right"></button>
</div>
<div class="archive-world__action-pad">
<button type="button" data-world-attack>Attack</button>
<button type="button" data-world-interact>Explore</button>
<button type="button" data-world-item>Item</button>
<button type="button" data-world-menu>Menu</button>
</div>
</div>
<p class="archive-world__prompt" data-world-prompt>Follow the paths, signs, and warm window light.</p>
<p class="archive-world__status" data-world-status aria-live="polite">
Choose your traveler to enter Archive Town.
</p>
<p class="archive-world__instructions">
Move: WASD/arrows · Attack: Space · Explore: E/Enter · Use item: Q · Cycle item: Tab · Pause: Escape.
Audio begins muted and remains optional.
</p>
<section class="lima-rpg__loading" data-lima-loading aria-live="polite">
<strong>Preparing the road to Lima…</strong>
<span>Loading local maps, characters, and sounds.</span>
</section>
<dialog class="archive-world__dialog archive-world__setup" data-world-setup>
<form data-world-setup-form>
<p class="play-kicker">New traveler</p>
<h2>Who has arrived?</h2>
<p>The Town Gate is losing names. Yours has held long enough for the Living Bookmark to find you.</p>
<label>
Traveler name
<input name="name" type="text" minlength="1" maxlength="20" autocomplete="nickname" required />
</label>
<fieldset>
<legend>Traveller coat</legend>
<label><input type="radio" name="palette" value="brass" checked /> <span class="palette-swatch palette-swatch--brass"></span> Brass</label>
<label><input type="radio" name="palette" value="moss" /> <span class="palette-swatch palette-swatch--moss"></span> Moss</label>
<label><input type="radio" name="palette" value="berry" /> <span class="palette-swatch palette-swatch--berry"></span> Berry</label>
</fieldset>
<p class="archive-world__error" data-world-setup-error aria-live="polite"></p>
<button type="submit">Enter the world</button>
</form>
</dialog>
<details class="archive-world__directory">
<summary>Plain directory of Archive Town</summary>
<p>This directory remains usable if JavaScript, Canvas, Phaser, or optional game assets are unavailable.</p>
<div class="archive-world__directory-grid">
<section><h2>Town Gate</h2><a href="/">Home</a> · <a href="/recently-updated.html">Recently updated</a> · <a href="/home/contact.html">Contact</a></section>
<section><h2>Grand Library</h2><a href="/posts/posts-list.html">Posts</a> · <a href="/home/categories.html">Categories</a> · <a href="/posts/posts-intro.html">Introduction</a></section>
<section><h2>Guild Study</h2><a href="/posts/career/career-list.html">Career</a> · <a href="/home/status.html">Competency status</a> · <a href="/posts/career/probation-objectives.html">Objectives</a></section>
<section><h2>Kitchen Inn</h2><a href="/blogs/blogs-list.html">Blogs</a> · <a href="/tags/review.html">Weekly reviews</a> · <a href="/blogs/blogs-intro.html">Introduction</a></section>
<section><h2>Workshop</h2><a href="/home/services.html">Services</a> · <a href="/home/wird-tracker.html">Wird tracker</a> · <a href="/home/countdown.html">Countdowns</a> · <a href="/home/backlog.html">Backlog</a></section>
<section><h2>Playroom</h2><a href="/play/play.html">Play</a> · <a href="/play/the-rain-index.html">Rain Index</a> · <a href="/play/house.html">House of Pages</a></section>
<section><h2>Lima Museum</h2><a href="/lima/index.html">Lima</a> · <a href="/blogs/2025/2025-list.html">Older writing</a> · <a href="/play/memory.html">Memory Cabinet</a></section>
<section><h2>Notes Garden</h2><a href="/home/notes.html">Notes wall</a> · <a href="/home/categories.html">Categories</a> · <a href="/recently-updated.html">Recently updated</a> · <a href="/sitemap.html">Sitemap</a></section>
<section class="lima-main-menu" data-lima-menu hidden aria-labelledby="lima-game-title">
<p class="lima-main-menu__eyebrow">A four-chapter fantasy adventure</p>
<h1 id="lima-game-title">Rescue<br />Princess Lima</h1>
<p class="lima-main-menu__tagline">The princess has been taken beyond the mountains. Help a wounded village, cross the wild roads, and enter the Fortress of Shadows.</p>
<div class="lima-menu-stack">
<button type="button" class="lima-primary" data-lima-new>New Game</button>
<button type="button" data-lima-continue disabled>Continue</button>
<button type="button" data-lima-credits>Credits</button>
<a class="lima-button-link" href="/">Exit to Website</a>
</div>
</details>
</section>
<noscript><p class="archive-world__noscript">The RPG needs JavaScript, but every destination remains available in the plain directory above.</p></noscript>
<section class="lima-setup" data-lima-setup hidden aria-labelledby="lima-setup-title">
<form data-lima-setup-form>
<p class="lima-main-menu__eyebrow">New Game</p>
<h2 id="lima-setup-title">Name the traveller</h2>
<label>Traveller name <input type="text" name="name" minlength="1" maxlength="20" autocomplete="nickname" required /></label>
<fieldset>
<legend>Travel cloak</legend>
<label><input type="radio" name="appearance" value="azure" checked /> Azure</label>
<label><input type="radio" name="appearance" value="ember" /> Ember</label>
<label><input type="radio" name="appearance" value="pine" /> Pine</label>
</fieldset>
<p class="lima-setup__error" data-lima-setup-error aria-live="polite"></p>
<button type="submit" class="lima-primary">Begin Chapter One</button>
<button type="button" data-lima-setup-cancel>Back</button>
</form>
</section>
<section class="lima-hud" data-lima-hud hidden aria-label="Game status">
<div><span>Traveller</span><strong data-lima-player>Traveller</strong><span data-lima-chapter>Chapter 1</span></div>
<div><span>Health</span><strong data-lima-health>100 / 100</strong><div class="lima-hud__healthbar" aria-hidden="true"><i></i></div></div>
<div class="lima-hud__objective"><span data-lima-region>Broken Village</span><strong data-lima-objective>Speak with Elder Corin.</strong></div>
<div class="lima-hud__actions">
<button type="button" data-lima-quests aria-label="Quest log">Quests</button>
<button type="button" data-lima-inventory aria-label="Inventory" data-lima-tonics>Tonic ×2</button>
<button type="button" data-lima-sound>Sound Muted</button>
<button type="button" data-lima-fullscreen aria-pressed="false">Fullscreen</button>
<button type="button" data-lima-pause>Menu</button>
</div>
</section>
<section class="lima-boss" data-lima-boss hidden aria-live="polite">
<strong data-lima-boss-name>Guardian</strong>
<div class="lima-boss__bar" aria-hidden="true"><i></i></div>
</section>
<div class="lima-status-stack" data-lima-status-stack hidden>
<p class="lima-prompt" data-lima-prompt>Move with WASD or arrow keys.</p>
<p class="lima-status" data-lima-status aria-live="polite">Audio begins muted. Press Sound to enable it.</p>
</div>
<div class="lima-touch" data-lima-touch hidden aria-label="Touch controls">
<div class="lima-dpad">
<button type="button" data-lima-move="up" aria-label="Move up"></button>
<button type="button" data-lima-move="left" aria-label="Move left"></button>
<button type="button" data-lima-move="down" aria-label="Move down"></button>
<button type="button" data-lima-move="right" aria-label="Move right"></button>
</div>
<div class="lima-actions">
<button type="button" data-lima-attack>Attack</button>
<button type="button" data-lima-interact>Interact</button>
<button type="button" data-lima-item>Item</button>
<button type="button" data-lima-pause>Menu</button>
</div>
</div>
<div class="lima-overlay" data-lima-overlay hidden>
<section class="lima-panel" data-lima-panel role="dialog" aria-modal="true" aria-labelledby="lima-panel-title">
<header>
<div><p class="lima-panel__eyebrow">Rescue Princess Lima</p><h2 id="lima-panel-title" data-lima-panel-title>Menu</h2></div>
<button type="button" data-lima-panel-close aria-label="Close">×</button>
</header>
<div class="lima-panel__body" data-lima-panel-body></div>
</section>
</div>
<noscript>
<section class="lima-rpg__loading"><strong>JavaScript is required for this game.</strong><a href="/">Exit to Website</a></section>
</noscript>
</main>
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=archive-world-2.1.2" defer></script>
<script src="/assets/scripts/pages/archive-world-data.js?v=archive-world-2.1.2" defer></script>
<script src="/assets/scripts/pages/archive-world-state.js?v=archive-world-2.1.2" defer></script>
<script src="/assets/scripts/pages/archive-world-systems.js?v=archive-world-2.1.2" defer></script>
<script src="/assets/scripts/pages/archive-world-audio.js?v=archive-world-2.1.2" defer></script>
<script src="/assets/scripts/pages/archive-world-ui.js?v=archive-world-2.1.2" defer></script>
<script src="/assets/scripts/pages/archive-world-scenes.js?v=archive-world-2.1.2" defer></script>
<script src="/assets/scripts/pages/archive-world.js?v=archive-world-2.1.2" defer></script>
<link rel="stylesheet" href="/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.0.1" />
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=princess-lima-1.0.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-data.js?v=princess-lima-1.0.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-state.js?v=princess-lima-1.0.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-systems.js?v=princess-lima-1.0.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-audio.js?v=princess-lima-1.0.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-ui.js?v=princess-lima-1.0.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-scenes.js?v=princess-lima-1.0.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-game.js?v=princess-lima-1.0.1" 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 10:03</span>@@
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">30-07-2026 10:18</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

@@ -2,7 +2,7 @@
#+OPTIONS: toc:nil num:nil
* Recently Updated (top 26 files)
- [[file:play/rpg.org][The Archive World]] @@html:<span class="post-date">2026-07-29 00:00</span>@@
- [[file:play/rpg.org][Rescue Princess Lima]] @@html:<span class="post-date">2026-07-29 00:00</span>@@
- [[file:play/house.org][The House of Pages]] @@html:<span class="post-date">2026-07-15 00:00</span>@@
- [[file:blogs/2026/07-july/12-07-week-review.org][[12-07-2026] - Weekly Review]] @@html:<span class="post-date">2026-07-12 12:00</span>@@
- [[file:home/wird-tracker.org][Wird Tracker]] @@html:<span class="post-date">2026-07-08 22:36</span>@@

View File

@@ -110,9 +110,9 @@ flowchart TD
n43 --> n52
n53["Tag: insights"]
n43 --> n53
n54["Tag: education"]
n54["Tag: emacs"]
n43 --> n54
n55["Tag: emacs"]
n55["Tag: education"]
n43 --> n55
n56["Tag: reading"]
n43 --> n56
@@ -142,7 +142,7 @@ flowchart TD
n58 --> n68
n69["The House of Pages"]
n58 --> n69
n70["The Archive World"]
n70["Rescue Princess Lima"]
n58 --> n70
n71{{"posts"}}
root --> n71
@@ -227,8 +227,8 @@ flowchart TD
click n51 "tags/life.html" "Tag: life"
click n52 "tags/update.html" "Tag: update"
click n53 "tags/insights.html" "Tag: insights"
click n54 "tags/education.html" "Tag: education"
click n55 "tags/emacs.html" "Tag: emacs"
click n54 "tags/emacs.html" "Tag: emacs"
click n55 "tags/education.html" "Tag: education"
click n56 "tags/reading.html" "Tag: reading"
click n57 "tags/maths.html" "Tag: maths"
click n59 "play/sigil.html" "Sigil Press"
@@ -242,7 +242,7 @@ flowchart TD
click n67 "play/poem.html" "Marginalia Machine"
click n68 "play/the-rain-index.html" "The Rain Index"
click n69 "play/house.html" "The House of Pages"
click n70 "play/rpg.html" "The Archive World"
click n70 "play/rpg.html" "Rescue Princess Lima"
click n72 "posts/posts-intro.html" "Posts Introduction"
click n73 "posts/posts-list.html" "Posts List"
click n75 "posts/career/solid-principles.html" "SOLID Principles"
@@ -321,8 +321,8 @@ flowchart TD
- [[file:tags/life.org][Tag: life]]
- [[file:tags/update.org][Tag: update]]
- [[file:tags/insights.org][Tag: insights]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/reading.org][Tag: reading]]
- [[file:tags/maths.org][Tag: maths]]
- play
@@ -337,7 +337,7 @@ flowchart TD
- [[file:play/poem.org][Marginalia Machine]]
- [[file:play/the-rain-index.org][The Rain Index]]
- [[file:play/house.org][The House of Pages]]
- [[file:play/rpg.org][The Archive World]]
- [[file:play/rpg.org][Rescue Princess Lima]]
- posts
- [[file:posts/posts-intro.org][Posts Introduction]]
- [[file:posts/posts-list.org][Posts List]]
@@ -361,4 +361,4 @@ flowchart TD
- [[file:posts/career/javascript.org][Understands the Javascript language]]
- [[file:posts/career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]]
- [[file:posts/career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]]
- [[file:posts/career/career-list.org][Career List]]
- [[file:posts/career/career-list.org][Career List]]

View File

@@ -1,96 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const data = require("../assets/scripts/pages/archive-world-data.js");
const state = require("../assets/scripts/pages/archive-world-state.js");
test("fresh v2 state applies safe defaults", () => {
const fresh = state.fresh(" Ada ", "moss");
assert.equal(fresh.version, 2);
assert.equal(fresh.name, "Ada");
assert.equal(fresh.palette, "moss");
assert.equal(fresh.position.area, "town");
assert.deepEqual(
{ x: fresh.position.x, y: fresh.position.y },
data.AREAS.town.safeSpawns.gate
);
assert.ok(fresh.position.y > data.AREAS.town.gates[0].y + data.AREAS.town.gates[0].height);
assert.equal(fresh.health, 100);
assert.equal(fresh.settings.soundEnabled, false);
assert.deepEqual(fresh.sigils, []);
assert.equal(fresh.inventory.find((item) => item.id === "ink_vial").quantity, 2);
});
test("names and palettes are validated", () => {
assert.equal(state.validateName(" Archive Guest "), "Archive Guest");
assert.equal(state.validateName(""), null);
assert.equal(state.validateName("x".repeat(21)), null);
assert.equal(state.validatePalette("berry"), "berry");
assert.equal(state.validatePalette("ultraviolet"), null);
});
test("v1 data migrates identity, discoveries, position and sound", () => {
const migrated = state.migrateV1({
version: 1,
name: "Mira",
palette: "berry",
discovered: ["gate", "gate", "museum", "missing"],
complete: false,
position: { x: 700, y: 850 },
soundEnabled: true
});
assert.equal(migrated.version, 2);
assert.deepEqual(migrated.discovered, ["gate", "museum"]);
assert.equal(migrated.position.area, "town");
assert.equal(migrated.position.x, 700);
assert.equal(migrated.settings.soundEnabled, true);
});
test("normalization deduplicates and filters restored collections", () => {
const candidate = state.fresh("Zaine", "brass");
candidate.discovered = ["gate", "gate", "missing"];
candidate.sigils = ["garden", "garden", "bad"];
candidate.inventory.push({ id: "ink_vial", quantity: 99 }, { id: "invalid", quantity: 2 });
candidate.defeatedBosses = ["sentinel", "sentinel", "fake"];
candidate.solvedPuzzles = ["gate", "gate", "fake"];
const restored = state.normalize(candidate);
assert.deepEqual(restored.discovered, ["gate"]);
assert.deepEqual(restored.sigils, ["garden"]);
assert.equal(restored.inventory.find((item) => item.id === "ink_vial").quantity, data.ITEMS.ink_vial.stack);
assert.deepEqual(restored.defeatedBosses, ["sentinel"]);
assert.deepEqual(restored.solvedPuzzles, ["gate"]);
});
test("quest restoration accepts only known statuses and bounded counters", () => {
const candidate = state.fresh("Ada", "brass");
candidate.quests.gate = { status: "active", step: 200, count: -5 };
candidate.quests.library = { status: "nonsense", step: 2, count: 3 };
const restored = state.normalize(candidate);
assert.deepEqual(restored.quests.gate, { status: "active", step: 20, count: 0 });
assert.deepEqual(restored.quests.library, { status: "locked", step: 2, count: 3 });
});
test("position, health, settings and unsupported schemas recover safely", () => {
const candidate = state.fresh("Ada", "brass");
candidate.position = { area: "missing", x: -100, y: 9000, facing: "downward" };
candidate.health = 999;
candidate.settings = { soundEnabled: true, ambience: 9, music: -2, effects: "bad", assist: true };
const restored = state.normalize(candidate);
assert.equal(restored.position.area, "town");
assert.equal(restored.position.x, 24);
assert.equal(restored.position.y, 1062);
assert.equal(restored.position.facing, "north");
assert.equal(restored.health, restored.maxHealth);
assert.equal(restored.settings.ambience, 1);
assert.equal(restored.settings.music, 0);
assert.equal(restored.settings.effects, 0.65);
assert.equal(state.parse("{"), null);
assert.equal(state.parse(JSON.stringify({ version: 99 })), null);
});
test("level thresholds and reset-ready fresh state are deterministic", () => {
assert.equal(state.levelForXp(0), 1);
assert.equal(state.levelForXp(80), 2);
assert.equal(state.levelForXp(620), 5);
assert.deepEqual(state.fresh("Mira", "berry").discovered, []);
assert.equal(state.fresh("Mira", "berry").quests.gate.status, "locked");
});

View File

@@ -1,160 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const data = require("../assets/scripts/pages/archive-world-data.js");
const state = require("../assets/scripts/pages/archive-world-state.js");
const systems = require("../assets/scripts/pages/archive-world-systems.js");
test("movement normalization prevents faster diagonals", () => {
const straight = systems.normalizedVector(1, 0, 175);
const diagonal = systems.normalizedVector(1, 1, 175);
assert.equal(Math.round(Math.hypot(straight.x, straight.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", () => {
let current = state.fresh("Ada", "brass");
current = systems.discover(current, "gate");
current = systems.discover(current, "gate");
assert.deepEqual(current.discovered, ["gate"]);
data.LANDMARK_IDS.forEach((id) => { current = systems.discover(current, id); });
assert.equal(current.complete, true);
});
test("quest progression and completion grant each reward once", () => {
let current = state.fresh("Ada", "brass");
current = systems.startQuest(current, "gate");
assert.equal(current.quests.gate.status, "active");
current = systems.completeQuest(current, "gate");
const xp = current.xp;
assert.equal(current.quests.gate.status, "complete");
assert.deepEqual(current.sigils, ["gate"]);
assert.equal(current.inventory.some((item) => item.id === "lantern"), true);
current = systems.completeQuest(current, "gate");
assert.equal(current.xp, xp);
assert.deepEqual(current.sigils, ["gate"]);
});
test("inventory collection caps stacks and consumables heal", () => {
let current = state.fresh("Ada", "brass");
current = systems.addItem(current, "ink_vial", 30);
assert.equal(systems.itemQuantity(current, "ink_vial"), 9);
current.health = 30;
const used = systems.consumeItem(current, "ink_vial");
assert.equal(used.used, true);
assert.equal(used.state.health, 65);
assert.equal(systems.itemQuantity(used.state, "ink_vial"), 8);
});
test("damage respects invulnerability and assist, defeat preserves progress", () => {
let current = state.fresh("Ada", "brass");
current.sigils = ["gate"];
const assisted = state.normalize({ ...current, settings: { ...current.settings, assist: true } });
const first = systems.takeDamage(assisted, 20, 2000, 0);
assert.equal(first.state.health, 90);
const ignored = systems.takeDamage(first.state, 20, 2200, 2000);
assert.equal(ignored.hit, false);
const defeated = systems.takeDamage({ ...first.state, health: 5, settings: { ...first.state.settings, assist: false } }, 20, 5000, 0);
assert.equal(defeated.defeated, true);
const respawned = systems.respawn(defeated.state);
assert.deepEqual(respawned.sigils, ["gate"]);
assert.equal(respawned.health >= 50, true);
});
test("XP levels, boss completion and sigil story stage restore correctly", () => {
let current = state.fresh("Ada", "brass");
current = systems.grantXp(current, 200);
assert.equal(current.level, 3);
assert.equal(current.maxHealth, 120);
current = systems.recordBoss(current, "sentinel");
assert.equal(current.defeatedBosses.includes("sentinel"), true);
assert.equal(current.quests.workshop.status, "complete");
data.LANDMARK_IDS.forEach((id) => {
if (!current.sigils.includes(id)) current = systems.completeQuest(current, id);
});
assert.equal(current.sigils.length, 8);
assert.equal(current.story.stage, "vault");
current = systems.recordBoss(current, "redactor");
assert.equal(current.story.stage, "postgame");
});
test("invalid collision positions resolve to a named safe spawn", () => {
assert.equal(systems.isSafe("town", 200, 150), false);
const safe = systems.nearestSafeSpawn("town", 200, 150);
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));
});
test("locked-gate saves inside town recover to the outside opening spawn", () => {
const current = state.fresh("Ada", "brass");
current.position = { area: "town", spawn: "square", x: 724, y: 555, facing: "south" };
const safe = systems.safeSpawnForState(current);
assert.deepEqual(
{ area: safe.area, spawn: safe.spawn, x: safe.x, y: safe.y },
{ area: "town", spawn: "gate", ...data.AREAS.town.safeSpawns.gate }
);
current.sigils.push("gate");
const unlocked = systems.safeSpawnForState(current);
assert.equal(unlocked.spawn, "saved");
assert.equal(unlocked.y, 555);
});
test("interaction ranges are type-specific and NPC conversations require close approach", () => {
const npc = { type: "npc", id: "orin", x: 0, y: 0 };
const landmark = { type: "landmark", id: "gate", x: 56, y: 0 };
assert.equal(systems.interactionRadius(npc), 46);
assert.equal(systems.nearestInteraction([npc], 47, 0), null);
assert.equal(systems.nearestInteraction([npc], 45, 0), npc);
assert.equal(systems.nearestInteraction([npc, landmark], 0, 0), npc);
assert.equal(systems.nearestInteraction([landmark], 0, 0), landmark);
});
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}`);
});
});
test("town safe spawns do not place the player inside an NPC body", () => {
Object.entries(data.AREAS.town.safeSpawns).forEach(([spawnId, spawn]) => {
data.AREAS.town.npcs.forEach(([npcId, x, y]) => {
assert.ok(Math.hypot(spawn.x - x, spawn.y - y) >= 30, `${spawnId} overlaps npc:${npcId}`);
});
});
});

View File

@@ -0,0 +1,62 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const data = require("../assets/scripts/pages/princess-lima-data.js");
const state = require("../assets/scripts/pages/princess-lima-state.js");
test("fresh save uses the Princess Lima schema and safe village start", () => {
const fresh = state.fresh(" Rowan ", "pine");
assert.equal(fresh.version, 1);
assert.equal(state.STORAGE_KEY, "zxh_princess_lima_rpg_v1");
assert.equal(fresh.player.name, "Rowan");
assert.equal(fresh.player.appearance, "pine");
assert.equal(fresh.region, "village");
assert.deepEqual({ x: fresh.position.x, y: fresh.position.y }, data.MAPS.village.spawns.start);
assert.equal(fresh.settings.soundEnabled, false);
});
test("names and appearances validate", () => {
assert.equal(state.validName(" A Traveller "), "A Traveller");
assert.equal(state.validName(""), null);
assert.equal(state.validName("x".repeat(21)), null);
assert.equal(state.normalize({ ...state.fresh("A", "azure"), player: { name: "A", appearance: "bad" } }), null);
});
test("valid saves restore bounded state and derived equipment", () => {
const candidate = state.fresh("Mira", "azure");
candidate.health = 999;
candidate.inventory = [
{ id: "tempered_sword", quantity: 1 },
{ id: "healing_tonic", quantity: 99 },
{ id: "bad", quantity: 4 }
];
candidate.solvedPuzzles = ["forest_stones", "forest_stones", "bad"];
candidate.defeatedBosses = ["captain", "captain", "bad"];
const restored = state.normalize(candidate);
assert.equal(restored.health, restored.maxHealth);
assert.equal(restored.attack, 2);
assert.equal(restored.inventory.find((item) => item.id === "healing_tonic").quantity, 9);
assert.deepEqual(restored.solvedPuzzles, ["forest_stones"]);
assert.deepEqual(restored.defeatedBosses, ["captain"]);
});
test("invalid and unsupported saves recover without throwing", () => {
assert.equal(state.parse("{"), null);
assert.equal(state.parse(JSON.stringify({ version: 99 })), null);
assert.equal(state.parse(JSON.stringify({ version: 1 })), null);
});
test("quest, route, puzzle, boss and settings restoration filters malformed data", () => {
const candidate = state.fresh("Mira", "ember");
candidate.quests.aftermath = { status: "active", count: 999, rewarded: true };
candidate.quests.village_defence = { status: "nonsense", count: -10 };
candidate.unlockedRoutes = ["guide_found", "guide_found", "fake"];
candidate.settings = { master: 4, music: -2, effects: "bad", textSpeed: "instant", highContrast: true };
const restored = state.normalize(candidate);
assert.equal(restored.quests.aftermath.count, 99);
assert.equal(restored.quests.village_defence.status, "locked");
assert.deepEqual(restored.unlockedRoutes, ["guide_found"]);
assert.equal(restored.settings.master, 1);
assert.equal(restored.settings.music, 0);
assert.equal(restored.settings.effects, 0.7);
assert.equal(restored.settings.textSpeed, "instant");
});

View File

@@ -0,0 +1,104 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const data = require("../assets/scripts/pages/princess-lima-data.js");
const state = require("../assets/scripts/pages/princess-lima-state.js");
const systems = require("../assets/scripts/pages/princess-lima-systems.js");
test("movement is responsive and diagonal speed is normalized", () => {
let velocity = { x: 0, y: 0 };
for (let frame = 0; frame < 120; frame += 1) {
velocity = systems.approachVelocity(velocity.x, velocity.y, 1, 1, 16, false);
}
assert.equal(Math.round(Math.hypot(velocity.x, velocity.y)), 176);
for (let frame = 0; frame < 20; frame += 1) {
velocity = systems.approachVelocity(velocity.x, velocity.y, 0, 0, 16, false);
}
assert.deepEqual(velocity, { x: 0, y: 0 });
});
test("all named spawns are collision-safe and invalid positions recover", () => {
const current = state.fresh("Ada", "azure");
Object.entries(data.MAPS).forEach(([regionId, map]) => {
Object.entries(map.spawns).forEach(([spawnId, spawn]) => {
assert.equal(systems.isSafePosition(current, regionId, spawn.x, spawn.y), true, `${regionId}:${spawnId}`);
});
});
const recovered = systems.nearestSafeSpawn(current, "village", 100, 100);
assert.equal(systems.isSafePosition(current, recovered.region, recovered.x, recovered.y), true);
});
test("quest progression grants rewards once and unlocks routes", () => {
let current = state.fresh("Ada", "azure");
current = systems.completeQuest(current, "aftermath");
assert.equal(systems.quantity(current, "village_sword"), 1);
current = systems.progressQuest(current, "village_defence", 3);
assert.equal(current.quests.village_defence.status, "complete");
assert.equal(current.unlockedRoutes.includes("village_defended"), true);
const tonics = systems.quantity(current, "healing_tonic");
current = systems.completeQuest(current, "village_defence");
assert.equal(systems.quantity(current, "healing_tonic"), tonics);
});
test("optional quests remain independent of chapter progression", () => {
let current = state.fresh("Ada", "azure");
current = systems.startQuest(current, "healer_herbs");
current = systems.completeQuest(current, "healer_herbs");
assert.equal(current.quests.healer_herbs.status, "complete");
assert.equal(current.chapter, 1);
});
test("inventory caps stacks, protects quest items and healing consumes safely", () => {
let current = state.fresh("Ada", "azure");
current = systems.addItem(current, "healing_tonic", 30);
assert.equal(systems.quantity(current, "healing_tonic"), 9);
current = systems.addItem(current, "prison_key", 1);
assert.equal(systems.removeItem(current, "prison_key", 1).removed, false);
current.health = 30;
const result = systems.useItem(current, "healing_tonic");
assert.equal(result.used, true);
assert.equal(result.state.health, 70);
assert.equal(systems.quantity(result.state, "healing_tonic"), 8);
});
test("combat applies defence, invulnerability, defeat and checkpoint respawn", () => {
let current = systems.addItem(state.fresh("Ada", "azure"), "buckler", 1);
const hit = systems.damage(current, 10, 2000, 0);
assert.equal(hit.amount, 8);
assert.equal(hit.state.health, 92);
assert.equal(systems.damage(hit.state, 10, 2100, 2000).hit, false);
const defeated = systems.damage({ ...hit.state, health: 3 }, 20, 4000, 0);
assert.equal(defeated.defeated, true);
const respawned = systems.respawn(defeated.state);
assert.equal(respawned.region, "village");
assert.ok(respawned.health >= 50);
});
test("puzzles unlock chapter routes and cannot reward twice", () => {
let current = state.fresh("Ada", "azure");
current = systems.solvePuzzle(current, "forest_stones");
assert.equal(current.solvedPuzzles.includes("forest_stones"), true);
assert.equal(current.unlockedRoutes.includes("guide_found"), true);
const boots = systems.quantity(current, "trail_boots");
current = systems.solvePuzzle(current, "forest_stones");
assert.equal(systems.quantity(current, "trail_boots"), boots);
});
test("boss phases and victories are deterministic", () => {
assert.equal(systems.bossPhase(48, 48, 3), 1);
assert.equal(systems.bossPhase(24, 48, 3), 2);
assert.equal(systems.bossPhase(10, 48, 3), 3);
let current = state.fresh("Ada", "azure");
current = systems.recordBoss(current, "malrec");
assert.equal(current.defeatedBosses.includes("malrec"), true);
assert.equal(current.unlockedRoutes.includes("malrec_defeated"), true);
assert.equal(current.story, "rescue");
});
test("complete story path reaches the Princess Lima rescue state", () => {
let current = state.fresh("Ada", "azure");
["aftermath", "village_defence", "find_guide", "ruins_light", "wolf_miniboss", "repair_bridge", "stone_guardian", "free_scout", "free_prisoners", "break_wards", "defeat_malrec"]
.forEach((id) => { current = systems.completeQuest(current, id); });
assert.equal(current.chapter, 4);
assert.equal(current.unlockedRoutes.includes("wards_broken"), true);
assert.equal(current.unlockedRoutes.includes("malrec_defeated"), true);
});