Refactor org web platform and remove legacy code
All checks were successful
Build Org Website / build (push) Successful in 39s
@@ -274,7 +274,7 @@ The published HTML is static, but some browser modules rely on same-origin APIs:
|
||||
| Notes board | `pages/notes.js` | `/api/notes` |
|
||||
| Wird tracker | `pages/wird-tracker.js` | `/api/wird`, `/api/wird/motalah`, `/api/calibre/books` |
|
||||
| Competency board | `pages/competency-status-board.js` | `/api/competencies/items...` |
|
||||
| Archive World progress | `pages/archive-world.js` | Device-local `localStorage` state |
|
||||
| Princess Lima RPG progress | `pages/princess-lima-game.js` | Device-local `localStorage` state |
|
||||
|
||||
These APIs are not implemented here. Production routing must serve `output/` and proxy `/api/` to the appropriate backend. When changing an endpoint contract, coordinate the static client and its external service.
|
||||
|
||||
|
||||
BIN
assets/audio/princess-lima/attack.wav
Normal file
BIN
assets/audio/princess-lima/boss-theme.wav
Normal file
BIN
assets/audio/princess-lima/damage.wav
Normal file
BIN
assets/audio/princess-lima/defeat.wav
Normal file
BIN
assets/audio/princess-lima/door.wav
Normal file
BIN
assets/audio/princess-lima/forest-theme.wav
Normal file
BIN
assets/audio/princess-lima/fortress-theme.wav
Normal file
BIN
assets/audio/princess-lima/mountain-theme.wav
Normal file
BIN
assets/audio/princess-lima/pickup.wav
Normal file
BIN
assets/audio/princess-lima/puzzle.wav
Normal file
BIN
assets/audio/princess-lima/quest.wav
Normal file
BIN
assets/audio/princess-lima/step.wav
Normal file
BIN
assets/audio/princess-lima/victory-theme.wav
Normal file
BIN
assets/audio/princess-lima/victory.wav
Normal file
BIN
assets/audio/princess-lima/village-theme.wav
Normal file
|
Before Width: | Height: | Size: 563 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1012 KiB |
|
Before Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 998 KiB |
BIN
assets/images/play/princess-lima/cast-atlas.png
Normal file
|
After Width: | Height: | Size: 975 KiB |
BIN
assets/images/play/princess-lima/title-landscape.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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
|
||||
});
|
||||
}));
|
||||
@@ -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
|
||||
});
|
||||
}));
|
||||
@@ -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));
|
||||
@@ -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
|
||||
});
|
||||
}());
|
||||
@@ -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/"],
|
||||
|
||||
66
assets/scripts/pages/princess-lima-audio.js
Normal 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));
|
||||
247
assets/scripts/pages/princess-lima-data.js
Normal 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
|
||||
});
|
||||
}));
|
||||
363
assets/scripts/pages/princess-lima-game.js
Normal 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
|
||||
});
|
||||
}());
|
||||
535
assets/scripts/pages/princess-lima-scenes.js
Normal 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));
|
||||
189
assets/scripts/pages/princess-lima-state.js
Normal 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
|
||||
});
|
||||
}));
|
||||
212
assets/scripts/pages/princess-lima-systems.js
Normal 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
|
||||
});
|
||||
}));
|
||||
179
assets/scripts/pages/princess-lima-ui.js
Normal 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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[character]));
|
||||
}
|
||||
|
||||
root.PrincessLimaUI = Object.freeze({ create });
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this));
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
626
assets/styles/pages/princess-lima-rpg.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -784,3 +784,10 @@ at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/build-logs/gite
|
||||
2026-07-30T10:03:41.6923349+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
|
||||
2026-07-30T10:03:41.7745044+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
|
||||
2026-07-30T10:03:42.0626713+01:00 [INFO] Sent authoring server test notification.
|
||||
2026-07-30T10:23:02.5931302+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
|
||||
2026-07-30T10:23:02.6008378+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
|
||||
2026-07-30T10:23:02.9635424+01:00 [ERROR] Failed to analyze job 780: Cannot bind argument to parameter 'LogText' because it is an empty string.
|
||||
2026-07-30T10:23:03.5156704+01:00 [INFO] Sent build status notification with 1 embed(s).
|
||||
2026-07-30T10:23:03.5358268+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
|
||||
2026-07-30T10:23:03.6148289+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
|
||||
2026-07-30T10:23:03.9852050+01:00 [INFO] Sent authoring server test notification.
|
||||
|
||||
@@ -80,7 +80,9 @@ Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
|
||||
|
||||
(defun z/org-html-add-body-classes (output backend info)
|
||||
"Add layout-related classes to <body> based on file metadata."
|
||||
(if (org-export-derived-backend-p backend 'html)
|
||||
(if (and (org-export-derived-backend-p backend 'html)
|
||||
(not (and (fboundp 'z/rpg-standalone-file-p)
|
||||
(z/rpg-standalone-file-p info))))
|
||||
(let* ((input-file (plist-get info :input-file))
|
||||
(classes
|
||||
(delq nil
|
||||
@@ -101,6 +103,47 @@ Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
|
||||
(add-to-list 'org-export-filter-body-functions
|
||||
#'z/org-html-insert-comments-into-body)
|
||||
|
||||
(defconst z/rpg-standalone-start "<!-- RPG-STANDALONE-START -->")
|
||||
(defconst z/rpg-standalone-end "<!-- RPG-STANDALONE-END -->")
|
||||
|
||||
(defun z/rpg-standalone-file-p (info)
|
||||
"Return non-nil when INFO describes the dedicated RPG page."
|
||||
(let ((input-file (plist-get info :input-file)))
|
||||
(and input-file
|
||||
(string= (file-truename input-file)
|
||||
(file-truename (site-path "play/rpg.org"))))))
|
||||
|
||||
(defun z/render-rpg-standalone (output backend info)
|
||||
"Replace normal site chrome with a dedicated game document for the RPG."
|
||||
(if (and (org-export-derived-backend-p backend 'html)
|
||||
(z/rpg-standalone-file-p info))
|
||||
(let* ((start (string-match (regexp-quote z/rpg-standalone-start) output))
|
||||
(end (and start (string-match (regexp-quote z/rpg-standalone-end) output start))))
|
||||
(if (and start end)
|
||||
(let* ((body-start (+ start (length z/rpg-standalone-start)))
|
||||
(body (substring output body-start end))
|
||||
(body (replace-regexp-in-string
|
||||
"<link rel=\"stylesheet\" href=\"/assets/styles/pages/princess-lima-rpg.css[^\"]*\" />"
|
||||
""
|
||||
body)))
|
||||
(concat
|
||||
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
|
||||
"<meta charset=\"utf-8\" />\n"
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\" />\n"
|
||||
"<meta name=\"theme-color\" content=\"#080a12\" />\n"
|
||||
"<meta name=\"description\" content=\"A standalone fantasy RPG about rescuing Princess Lima.\" />\n"
|
||||
"<title>Rescue Princess Lima</title>\n"
|
||||
"<link rel=\"icon\" href=\"/assets/icons/icons8-film-tape-100.png\" />\n"
|
||||
"<link rel=\"stylesheet\" href=\"/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.0.1\" />\n"
|
||||
"</head>\n<body class=\"princess-lima-page\">\n"
|
||||
body
|
||||
"\n</body>\n</html>\n"))
|
||||
output))
|
||||
output))
|
||||
|
||||
(add-to-list 'org-export-filter-final-output-functions
|
||||
#'z/render-rpg-standalone)
|
||||
|
||||
(org-export-define-derived-backend 'z-html 'html
|
||||
:filters-alist '((:filter-final-output . z/insert-filetags-after-title)))
|
||||
|
||||
|
||||
@@ -261,4 +261,31 @@
|
||||
(result (z/org-html-add-body-classes output 'latex info)))
|
||||
(should (string= output result))))))
|
||||
|
||||
;; ── standalone RPG export ────────────────────────────────────────────────────
|
||||
|
||||
(ert-deftest test/rpg-standalone-export-removes-normal-site-chrome ()
|
||||
"The RPG final-output filter should return a dedicated viewport document."
|
||||
(let* ((info (list :input-file (site-path "play/rpg.org")))
|
||||
(output (concat
|
||||
"<html><head><script src=\"search.js\"></script></head><body>"
|
||||
"<div class=\"banner-header\">Normal site</div>"
|
||||
z/rpg-standalone-start
|
||||
"<main data-princess-lima-rpg>Game</main>"
|
||||
"<script src=\"/assets/scripts/pages/princess-lima-game.js\"></script>"
|
||||
z/rpg-standalone-end
|
||||
"<footer>Normal footer</footer></body></html>"))
|
||||
(result (z/render-rpg-standalone output 'html info)))
|
||||
(should (string-match-p "class=\"princess-lima-page\"" result))
|
||||
(should (string-match-p "data-princess-lima-rpg" result))
|
||||
(should (string-match-p "princess-lima-game.js" result))
|
||||
(should-not (string-match-p "banner-header" result))
|
||||
(should-not (string-match-p "Normal footer" result))
|
||||
(should-not (string-match-p "search.js" result))))
|
||||
|
||||
(ert-deftest test/rpg-standalone-export-ignores-other-pages ()
|
||||
"Normal pages must retain their existing exported document."
|
||||
(let* ((info (list :input-file (site-path "index.org")))
|
||||
(output "<html><body><div class=\"banner-header\">Site</div></body></html>"))
|
||||
(should (string= output (z/render-rpg-standalone output 'html info)))))
|
||||
|
||||
;;; build-site-tests.el ends here
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
<section><h2>Study</h2><a href="/posts/career/career-list.html">Career</a> · <a href="/home/status.html">Competency status</a></section>
|
||||
<section><h2>Kitchen</h2><a href="/blogs/blogs-list.html">Blogs</a> · <a href="/tags/review.html">Weekly reviews</a></section>
|
||||
<section><h2>Workshop</h2><a href="/home/services.html">Services</a> · <a href="/home/wird-tracker.html">Wird tracker</a> · <a href="/home/backlog.html">Backlog</a></section>
|
||||
<section><h2>Playroom</h2><a href="/play/play.html">Play</a> · <a href="/play/rpg.html">The Archive World</a></section>
|
||||
<section><h2>Playroom</h2><a href="/play/play.html">Play</a> · <a href="/play/rpg.html">Rescue Princess Lima</a></section>
|
||||
<section><h2>Attic</h2><a href="/lima/index.html">Lima archive</a> · <a href="/blogs/blogs-list.html">Older writing</a></section>
|
||||
<section><h2>Garden</h2><a href="/home/notes.html">Notes wall</a> · <a href="/home/categories.html">Categories</a> · <a href="/sitemap.html">Sitemap</a></section>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<a href="/play/terminal.html" data-kind="Story" data-desc="Explore a tiny command-line adventure hidden in the archive."><span>06</span><strong>Archive Terminal</strong></a>
|
||||
<a href="/play/study.html" data-kind="Timer" data-desc="Run a focus timer that grows a little desk scene as time passes."><span>07</span><strong>Study Lamp</strong></a>
|
||||
<a href="/play/sigil.html" data-kind="Maker" data-desc="Generate a small personal sigil from initials, colors, and motto."><span>08</span><strong>Sigil Press</strong></a>
|
||||
<a href="/play/rpg.html" data-kind="RPG world" data-desc="Walk through the website as Archive Town, discover eight landmarks, and travel through its living sections."><span>09</span><strong>The Archive World</strong></a>
|
||||
<a href="/play/rpg.html" data-kind="Fantasy RPG" data-desc="Cross a broken kingdom, defeat the fortress guardians, and rescue Princess Lima."><span>09</span><strong>Rescue Princess Lima</strong></a>
|
||||
<a href="/play/the-rain-index.html" data-kind="Artifact" data-desc="Handle impossible paper rooms, type forgotten words, and let a rainy archive file you back."><span>10</span><strong>The Rain Index</strong></a>
|
||||
<a href="/play/house.html" data-kind="Living archive" data-desc="Wander through a lived-in house where every room opens another part of the archive."><span>11</span><strong>The House of Pages</strong></a>
|
||||
</nav>
|
||||
|
||||
231
play/rpg.org
Executable file → Normal file
@@ -1,153 +1,112 @@
|
||||
#+TITLE: The Archive World
|
||||
#+OPTIONS: num:nil title:nil toc:nil
|
||||
#+TITLE: Rescue Princess Lima
|
||||
#+OPTIONS: num:nil title:nil toc:nil html-preamble:nil html-postamble:nil
|
||||
#+NO_SIDENOTES: t
|
||||
#+DATE: <2026-07-29 Wed>
|
||||
#+DATE: <2026-07-30 Thu>
|
||||
|
||||
#+BEGIN_EXPORT html
|
||||
<main class="play-root play-page archive-world" data-play-page="archive-world">
|
||||
<a class="play-back" href="/play/play.html">Back to Play</a>
|
||||
<!-- RPG-STANDALONE-START -->
|
||||
<main class="lima-rpg" data-princess-lima-rpg aria-label="Rescue Princess Lima role-playing game">
|
||||
<a class="lima-exit" href="/">Exit to Website</a>
|
||||
|
||||
<header class="play-page-head archive-world__heading">
|
||||
<p class="play-kicker">09 / Role Playing World</p>
|
||||
<h1>The Archive World</h1>
|
||||
<p>A complete story-forward RPG about names, memories, and the connections that give an archive meaning.</p>
|
||||
</header>
|
||||
|
||||
<section class="archive-world__shell" data-world-shell aria-labelledby="archive-world-title">
|
||||
<div class="archive-world__hud">
|
||||
<div class="rpg-hud__identity">
|
||||
<span>Traveler</span>
|
||||
<strong data-world-player>New arrival</strong>
|
||||
<small data-world-level>Lv 1</small>
|
||||
</div>
|
||||
<div class="rpg-hud__health">
|
||||
<span>Health <strong data-world-health-text>100 / 100</strong></span>
|
||||
<div class="rpg-meter" aria-hidden="true"><i data-world-health-bar style="width:100%"></i></div>
|
||||
</div>
|
||||
<div class="rpg-hud__sigils">
|
||||
<span>Archive Sigils</span>
|
||||
<strong data-world-sigil-count>0 / 8</strong>
|
||||
</div>
|
||||
<div class="rpg-hud__item">
|
||||
<span>Selected item</span>
|
||||
<strong data-world-selected-item>Ink Vial ×2</strong>
|
||||
</div>
|
||||
<div class="rpg-hud__actions">
|
||||
<button type="button" data-world-quests>Quests</button>
|
||||
<button type="button" data-world-inventory>Inventory</button>
|
||||
<button type="button" data-world-journal-open>Journal</button>
|
||||
<button type="button" data-world-sound>Sound: muted</button>
|
||||
<button type="button" data-world-fullscreen aria-pressed="false">Fullscreen</button>
|
||||
<button type="button" data-world-reset>Menu</button>
|
||||
</div>
|
||||
<div id="princess-lima-game" class="lima-rpg__game" data-lima-game tabindex="0" role="application"
|
||||
aria-label="Rescue Princess Lima. Move with WASD or arrow keys. Attack with Space. Interact with E or Enter. Use a healing tonic with Q. Pause with Escape.">
|
||||
</div>
|
||||
|
||||
<div class="rpg-objective" aria-live="polite">
|
||||
<span>Main objective</span>
|
||||
<strong data-world-objective>Speak with Gatekeeper Orin at the Town Gate.</strong>
|
||||
</div>
|
||||
<p class="archive-world__badge" data-world-badge hidden>Archive Cartographer · all eight landmarks discovered</p>
|
||||
|
||||
<h2 id="archive-world-title" class="visually-hidden">Archive World role-playing game</h2>
|
||||
<div class="archive-world__stage">
|
||||
<div
|
||||
id="archive-world-game"
|
||||
class="archive-world__game"
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-label="The Archive World. Move with arrow keys or WASD, attack with Space, interact with E or Enter, use an item with Q, and open the menu with Escape."
|
||||
>
|
||||
<p class="archive-world__loading">Opening the Archive Town gate…</p>
|
||||
</div>
|
||||
|
||||
<div class="rpg-boss" data-world-boss hidden aria-live="polite">
|
||||
<span data-world-boss-name>The Redactor</span>
|
||||
<div class="rpg-meter rpg-meter--boss"><i data-world-boss-bar style="width:100%"></i></div>
|
||||
</div>
|
||||
|
||||
<div class="archive-world__overlay" data-world-overlay hidden>
|
||||
<section class="archive-world__panel" data-world-panel role="dialog" aria-modal="true" aria-labelledby="archive-world-panel-title">
|
||||
<header>
|
||||
<div>
|
||||
<p class="play-kicker" data-world-panel-eyebrow>Archive</p>
|
||||
<h2 id="archive-world-panel-title" data-world-panel-title>Traveler’s folio</h2>
|
||||
</div>
|
||||
<button type="button" data-world-panel-close aria-label="Close panel">×</button>
|
||||
</header>
|
||||
<div class="archive-world__panel-body" data-world-panel-body></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="archive-world__controls" aria-label="Touch game controls">
|
||||
<div class="archive-world__dpad">
|
||||
<button type="button" data-world-move="up" aria-label="Move up">↑</button>
|
||||
<button type="button" data-world-move="left" aria-label="Move left">←</button>
|
||||
<button type="button" data-world-move="down" aria-label="Move down">↓</button>
|
||||
<button type="button" data-world-move="right" aria-label="Move right">→</button>
|
||||
</div>
|
||||
<div class="archive-world__action-pad">
|
||||
<button type="button" data-world-attack>Attack</button>
|
||||
<button type="button" data-world-interact>Explore</button>
|
||||
<button type="button" data-world-item>Item</button>
|
||||
<button type="button" data-world-menu>Menu</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="archive-world__prompt" data-world-prompt>Follow the paths, signs, and warm window light.</p>
|
||||
<p class="archive-world__status" data-world-status aria-live="polite">
|
||||
Choose your traveler to enter Archive Town.
|
||||
</p>
|
||||
<p class="archive-world__instructions">
|
||||
Move: WASD/arrows · Attack: Space · Explore: E/Enter · Use item: Q · Cycle item: Tab · Pause: Escape.
|
||||
Audio begins muted and remains optional.
|
||||
</p>
|
||||
<section class="lima-rpg__loading" data-lima-loading aria-live="polite">
|
||||
<strong>Preparing the road to Lima…</strong>
|
||||
<span>Loading local maps, characters, and sounds.</span>
|
||||
</section>
|
||||
|
||||
<dialog class="archive-world__dialog archive-world__setup" data-world-setup>
|
||||
<form data-world-setup-form>
|
||||
<p class="play-kicker">New traveler</p>
|
||||
<h2>Who has arrived?</h2>
|
||||
<p>The Town Gate is losing names. Yours has held long enough for the Living Bookmark to find you.</p>
|
||||
<label>
|
||||
Traveler name
|
||||
<input name="name" type="text" minlength="1" maxlength="20" autocomplete="nickname" required />
|
||||
</label>
|
||||
<section class="lima-main-menu" data-lima-menu hidden aria-labelledby="lima-game-title">
|
||||
<p class="lima-main-menu__eyebrow">A four-chapter fantasy adventure</p>
|
||||
<h1 id="lima-game-title">Rescue<br />Princess Lima</h1>
|
||||
<p class="lima-main-menu__tagline">The princess has been taken beyond the mountains. Help a wounded village, cross the wild roads, and enter the Fortress of Shadows.</p>
|
||||
<div class="lima-menu-stack">
|
||||
<button type="button" class="lima-primary" data-lima-new>New Game</button>
|
||||
<button type="button" data-lima-continue disabled>Continue</button>
|
||||
<button type="button" data-lima-credits>Credits</button>
|
||||
<a class="lima-button-link" href="/">Exit to Website</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lima-setup" data-lima-setup hidden aria-labelledby="lima-setup-title">
|
||||
<form data-lima-setup-form>
|
||||
<p class="lima-main-menu__eyebrow">New Game</p>
|
||||
<h2 id="lima-setup-title">Name the traveller</h2>
|
||||
<label>Traveller name <input type="text" name="name" minlength="1" maxlength="20" autocomplete="nickname" required /></label>
|
||||
<fieldset>
|
||||
<legend>Traveller coat</legend>
|
||||
<label><input type="radio" name="palette" value="brass" checked /> <span class="palette-swatch palette-swatch--brass"></span> Brass</label>
|
||||
<label><input type="radio" name="palette" value="moss" /> <span class="palette-swatch palette-swatch--moss"></span> Moss</label>
|
||||
<label><input type="radio" name="palette" value="berry" /> <span class="palette-swatch palette-swatch--berry"></span> Berry</label>
|
||||
<legend>Travel cloak</legend>
|
||||
<label><input type="radio" name="appearance" value="azure" checked /> Azure</label>
|
||||
<label><input type="radio" name="appearance" value="ember" /> Ember</label>
|
||||
<label><input type="radio" name="appearance" value="pine" /> Pine</label>
|
||||
</fieldset>
|
||||
<p class="archive-world__error" data-world-setup-error aria-live="polite"></p>
|
||||
<button type="submit">Enter the world</button>
|
||||
<p class="lima-setup__error" data-lima-setup-error aria-live="polite"></p>
|
||||
<button type="submit" class="lima-primary">Begin Chapter One</button>
|
||||
<button type="button" data-lima-setup-cancel>Back</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</section>
|
||||
|
||||
<details class="archive-world__directory">
|
||||
<summary>Plain directory of Archive Town</summary>
|
||||
<p>This directory remains usable if JavaScript, Canvas, Phaser, or optional game assets are unavailable.</p>
|
||||
<div class="archive-world__directory-grid">
|
||||
<section><h2>Town Gate</h2><a href="/">Home</a> · <a href="/recently-updated.html">Recently updated</a> · <a href="/home/contact.html">Contact</a></section>
|
||||
<section><h2>Grand Library</h2><a href="/posts/posts-list.html">Posts</a> · <a href="/home/categories.html">Categories</a> · <a href="/posts/posts-intro.html">Introduction</a></section>
|
||||
<section><h2>Guild Study</h2><a href="/posts/career/career-list.html">Career</a> · <a href="/home/status.html">Competency status</a> · <a href="/posts/career/probation-objectives.html">Objectives</a></section>
|
||||
<section><h2>Kitchen Inn</h2><a href="/blogs/blogs-list.html">Blogs</a> · <a href="/tags/review.html">Weekly reviews</a> · <a href="/blogs/blogs-intro.html">Introduction</a></section>
|
||||
<section><h2>Workshop</h2><a href="/home/services.html">Services</a> · <a href="/home/wird-tracker.html">Wird tracker</a> · <a href="/home/countdown.html">Countdowns</a> · <a href="/home/backlog.html">Backlog</a></section>
|
||||
<section><h2>Playroom</h2><a href="/play/play.html">Play</a> · <a href="/play/the-rain-index.html">Rain Index</a> · <a href="/play/house.html">House of Pages</a></section>
|
||||
<section><h2>Lima Museum</h2><a href="/lima/index.html">Lima</a> · <a href="/blogs/2025/2025-list.html">Older writing</a> · <a href="/play/memory.html">Memory Cabinet</a></section>
|
||||
<section><h2>Notes Garden</h2><a href="/home/notes.html">Notes wall</a> · <a href="/home/categories.html">Categories</a> · <a href="/recently-updated.html">Recently updated</a> · <a href="/sitemap.html">Sitemap</a></section>
|
||||
<section class="lima-hud" data-lima-hud hidden aria-label="Game status">
|
||||
<div><span>Traveller</span><strong data-lima-player>Traveller</strong><span data-lima-chapter>Chapter 1</span></div>
|
||||
<div><span>Health</span><strong data-lima-health>100 / 100</strong><div class="lima-hud__healthbar" aria-hidden="true"><i></i></div></div>
|
||||
<div class="lima-hud__objective"><span data-lima-region>Broken Village</span><strong data-lima-objective>Speak with Elder Corin.</strong></div>
|
||||
<div class="lima-hud__actions">
|
||||
<button type="button" data-lima-quests aria-label="Quest log">Quests</button>
|
||||
<button type="button" data-lima-inventory aria-label="Inventory" data-lima-tonics>Tonic ×2</button>
|
||||
<button type="button" data-lima-sound>Sound Muted</button>
|
||||
<button type="button" data-lima-fullscreen aria-pressed="false">Fullscreen</button>
|
||||
<button type="button" data-lima-pause>Menu</button>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<noscript><p class="archive-world__noscript">The RPG needs JavaScript, but every destination remains available in the plain directory above.</p></noscript>
|
||||
<section class="lima-boss" data-lima-boss hidden aria-live="polite">
|
||||
<strong data-lima-boss-name>Guardian</strong>
|
||||
<div class="lima-boss__bar" aria-hidden="true"><i></i></div>
|
||||
</section>
|
||||
|
||||
<div class="lima-status-stack" data-lima-status-stack hidden>
|
||||
<p class="lima-prompt" data-lima-prompt>Move with WASD or arrow keys.</p>
|
||||
<p class="lima-status" data-lima-status aria-live="polite">Audio begins muted. Press Sound to enable it.</p>
|
||||
</div>
|
||||
|
||||
<div class="lima-touch" data-lima-touch hidden aria-label="Touch controls">
|
||||
<div class="lima-dpad">
|
||||
<button type="button" data-lima-move="up" aria-label="Move up">↑</button>
|
||||
<button type="button" data-lima-move="left" aria-label="Move left">←</button>
|
||||
<button type="button" data-lima-move="down" aria-label="Move down">↓</button>
|
||||
<button type="button" data-lima-move="right" aria-label="Move right">→</button>
|
||||
</div>
|
||||
<div class="lima-actions">
|
||||
<button type="button" data-lima-attack>Attack</button>
|
||||
<button type="button" data-lima-interact>Interact</button>
|
||||
<button type="button" data-lima-item>Item</button>
|
||||
<button type="button" data-lima-pause>Menu</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lima-overlay" data-lima-overlay hidden>
|
||||
<section class="lima-panel" data-lima-panel role="dialog" aria-modal="true" aria-labelledby="lima-panel-title">
|
||||
<header>
|
||||
<div><p class="lima-panel__eyebrow">Rescue Princess Lima</p><h2 id="lima-panel-title" data-lima-panel-title>Menu</h2></div>
|
||||
<button type="button" data-lima-panel-close aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="lima-panel__body" data-lima-panel-body></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<section class="lima-rpg__loading"><strong>JavaScript is required for this game.</strong><a href="/">Exit to Website</a></section>
|
||||
</noscript>
|
||||
</main>
|
||||
|
||||
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=archive-world-2.1.2" defer></script>
|
||||
<script src="/assets/scripts/pages/archive-world-data.js?v=archive-world-2.1.2" defer></script>
|
||||
<script src="/assets/scripts/pages/archive-world-state.js?v=archive-world-2.1.2" defer></script>
|
||||
<script src="/assets/scripts/pages/archive-world-systems.js?v=archive-world-2.1.2" defer></script>
|
||||
<script src="/assets/scripts/pages/archive-world-audio.js?v=archive-world-2.1.2" defer></script>
|
||||
<script src="/assets/scripts/pages/archive-world-ui.js?v=archive-world-2.1.2" defer></script>
|
||||
<script src="/assets/scripts/pages/archive-world-scenes.js?v=archive-world-2.1.2" defer></script>
|
||||
<script src="/assets/scripts/pages/archive-world.js?v=archive-world-2.1.2" defer></script>
|
||||
<link rel="stylesheet" href="/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.0.1" />
|
||||
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=princess-lima-1.0.1" defer></script>
|
||||
<script src="/assets/scripts/pages/princess-lima-data.js?v=princess-lima-1.0.1" defer></script>
|
||||
<script src="/assets/scripts/pages/princess-lima-state.js?v=princess-lima-1.0.1" defer></script>
|
||||
<script src="/assets/scripts/pages/princess-lima-systems.js?v=princess-lima-1.0.1" defer></script>
|
||||
<script src="/assets/scripts/pages/princess-lima-audio.js?v=princess-lima-1.0.1" defer></script>
|
||||
<script src="/assets/scripts/pages/princess-lima-ui.js?v=princess-lima-1.0.1" defer></script>
|
||||
<script src="/assets/scripts/pages/princess-lima-scenes.js?v=princess-lima-1.0.1" defer></script>
|
||||
<script src="/assets/scripts/pages/princess-lima-game.js?v=princess-lima-1.0.1" defer></script>
|
||||
<!-- RPG-STANDALONE-END -->
|
||||
#+END_EXPORT
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
||||
|
||||
* Posts:
|
||||
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">30-07-2026 10:03</span>@@
|
||||
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">30-07-2026 10:18</span>@@
|
||||
- [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#+OPTIONS: toc:nil num:nil
|
||||
|
||||
* Recently Updated (top 26 files)
|
||||
- [[file:play/rpg.org][The Archive World]] @@html:<span class="post-date">2026-07-29 00:00</span>@@
|
||||
- [[file:play/rpg.org][Rescue Princess Lima]] @@html:<span class="post-date">2026-07-29 00:00</span>@@
|
||||
- [[file:play/house.org][The House of Pages]] @@html:<span class="post-date">2026-07-15 00:00</span>@@
|
||||
- [[file:blogs/2026/07-july/12-07-week-review.org][[12-07-2026] - Weekly Review]] @@html:<span class="post-date">2026-07-12 12:00</span>@@
|
||||
- [[file:home/wird-tracker.org][Wird Tracker]] @@html:<span class="post-date">2026-07-08 22:36</span>@@
|
||||
|
||||
16
sitemap.org
@@ -110,9 +110,9 @@ flowchart TD
|
||||
n43 --> n52
|
||||
n53["Tag: insights"]
|
||||
n43 --> n53
|
||||
n54["Tag: education"]
|
||||
n54["Tag: emacs"]
|
||||
n43 --> n54
|
||||
n55["Tag: emacs"]
|
||||
n55["Tag: education"]
|
||||
n43 --> n55
|
||||
n56["Tag: reading"]
|
||||
n43 --> n56
|
||||
@@ -142,7 +142,7 @@ flowchart TD
|
||||
n58 --> n68
|
||||
n69["The House of Pages"]
|
||||
n58 --> n69
|
||||
n70["The Archive World"]
|
||||
n70["Rescue Princess Lima"]
|
||||
n58 --> n70
|
||||
n71{{"posts"}}
|
||||
root --> n71
|
||||
@@ -227,8 +227,8 @@ flowchart TD
|
||||
click n51 "tags/life.html" "Tag: life"
|
||||
click n52 "tags/update.html" "Tag: update"
|
||||
click n53 "tags/insights.html" "Tag: insights"
|
||||
click n54 "tags/education.html" "Tag: education"
|
||||
click n55 "tags/emacs.html" "Tag: emacs"
|
||||
click n54 "tags/emacs.html" "Tag: emacs"
|
||||
click n55 "tags/education.html" "Tag: education"
|
||||
click n56 "tags/reading.html" "Tag: reading"
|
||||
click n57 "tags/maths.html" "Tag: maths"
|
||||
click n59 "play/sigil.html" "Sigil Press"
|
||||
@@ -242,7 +242,7 @@ flowchart TD
|
||||
click n67 "play/poem.html" "Marginalia Machine"
|
||||
click n68 "play/the-rain-index.html" "The Rain Index"
|
||||
click n69 "play/house.html" "The House of Pages"
|
||||
click n70 "play/rpg.html" "The Archive World"
|
||||
click n70 "play/rpg.html" "Rescue Princess Lima"
|
||||
click n72 "posts/posts-intro.html" "Posts Introduction"
|
||||
click n73 "posts/posts-list.html" "Posts List"
|
||||
click n75 "posts/career/solid-principles.html" "SOLID Principles"
|
||||
@@ -321,8 +321,8 @@ flowchart TD
|
||||
- [[file:tags/life.org][Tag: life]]
|
||||
- [[file:tags/update.org][Tag: update]]
|
||||
- [[file:tags/insights.org][Tag: insights]]
|
||||
- [[file:tags/education.org][Tag: education]]
|
||||
- [[file:tags/emacs.org][Tag: emacs]]
|
||||
- [[file:tags/education.org][Tag: education]]
|
||||
- [[file:tags/reading.org][Tag: reading]]
|
||||
- [[file:tags/maths.org][Tag: maths]]
|
||||
- play
|
||||
@@ -337,7 +337,7 @@ flowchart TD
|
||||
- [[file:play/poem.org][Marginalia Machine]]
|
||||
- [[file:play/the-rain-index.org][The Rain Index]]
|
||||
- [[file:play/house.org][The House of Pages]]
|
||||
- [[file:play/rpg.org][The Archive World]]
|
||||
- [[file:play/rpg.org][Rescue Princess Lima]]
|
||||
- posts
|
||||
- [[file:posts/posts-intro.org][Posts Introduction]]
|
||||
- [[file:posts/posts-list.org][Posts List]]
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const data = require("../assets/scripts/pages/archive-world-data.js");
|
||||
const state = require("../assets/scripts/pages/archive-world-state.js");
|
||||
|
||||
test("fresh v2 state applies safe defaults", () => {
|
||||
const fresh = state.fresh(" Ada ", "moss");
|
||||
assert.equal(fresh.version, 2);
|
||||
assert.equal(fresh.name, "Ada");
|
||||
assert.equal(fresh.palette, "moss");
|
||||
assert.equal(fresh.position.area, "town");
|
||||
assert.deepEqual(
|
||||
{ x: fresh.position.x, y: fresh.position.y },
|
||||
data.AREAS.town.safeSpawns.gate
|
||||
);
|
||||
assert.ok(fresh.position.y > data.AREAS.town.gates[0].y + data.AREAS.town.gates[0].height);
|
||||
assert.equal(fresh.health, 100);
|
||||
assert.equal(fresh.settings.soundEnabled, false);
|
||||
assert.deepEqual(fresh.sigils, []);
|
||||
assert.equal(fresh.inventory.find((item) => item.id === "ink_vial").quantity, 2);
|
||||
});
|
||||
|
||||
test("names and palettes are validated", () => {
|
||||
assert.equal(state.validateName(" Archive Guest "), "Archive Guest");
|
||||
assert.equal(state.validateName(""), null);
|
||||
assert.equal(state.validateName("x".repeat(21)), null);
|
||||
assert.equal(state.validatePalette("berry"), "berry");
|
||||
assert.equal(state.validatePalette("ultraviolet"), null);
|
||||
});
|
||||
|
||||
test("v1 data migrates identity, discoveries, position and sound", () => {
|
||||
const migrated = state.migrateV1({
|
||||
version: 1,
|
||||
name: "Mira",
|
||||
palette: "berry",
|
||||
discovered: ["gate", "gate", "museum", "missing"],
|
||||
complete: false,
|
||||
position: { x: 700, y: 850 },
|
||||
soundEnabled: true
|
||||
});
|
||||
assert.equal(migrated.version, 2);
|
||||
assert.deepEqual(migrated.discovered, ["gate", "museum"]);
|
||||
assert.equal(migrated.position.area, "town");
|
||||
assert.equal(migrated.position.x, 700);
|
||||
assert.equal(migrated.settings.soundEnabled, true);
|
||||
});
|
||||
|
||||
test("normalization deduplicates and filters restored collections", () => {
|
||||
const candidate = state.fresh("Zaine", "brass");
|
||||
candidate.discovered = ["gate", "gate", "missing"];
|
||||
candidate.sigils = ["garden", "garden", "bad"];
|
||||
candidate.inventory.push({ id: "ink_vial", quantity: 99 }, { id: "invalid", quantity: 2 });
|
||||
candidate.defeatedBosses = ["sentinel", "sentinel", "fake"];
|
||||
candidate.solvedPuzzles = ["gate", "gate", "fake"];
|
||||
const restored = state.normalize(candidate);
|
||||
assert.deepEqual(restored.discovered, ["gate"]);
|
||||
assert.deepEqual(restored.sigils, ["garden"]);
|
||||
assert.equal(restored.inventory.find((item) => item.id === "ink_vial").quantity, data.ITEMS.ink_vial.stack);
|
||||
assert.deepEqual(restored.defeatedBosses, ["sentinel"]);
|
||||
assert.deepEqual(restored.solvedPuzzles, ["gate"]);
|
||||
});
|
||||
|
||||
test("quest restoration accepts only known statuses and bounded counters", () => {
|
||||
const candidate = state.fresh("Ada", "brass");
|
||||
candidate.quests.gate = { status: "active", step: 200, count: -5 };
|
||||
candidate.quests.library = { status: "nonsense", step: 2, count: 3 };
|
||||
const restored = state.normalize(candidate);
|
||||
assert.deepEqual(restored.quests.gate, { status: "active", step: 20, count: 0 });
|
||||
assert.deepEqual(restored.quests.library, { status: "locked", step: 2, count: 3 });
|
||||
});
|
||||
|
||||
test("position, health, settings and unsupported schemas recover safely", () => {
|
||||
const candidate = state.fresh("Ada", "brass");
|
||||
candidate.position = { area: "missing", x: -100, y: 9000, facing: "downward" };
|
||||
candidate.health = 999;
|
||||
candidate.settings = { soundEnabled: true, ambience: 9, music: -2, effects: "bad", assist: true };
|
||||
const restored = state.normalize(candidate);
|
||||
assert.equal(restored.position.area, "town");
|
||||
assert.equal(restored.position.x, 24);
|
||||
assert.equal(restored.position.y, 1062);
|
||||
assert.equal(restored.position.facing, "north");
|
||||
assert.equal(restored.health, restored.maxHealth);
|
||||
assert.equal(restored.settings.ambience, 1);
|
||||
assert.equal(restored.settings.music, 0);
|
||||
assert.equal(restored.settings.effects, 0.65);
|
||||
assert.equal(state.parse("{"), null);
|
||||
assert.equal(state.parse(JSON.stringify({ version: 99 })), null);
|
||||
});
|
||||
|
||||
test("level thresholds and reset-ready fresh state are deterministic", () => {
|
||||
assert.equal(state.levelForXp(0), 1);
|
||||
assert.equal(state.levelForXp(80), 2);
|
||||
assert.equal(state.levelForXp(620), 5);
|
||||
assert.deepEqual(state.fresh("Mira", "berry").discovered, []);
|
||||
assert.equal(state.fresh("Mira", "berry").quests.gate.status, "locked");
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const data = require("../assets/scripts/pages/archive-world-data.js");
|
||||
const state = require("../assets/scripts/pages/archive-world-state.js");
|
||||
const systems = require("../assets/scripts/pages/archive-world-systems.js");
|
||||
|
||||
test("movement normalization prevents faster diagonals", () => {
|
||||
const straight = systems.normalizedVector(1, 0, 175);
|
||||
const diagonal = systems.normalizedVector(1, 1, 175);
|
||||
assert.equal(Math.round(Math.hypot(straight.x, straight.y)), 175);
|
||||
assert.equal(Math.round(Math.hypot(diagonal.x, diagonal.y)), 175);
|
||||
});
|
||||
|
||||
test("movement acceleration is frame-rate aware, responsive, and speed capped", () => {
|
||||
const movement = data.AREAS.town.movement;
|
||||
const first = systems.approachVelocity(0, 0, 1, 1, movement, 16, false);
|
||||
assert.ok(Math.hypot(first.x, first.y) > 0);
|
||||
assert.ok(Math.hypot(first.x, first.y) < movement.speed);
|
||||
|
||||
let velocity = { x: 0, y: 0 };
|
||||
for (let frame = 0; frame < 120; frame += 1) {
|
||||
velocity = systems.approachVelocity(velocity.x, velocity.y, 1, 1, movement, 16, false);
|
||||
}
|
||||
assert.equal(Math.round(Math.hypot(velocity.x, velocity.y)), movement.speed);
|
||||
|
||||
for (let frame = 0; frame < 20; frame += 1) {
|
||||
velocity = systems.approachVelocity(velocity.x, velocity.y, 0, 0, movement, 16, false);
|
||||
}
|
||||
assert.deepEqual(velocity, { x: 0, y: 0 });
|
||||
});
|
||||
|
||||
test("landmark discoveries deduplicate and complete at eight", () => {
|
||||
let current = state.fresh("Ada", "brass");
|
||||
current = systems.discover(current, "gate");
|
||||
current = systems.discover(current, "gate");
|
||||
assert.deepEqual(current.discovered, ["gate"]);
|
||||
data.LANDMARK_IDS.forEach((id) => { current = systems.discover(current, id); });
|
||||
assert.equal(current.complete, true);
|
||||
});
|
||||
|
||||
test("quest progression and completion grant each reward once", () => {
|
||||
let current = state.fresh("Ada", "brass");
|
||||
current = systems.startQuest(current, "gate");
|
||||
assert.equal(current.quests.gate.status, "active");
|
||||
current = systems.completeQuest(current, "gate");
|
||||
const xp = current.xp;
|
||||
assert.equal(current.quests.gate.status, "complete");
|
||||
assert.deepEqual(current.sigils, ["gate"]);
|
||||
assert.equal(current.inventory.some((item) => item.id === "lantern"), true);
|
||||
current = systems.completeQuest(current, "gate");
|
||||
assert.equal(current.xp, xp);
|
||||
assert.deepEqual(current.sigils, ["gate"]);
|
||||
});
|
||||
|
||||
test("inventory collection caps stacks and consumables heal", () => {
|
||||
let current = state.fresh("Ada", "brass");
|
||||
current = systems.addItem(current, "ink_vial", 30);
|
||||
assert.equal(systems.itemQuantity(current, "ink_vial"), 9);
|
||||
current.health = 30;
|
||||
const used = systems.consumeItem(current, "ink_vial");
|
||||
assert.equal(used.used, true);
|
||||
assert.equal(used.state.health, 65);
|
||||
assert.equal(systems.itemQuantity(used.state, "ink_vial"), 8);
|
||||
});
|
||||
|
||||
test("damage respects invulnerability and assist, defeat preserves progress", () => {
|
||||
let current = state.fresh("Ada", "brass");
|
||||
current.sigils = ["gate"];
|
||||
const assisted = state.normalize({ ...current, settings: { ...current.settings, assist: true } });
|
||||
const first = systems.takeDamage(assisted, 20, 2000, 0);
|
||||
assert.equal(first.state.health, 90);
|
||||
const ignored = systems.takeDamage(first.state, 20, 2200, 2000);
|
||||
assert.equal(ignored.hit, false);
|
||||
const defeated = systems.takeDamage({ ...first.state, health: 5, settings: { ...first.state.settings, assist: false } }, 20, 5000, 0);
|
||||
assert.equal(defeated.defeated, true);
|
||||
const respawned = systems.respawn(defeated.state);
|
||||
assert.deepEqual(respawned.sigils, ["gate"]);
|
||||
assert.equal(respawned.health >= 50, true);
|
||||
});
|
||||
|
||||
test("XP levels, boss completion and sigil story stage restore correctly", () => {
|
||||
let current = state.fresh("Ada", "brass");
|
||||
current = systems.grantXp(current, 200);
|
||||
assert.equal(current.level, 3);
|
||||
assert.equal(current.maxHealth, 120);
|
||||
current = systems.recordBoss(current, "sentinel");
|
||||
assert.equal(current.defeatedBosses.includes("sentinel"), true);
|
||||
assert.equal(current.quests.workshop.status, "complete");
|
||||
data.LANDMARK_IDS.forEach((id) => {
|
||||
if (!current.sigils.includes(id)) current = systems.completeQuest(current, id);
|
||||
});
|
||||
assert.equal(current.sigils.length, 8);
|
||||
assert.equal(current.story.stage, "vault");
|
||||
current = systems.recordBoss(current, "redactor");
|
||||
assert.equal(current.story.stage, "postgame");
|
||||
});
|
||||
|
||||
test("invalid collision positions resolve to a named safe spawn", () => {
|
||||
assert.equal(systems.isSafe("town", 200, 150), false);
|
||||
const safe = systems.nearestSafeSpawn("town", 200, 150);
|
||||
assert.equal(systems.isSafe(safe.area, safe.x, safe.y), true);
|
||||
assert.ok(["gate", "square", "library", "study", "inn", "workshop", "playroom", "museum", "garden"].includes(safe.spawn));
|
||||
});
|
||||
|
||||
test("locked-gate saves inside town recover to the outside opening spawn", () => {
|
||||
const current = state.fresh("Ada", "brass");
|
||||
current.position = { area: "town", spawn: "square", x: 724, y: 555, facing: "south" };
|
||||
const safe = systems.safeSpawnForState(current);
|
||||
assert.deepEqual(
|
||||
{ area: safe.area, spawn: safe.spawn, x: safe.x, y: safe.y },
|
||||
{ area: "town", spawn: "gate", ...data.AREAS.town.safeSpawns.gate }
|
||||
);
|
||||
|
||||
current.sigils.push("gate");
|
||||
const unlocked = systems.safeSpawnForState(current);
|
||||
assert.equal(unlocked.spawn, "saved");
|
||||
assert.equal(unlocked.y, 555);
|
||||
});
|
||||
|
||||
test("interaction ranges are type-specific and NPC conversations require close approach", () => {
|
||||
const npc = { type: "npc", id: "orin", x: 0, y: 0 };
|
||||
const landmark = { type: "landmark", id: "gate", x: 56, y: 0 };
|
||||
assert.equal(systems.interactionRadius(npc), 46);
|
||||
assert.equal(systems.nearestInteraction([npc], 47, 0), null);
|
||||
assert.equal(systems.nearestInteraction([npc], 45, 0), npc);
|
||||
assert.equal(systems.nearestInteraction([npc, landmark], 0, 0), npc);
|
||||
assert.equal(systems.nearestInteraction([landmark], 0, 0), landmark);
|
||||
});
|
||||
|
||||
test("all declared safe spawns and enemy homes are outside collision geometry", () => {
|
||||
Object.entries(data.AREAS).forEach(([areaId, area]) => {
|
||||
Object.entries(area.safeSpawns).forEach(([spawnId, spawn]) => {
|
||||
assert.equal(systems.isSafe(areaId, spawn.x, spawn.y), true, `${areaId}:${spawnId}`);
|
||||
});
|
||||
area.enemies.forEach((entry) => {
|
||||
const spawn = Array.isArray(entry)
|
||||
? { type: entry[0], x: entry[1], y: entry[2] }
|
||||
: entry;
|
||||
assert.equal(systems.isSafe(areaId, spawn.x, spawn.y), true, `${areaId}:${spawn.type}`);
|
||||
assert.equal(systems.isCombatSafe(areaId, spawn.x, spawn.y), false, `${areaId}:${spawn.type} is in a safe zone`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("town landmarks and NPC interaction points remain reachable", () => {
|
||||
Object.values(data.LANDMARKS).forEach((landmark) => {
|
||||
assert.equal(systems.isSafe("town", landmark.x, landmark.y), true, `landmark:${landmark.id}`);
|
||||
});
|
||||
data.AREAS.town.npcs.forEach(([npcId, x, y]) => {
|
||||
assert.equal(systems.isSafe("town", x, y), true, `npc:${npcId}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("town safe spawns do not place the player inside an NPC body", () => {
|
||||
Object.entries(data.AREAS.town.safeSpawns).forEach(([spawnId, spawn]) => {
|
||||
data.AREAS.town.npcs.forEach(([npcId, x, y]) => {
|
||||
assert.ok(Math.hypot(spawn.x - x, spawn.y - y) >= 30, `${spawnId} overlaps npc:${npcId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
tests/princess-lima-state.test.cjs
Normal file
@@ -0,0 +1,62 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const data = require("../assets/scripts/pages/princess-lima-data.js");
|
||||
const state = require("../assets/scripts/pages/princess-lima-state.js");
|
||||
|
||||
test("fresh save uses the Princess Lima schema and safe village start", () => {
|
||||
const fresh = state.fresh(" Rowan ", "pine");
|
||||
assert.equal(fresh.version, 1);
|
||||
assert.equal(state.STORAGE_KEY, "zxh_princess_lima_rpg_v1");
|
||||
assert.equal(fresh.player.name, "Rowan");
|
||||
assert.equal(fresh.player.appearance, "pine");
|
||||
assert.equal(fresh.region, "village");
|
||||
assert.deepEqual({ x: fresh.position.x, y: fresh.position.y }, data.MAPS.village.spawns.start);
|
||||
assert.equal(fresh.settings.soundEnabled, false);
|
||||
});
|
||||
|
||||
test("names and appearances validate", () => {
|
||||
assert.equal(state.validName(" A Traveller "), "A Traveller");
|
||||
assert.equal(state.validName(""), null);
|
||||
assert.equal(state.validName("x".repeat(21)), null);
|
||||
assert.equal(state.normalize({ ...state.fresh("A", "azure"), player: { name: "A", appearance: "bad" } }), null);
|
||||
});
|
||||
|
||||
test("valid saves restore bounded state and derived equipment", () => {
|
||||
const candidate = state.fresh("Mira", "azure");
|
||||
candidate.health = 999;
|
||||
candidate.inventory = [
|
||||
{ id: "tempered_sword", quantity: 1 },
|
||||
{ id: "healing_tonic", quantity: 99 },
|
||||
{ id: "bad", quantity: 4 }
|
||||
];
|
||||
candidate.solvedPuzzles = ["forest_stones", "forest_stones", "bad"];
|
||||
candidate.defeatedBosses = ["captain", "captain", "bad"];
|
||||
const restored = state.normalize(candidate);
|
||||
assert.equal(restored.health, restored.maxHealth);
|
||||
assert.equal(restored.attack, 2);
|
||||
assert.equal(restored.inventory.find((item) => item.id === "healing_tonic").quantity, 9);
|
||||
assert.deepEqual(restored.solvedPuzzles, ["forest_stones"]);
|
||||
assert.deepEqual(restored.defeatedBosses, ["captain"]);
|
||||
});
|
||||
|
||||
test("invalid and unsupported saves recover without throwing", () => {
|
||||
assert.equal(state.parse("{"), null);
|
||||
assert.equal(state.parse(JSON.stringify({ version: 99 })), null);
|
||||
assert.equal(state.parse(JSON.stringify({ version: 1 })), null);
|
||||
});
|
||||
|
||||
test("quest, route, puzzle, boss and settings restoration filters malformed data", () => {
|
||||
const candidate = state.fresh("Mira", "ember");
|
||||
candidate.quests.aftermath = { status: "active", count: 999, rewarded: true };
|
||||
candidate.quests.village_defence = { status: "nonsense", count: -10 };
|
||||
candidate.unlockedRoutes = ["guide_found", "guide_found", "fake"];
|
||||
candidate.settings = { master: 4, music: -2, effects: "bad", textSpeed: "instant", highContrast: true };
|
||||
const restored = state.normalize(candidate);
|
||||
assert.equal(restored.quests.aftermath.count, 99);
|
||||
assert.equal(restored.quests.village_defence.status, "locked");
|
||||
assert.deepEqual(restored.unlockedRoutes, ["guide_found"]);
|
||||
assert.equal(restored.settings.master, 1);
|
||||
assert.equal(restored.settings.music, 0);
|
||||
assert.equal(restored.settings.effects, 0.7);
|
||||
assert.equal(restored.settings.textSpeed, "instant");
|
||||
});
|
||||
104
tests/princess-lima-systems.test.cjs
Normal file
@@ -0,0 +1,104 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const data = require("../assets/scripts/pages/princess-lima-data.js");
|
||||
const state = require("../assets/scripts/pages/princess-lima-state.js");
|
||||
const systems = require("../assets/scripts/pages/princess-lima-systems.js");
|
||||
|
||||
test("movement is responsive and diagonal speed is normalized", () => {
|
||||
let velocity = { x: 0, y: 0 };
|
||||
for (let frame = 0; frame < 120; frame += 1) {
|
||||
velocity = systems.approachVelocity(velocity.x, velocity.y, 1, 1, 16, false);
|
||||
}
|
||||
assert.equal(Math.round(Math.hypot(velocity.x, velocity.y)), 176);
|
||||
for (let frame = 0; frame < 20; frame += 1) {
|
||||
velocity = systems.approachVelocity(velocity.x, velocity.y, 0, 0, 16, false);
|
||||
}
|
||||
assert.deepEqual(velocity, { x: 0, y: 0 });
|
||||
});
|
||||
|
||||
test("all named spawns are collision-safe and invalid positions recover", () => {
|
||||
const current = state.fresh("Ada", "azure");
|
||||
Object.entries(data.MAPS).forEach(([regionId, map]) => {
|
||||
Object.entries(map.spawns).forEach(([spawnId, spawn]) => {
|
||||
assert.equal(systems.isSafePosition(current, regionId, spawn.x, spawn.y), true, `${regionId}:${spawnId}`);
|
||||
});
|
||||
});
|
||||
const recovered = systems.nearestSafeSpawn(current, "village", 100, 100);
|
||||
assert.equal(systems.isSafePosition(current, recovered.region, recovered.x, recovered.y), true);
|
||||
});
|
||||
|
||||
test("quest progression grants rewards once and unlocks routes", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
current = systems.completeQuest(current, "aftermath");
|
||||
assert.equal(systems.quantity(current, "village_sword"), 1);
|
||||
current = systems.progressQuest(current, "village_defence", 3);
|
||||
assert.equal(current.quests.village_defence.status, "complete");
|
||||
assert.equal(current.unlockedRoutes.includes("village_defended"), true);
|
||||
const tonics = systems.quantity(current, "healing_tonic");
|
||||
current = systems.completeQuest(current, "village_defence");
|
||||
assert.equal(systems.quantity(current, "healing_tonic"), tonics);
|
||||
});
|
||||
|
||||
test("optional quests remain independent of chapter progression", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
current = systems.startQuest(current, "healer_herbs");
|
||||
current = systems.completeQuest(current, "healer_herbs");
|
||||
assert.equal(current.quests.healer_herbs.status, "complete");
|
||||
assert.equal(current.chapter, 1);
|
||||
});
|
||||
|
||||
test("inventory caps stacks, protects quest items and healing consumes safely", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
current = systems.addItem(current, "healing_tonic", 30);
|
||||
assert.equal(systems.quantity(current, "healing_tonic"), 9);
|
||||
current = systems.addItem(current, "prison_key", 1);
|
||||
assert.equal(systems.removeItem(current, "prison_key", 1).removed, false);
|
||||
current.health = 30;
|
||||
const result = systems.useItem(current, "healing_tonic");
|
||||
assert.equal(result.used, true);
|
||||
assert.equal(result.state.health, 70);
|
||||
assert.equal(systems.quantity(result.state, "healing_tonic"), 8);
|
||||
});
|
||||
|
||||
test("combat applies defence, invulnerability, defeat and checkpoint respawn", () => {
|
||||
let current = systems.addItem(state.fresh("Ada", "azure"), "buckler", 1);
|
||||
const hit = systems.damage(current, 10, 2000, 0);
|
||||
assert.equal(hit.amount, 8);
|
||||
assert.equal(hit.state.health, 92);
|
||||
assert.equal(systems.damage(hit.state, 10, 2100, 2000).hit, false);
|
||||
const defeated = systems.damage({ ...hit.state, health: 3 }, 20, 4000, 0);
|
||||
assert.equal(defeated.defeated, true);
|
||||
const respawned = systems.respawn(defeated.state);
|
||||
assert.equal(respawned.region, "village");
|
||||
assert.ok(respawned.health >= 50);
|
||||
});
|
||||
|
||||
test("puzzles unlock chapter routes and cannot reward twice", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
current = systems.solvePuzzle(current, "forest_stones");
|
||||
assert.equal(current.solvedPuzzles.includes("forest_stones"), true);
|
||||
assert.equal(current.unlockedRoutes.includes("guide_found"), true);
|
||||
const boots = systems.quantity(current, "trail_boots");
|
||||
current = systems.solvePuzzle(current, "forest_stones");
|
||||
assert.equal(systems.quantity(current, "trail_boots"), boots);
|
||||
});
|
||||
|
||||
test("boss phases and victories are deterministic", () => {
|
||||
assert.equal(systems.bossPhase(48, 48, 3), 1);
|
||||
assert.equal(systems.bossPhase(24, 48, 3), 2);
|
||||
assert.equal(systems.bossPhase(10, 48, 3), 3);
|
||||
let current = state.fresh("Ada", "azure");
|
||||
current = systems.recordBoss(current, "malrec");
|
||||
assert.equal(current.defeatedBosses.includes("malrec"), true);
|
||||
assert.equal(current.unlockedRoutes.includes("malrec_defeated"), true);
|
||||
assert.equal(current.story, "rescue");
|
||||
});
|
||||
|
||||
test("complete story path reaches the Princess Lima rescue state", () => {
|
||||
let current = state.fresh("Ada", "azure");
|
||||
["aftermath", "village_defence", "find_guide", "ruins_light", "wolf_miniboss", "repair_bridge", "stone_guardian", "free_scout", "free_prisoners", "break_wards", "defeat_malrec"]
|
||||
.forEach((id) => { current = systems.completeQuest(current, id); });
|
||||
assert.equal(current.chapter, 4);
|
||||
assert.equal(current.unlockedRoutes.includes("wards_broken"), true);
|
||||
assert.equal(current.unlockedRoutes.includes("malrec_defeated"), true);
|
||||
});
|
||||