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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

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

View File

@@ -1,249 +0,0 @@
(function (root) {
"use strict";
function rect(x, y, width, height, depth) {
return Object.freeze({ x, y, width, height, depth: depth || 2 });
}
function enemy(type, x, y, options) {
return Object.freeze(Object.assign({
type, x, y, leash: 150, engage: 245, requiresSigil: null, questId: null
}, options || {}));
}
function landmark(id, title, eyebrow, x, y, npc, description, links, questId, area) {
return Object.freeze({ id, title, eyebrow, x, y, npc, description, links, questId, area: area || null });
}
const LANDMARKS = Object.freeze({
gate: landmark("gate", "Town Gate", "Arrivals", 724, 920, "orin",
"The only opening in Archive Town's old wall. Gatekeeper Orin is trying to restore the erased arrival plaques.",
[["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"]], "gate"),
library: landmark("library", "Grand Library", "Knowledge", 265, 305, "sera",
"Head Archivist Sera guards the sealed vault and a shelf whose symbols no longer remember their order.",
[["Posts", "/posts/posts-list.html"], ["Categories", "/home/categories.html"], ["Posts introduction", "/posts/posts-intro.html"]], "library", "vault"),
study: landmark("study", "Guild Study", "Work", 720, 300, "vale",
"Instructor Vale keeps the guild calm while Nulls gather beyond the practice banners.",
[["Career", "/posts/career/career-list.html"], ["Competency status", "/home/status.html"], ["Probation objectives", "/posts/career/probation-objectives.html"]], "study"),
inn: landmark("inn", "Kitchen Inn", "Daily life", 1110, 305, "mara",
"Innkeeper Mara holds three borrowed memories and cannot recall which traveller entrusted each one.",
[["Blogs", "/blogs/blogs-list.html"], ["Weekly reviews", "/tags/review.html"], ["Blogs introduction", "/blogs/blogs-intro.html"]], "inn"),
workshop: landmark("workshop", "Workshop", "Living systems", 1125, 590, "pell",
"Inventor Pell's recall engine has lost its cogs. A Redacted Sentinel occupies the repair yard.",
[["Services", "/home/services.html"], ["Wird tracker", "/home/wird-tracker.html"], ["Countdowns", "/home/countdown.html"], ["Backlog", "/home/backlog.html"]], "workshop", "workshop"),
playroom: landmark("playroom", "Playroom", "Experiments", 1120, 835, "pip",
"Gamekeeper Pip's lantern maze changes whenever a name disappears. Six patient lights still know the route.",
[["Play hub", "/play/play.html"], ["The Rain Index", "/play/the-rain-index.html"], ["House of Pages", "/play/house.html"]], "playroom", "playroom"),
museum: landmark("museum", "Lima Museum", "Keepsakes", 270, 830, "lima",
"Curator Lima can reveal what the Redactor was before the ink, but the Museum Lens is fractured.",
[["Lima archive", "/lima/index.html"], ["Older writing", "/blogs/2025/2025-list.html"], ["Memory Cabinet", "/play/memory.html"]], "museum"),
garden: landmark("garden", "Notes Garden", "Connections", 255, 545, "rowan",
"Gardener Rowan tends standing stones that preserve connections no catalogue can hold alone.",
[["Notes wall", "/home/notes.html"], ["Categories", "/home/categories.html"], ["Recently updated", "/recently-updated.html"], ["Sitemap", "/sitemap.html"]], "garden")
});
const NPCS = Object.freeze({
orin: Object.freeze({ name: "Gatekeeper Orin", frame: 0, landmark: "gate",
intro: "Your name held when the gate tried to erase it. That Living Bookmark chose well.",
hint: "Read the three arrival plaques from oldest memory to newest: Home, Contact, Return." }),
sera: Object.freeze({ name: "Head Archivist Sera", frame: 1, landmark: "library",
intro: "Books are not silent. These shelves are shouting their symbols out of order.",
hint: "A story begins with a seed, grows a branch, becomes a page, then earns its star." }),
vale: Object.freeze({ name: "Instructor Vale", frame: 2, landmark: "study",
intro: "A lesson remembered under pressure becomes a skill. Hold this square against the Nulls.",
hint: "Use Space to strike. Keep moving after a Scribe raises its quill." }),
mara: Object.freeze({ name: "Innkeeper Mara", frame: 3, landmark: "inn",
intro: "Three guests left memories for safekeeping. The ink took their labels, not their meaning.",
hint: "The sailor remembers rain, the gardener remembers a seed, and the child remembers a red kite." }),
pell: Object.freeze({ name: "Inventor Pell", frame: 4, landmark: "workshop",
intro: "My recall engine needs three cogs. The last one is under a Sentinel's very unreasonable boot.",
hint: "The Sentinel pauses after its charge. That is the safe moment to attack." }),
pip: Object.freeze({ name: "Gamekeeper Pip", frame: 5, landmark: "playroom",
intro: "The maze is fair, even if the Redactor is not. Light the lamps from smallest story to largest.",
hint: "Try lanterns two, four, one, three. The maze forgives every mistake." }),
lima: Object.freeze({ name: "Curator Lima", frame: 6, landmark: "museum",
intro: "Memories become dangerous only when stripped of context. Help me refocus the Museum Lens.",
hint: "Align the lens from near memory to far memory: self, neighbour, town, archive." }),
rowan: Object.freeze({ name: "Gardener Rowan", frame: 7, landmark: "garden",
intro: "Connections grow when tended in the right season. Plant, water, remember, then share.",
hint: "Seed before rain, rain before bloom, bloom before the path opens." }),
nell: Object.freeze({ name: "Nell, a lost courier", frame: 8, landmark: null,
intro: "I know I was carrying a letter. I cannot remember whether I meant to deliver it or keep it.",
hint: "The kind answer is not always the easy answer." }),
otho: Object.freeze({ name: "Otho", frame: 9, landmark: null, intro: "The lamps know more names than I do today.", hint: "" }),
remy: Object.freeze({ name: "Remy", frame: 10, landmark: null, intro: "I leave breadcrumbs. The Redactor hates breadcrumbs.", hint: "" })
});
const QUESTS = Object.freeze({
gate: Object.freeze({ title: "A Name at the Gate", landmark: "gate", kind: "sequence",
objective: "Restore Orin's three arrival plaques.", reward: "Gate Sigil · Living Bookmark · 80 Memory",
sequence: ["home", "contact", "return"], options: ["return", "home", "contact"] }),
library: Object.freeze({ title: "The Shouting Shelf", landmark: "library", kind: "sequence",
objective: "Put the shelf symbols back into narrative order.", reward: "Library Sigil · Archivist's Key · 90 Memory",
sequence: ["seed", "branch", "page", "star"], options: ["page", "seed", "star", "branch"] }),
study: Object.freeze({ title: "Lesson Under Pressure", landmark: "study", kind: "combat",
objective: "Defeat three Nulls threatening the Guild Study.", reward: "Study Sigil · +90 Memory",
target: 3 }),
inn: Object.freeze({ title: "Borrowed Memories", landmark: "inn", kind: "matching",
objective: "Match rain, seed, and kite memories to their owners.", reward: "Inn Sigil · Recall Stew · 80 Memory",
matches: { rain: "sailor", seed: "gardener", kite: "child" } }),
workshop: Object.freeze({ title: "The Recall Engine", landmark: "workshop", kind: "boss",
objective: "Enter the repair yard and defeat the Redacted Sentinel.", reward: "Workshop Sigil · Bookmark upgrade · 120 Memory" }),
playroom: Object.freeze({ title: "Six Patient Lights", landmark: "playroom", kind: "sequence",
objective: "Solve Pip's lantern route inside the Playroom maze.", reward: "Playroom Sigil · Swiftstep Boots · 90 Memory",
sequence: ["2", "4", "1", "3"], options: ["1", "2", "3", "4"] }),
museum: Object.freeze({ title: "Context Through Glass", landmark: "museum", kind: "sequence",
objective: "Align the Museum Lens from nearest memory to farthest.", reward: "Museum Sigil · Museum Lens · 100 Memory",
sequence: ["self", "neighbour", "town", "archive"], options: ["archive", "self", "town", "neighbour"] }),
garden: Object.freeze({ title: "A Path That Remembers", landmark: "garden", kind: "sequence",
objective: "Wake the standing stones in the order of growth.", reward: "Garden Sigil · Garden Seed · 80 Memory",
sequence: ["seed", "rain", "bloom", "share"], options: ["rain", "share", "seed", "bloom"] }),
lost_letter: Object.freeze({ title: "The Letter Nell Kept", landmark: "gate", kind: "choice",
objective: "Help Nell decide what to do with the unaddressed letter.", reward: "Lore fragment · 40 Memory" })
});
const ITEMS = Object.freeze({
living_bookmark: Object.freeze({ name: "Living Bookmark", type: "equipment", icon: 0, description: "A blade-shaped bookmark that restores severed connections." }),
lantern: Object.freeze({ name: "Lantern of Recall", type: "quest", icon: 1, description: "Its light outlines things the Redactor tried to remove." }),
archive_key: Object.freeze({ name: "Archivist's Key", type: "key", icon: 2, description: "Opens the sealed stair beneath the Grand Library." }),
swiftstep_boots: Object.freeze({ name: "Swiftstep Boots", type: "equipment", icon: 3, description: "Quicker starts and gentler stops without changing top speed." }),
ink_vial: Object.freeze({ name: "Ink Vial", type: "consumable", icon: 4, description: "Restores 35 health.", stack: 9 }),
memory_fragment: Object.freeze({ name: "Memory Fragment", type: "collectable", icon: 5, description: "A small truth with its edges intact.", stack: 12 }),
garden_seed: Object.freeze({ name: "Garden Seed", type: "quest", icon: 6, description: "A seed that remembers every garden it came from." }),
workshop_cog: Object.freeze({ name: "Workshop Cog", type: "quest", icon: 7, description: "A precision cog for Pell's recall engine.", stack: 3 }),
museum_lens: Object.freeze({ name: "Museum Lens", type: "equipment", icon: 8, description: "Reveals erased details and hidden paths." }),
recall_stew: Object.freeze({ name: "Recall Stew", type: "consumable", icon: 9, description: "Restores all health.", stack: 3 }),
lore_page: Object.freeze({ name: "Lore Fragment", type: "lore", icon: 10, description: "Context the Redactor failed to consume.", stack: 16 }),
archive_sigil: Object.freeze({ name: "Archive Sigil", type: "sigil", icon: 12, description: "One of eight seals that reopen the Archive Vault." })
});
const AREA_FRAMES = Object.freeze({ vault: 0, workshop: 1, playroom: 2, boss: 3 });
const AREAS = Object.freeze({
town: Object.freeze({
width: 1448, height: 1086, background: "town", spawn: { x: 724, y: 1015 },
movement: Object.freeze({ speed: 162, acceleration: 1280, deceleration: 1850, bootAcceleration: 1720 }),
safeSpawns: Object.freeze({
gate: { x: 724, y: 1015 }, square: { x: 724, y: 555 }, library: { x: 270, y: 370 },
study: { x: 720, y: 350 }, inn: { x: 1110, y: 370 }, workshop: { x: 1060, y: 625 },
playroom: { x: 1120, y: 835 }, museum: { x: 270, y: 830 }, garden: { x: 370, y: 545 }
}),
safeZones: Object.freeze([
rect(650, 845, 150, 220), rect(585, 455, 280, 190)
]),
gates: Object.freeze([
Object.freeze({ id: "town-gate", x: 672, y: 850, width: 104, height: 102, requiresSigil: "gate" })
]),
collisions: Object.freeze([
/* Outer town wall, with a deliberate walkable opening through the gate. */
rect(0, 0, 1448, 62), rect(0, 0, 54, 1086), rect(1394, 0, 54, 1086),
rect(0, 862, 590, 98), rect(590, 840, 82, 132), rect(776, 840, 86, 132), rect(862, 862, 586, 98),
/* Major buildings. Their steps and front doors remain approachable. */
rect(188, 62, 270, 212, 7), rect(606, 55, 238, 218, 7), rect(964, 58, 292, 218, 7),
rect(1010, 418, 330, 142, 7), rect(1002, 655, 340, 155, 7), rect(218, 620, 220, 178, 7),
/* Rivers. Breaks align with the two east/west bridge routes. */
rect(468, 88, 112, 255), rect(468, 432, 112, 93), rect(468, 610, 112, 250),
rect(868, 88, 112, 255), rect(868, 432, 112, 93), rect(868, 610, 112, 250),
/* Garden pond, dense hedges and corner ink pockets. */
rect(56, 360, 105, 292), rect(160, 382, 286, 45), rect(78, 675, 105, 175),
rect(1260, 310, 130, 108), rect(1270, 572, 120, 92), rect(1270, 812, 120, 80)
]),
npcs: Object.freeze([
["orin", 724, 875], ["sera", 270, 335], ["vale", 720, 315], ["mara", 1110, 335],
["pell", 1060, 590], ["pip", 1060, 835], ["lima", 335, 830], ["rowan", 335, 545],
["nell", 760, 720], ["otho", 675, 520], ["remy", 790, 585]
]),
enemies: Object.freeze([
enemy("ink_blot", 635, 350, { leash: 95, engage: 210, requiresSigil: "gate", questId: "study" }),
enemy("lost_footnote", 805, 350, { leash: 95, engage: 210, requiresSigil: "gate", questId: "study" }),
enemy("null_scribe", 720, 405, { leash: 105, engage: 280, requiresSigil: "gate", questId: "study" }),
enemy("ink_blot", 1110, 625, { leash: 105, engage: 205, requiresSigil: "gate" }),
enemy("lost_footnote", 340, 590, { leash: 75, engage: 175, requiresSigil: "gate" })
])
}),
vault: Object.freeze({
width: 724, height: 543, background: "areas", frame: 0, spawn: { x: 362, y: 485 },
movement: Object.freeze({ speed: 155, acceleration: 1300, deceleration: 1900, bootAcceleration: 1740 }),
safeSpawns: Object.freeze({ entrance: { x: 362, y: 485 } }),
safeZones: Object.freeze([rect(300, 430, 124, 95)]),
collisions: Object.freeze([
rect(0, 0, 724, 32), rect(0, 0, 34, 543), rect(690, 0, 34, 543),
rect(0, 32, 278, 130), rect(446, 32, 278, 130), rect(278, 32, 168, 60),
rect(0, 472, 300, 71), rect(424, 472, 300, 71),
rect(145, 150, 100, 72), rect(479, 150, 100, 72),
rect(72, 272, 95, 70), rect(557, 272, 95, 70),
rect(145, 345, 100, 72), rect(479, 345, 100, 72)
]),
npcs: Object.freeze([]), enemies: Object.freeze([])
}),
workshop: Object.freeze({
width: 724, height: 543, background: "areas", frame: 1, spawn: { x: 362, y: 475 },
movement: Object.freeze({ speed: 155, acceleration: 1300, deceleration: 1900, bootAcceleration: 1740 }),
safeSpawns: Object.freeze({ entrance: { x: 362, y: 475 } }),
safeZones: Object.freeze([rect(300, 430, 124, 95)]),
collisions: Object.freeze([
rect(0, 0, 724, 32), rect(0, 0, 34, 543), rect(690, 0, 34, 543),
rect(34, 32, 656, 115), rect(0, 472, 300, 71), rect(424, 472, 300, 71),
rect(34, 145, 120, 118), rect(570, 145, 120, 125),
rect(34, 342, 218, 130), rect(520, 360, 170, 112)
]),
npcs: Object.freeze([]), enemies: Object.freeze([
enemy("sentinel", 362, 245, { leash: 205, engage: 360 })
])
}),
playroom: Object.freeze({
width: 724, height: 543, background: "areas", frame: 2, spawn: { x: 362, y: 485 },
movement: Object.freeze({ speed: 150, acceleration: 1250, deceleration: 1850, bootAcceleration: 1680 }),
safeSpawns: Object.freeze({ entrance: { x: 362, y: 485 } }),
safeZones: Object.freeze([rect(300, 445, 124, 80)]),
collisions: Object.freeze([
rect(0, 0, 724, 38), rect(0, 0, 28, 543), rect(696, 0, 28, 543),
rect(0, 488, 300, 55), rect(424, 488, 300, 55),
rect(28, 38, 210, 58), rect(294, 38, 186, 58), rect(522, 38, 174, 58),
rect(147, 104, 92, 92), rect(345, 96, 72, 118), rect(520, 106, 96, 94),
rect(28, 220, 128, 72), rect(217, 214, 118, 78), rect(416, 210, 116, 82), rect(615, 220, 81, 72),
rect(112, 315, 126, 78), rect(302, 300, 94, 102), rect(492, 315, 126, 78),
rect(28, 408, 118, 80), rect(205, 408, 105, 80), rect(414, 408, 105, 80), rect(578, 408, 118, 80)
]),
npcs: Object.freeze([]), enemies: Object.freeze([
enemy("lost_footnote", 90, 180, { leash: 60, engage: 150 }),
enemy("ink_blot", 650, 350, { leash: 55, engage: 145 })
])
}),
boss: Object.freeze({
width: 724, height: 543, background: "areas", frame: 3, spawn: { x: 362, y: 470 },
movement: Object.freeze({ speed: 158, acceleration: 1380, deceleration: 1950, bootAcceleration: 1780 }),
safeSpawns: Object.freeze({ entrance: { x: 362, y: 470 } }),
safeZones: Object.freeze([rect(310, 438, 104, 70)]),
collisions: Object.freeze([
rect(0, 0, 724, 42), rect(0, 0, 34, 543), rect(690, 0, 34, 543),
rect(34, 42, 145, 55), rect(545, 42, 145, 55),
rect(34, 97, 48, 350), rect(642, 97, 48, 350),
rect(34, 447, 245, 64), rect(445, 447, 245, 64),
rect(196, 146, 48, 48), rect(480, 146, 48, 48),
rect(196, 326, 48, 48), rect(480, 326, 48, 48)
]),
npcs: Object.freeze([]), enemies: Object.freeze([
enemy("redactor", 362, 190, { leash: 250, engage: 500 })
])
})
});
const ENEMIES = Object.freeze({
ink_blot: Object.freeze({ name: "Ink Blot", frame: 11, health: 2, speed: 48, damage: 10, xp: 12, behaviour: "chase" }),
lost_footnote: Object.freeze({ name: "Lost Footnote", frame: 12, health: 2, speed: 75, damage: 8, xp: 15, behaviour: "wander" }),
null_scribe: Object.freeze({ name: "Null Scribe", frame: 13, health: 3, speed: 42, damage: 12, xp: 20, behaviour: "ranged" }),
sentinel: Object.freeze({ name: "Redacted Sentinel", frame: 14, health: 12, speed: 82, damage: 16, xp: 100, behaviour: "charge", boss: true }),
redactor: Object.freeze({ name: "The Redactor", frame: 15, health: 30, speed: 48, damage: 16, xp: 250, behaviour: "redactor", boss: true })
});
const api = Object.freeze({
LANDMARKS, NPCS, QUESTS, ITEMS, AREAS, AREA_FRAMES, ENEMIES,
LANDMARK_IDS: Object.freeze(Object.keys(LANDMARKS)),
QUEST_IDS: Object.freeze(Object.keys(QUESTS)),
ITEM_IDS: Object.freeze(Object.keys(ITEMS)),
AREA_IDS: Object.freeze(Object.keys(AREAS)),
BOSS_IDS: Object.freeze(["sentinel", "redactor"])
});
root.ArchiveWorldData = api;
if (typeof module === "object" && module.exports) module.exports = api;
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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