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

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