diff --git a/README.md b/README.md index 78aeebd..0bd2d81 100755 --- a/README.md +++ b/README.md @@ -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. diff --git a/assets/audio/archive-world/ambient.ogg b/assets/audio/archive-world/ambient.ogg deleted file mode 100755 index cddef7d..0000000 Binary files a/assets/audio/archive-world/ambient.ogg and /dev/null differ diff --git a/assets/audio/archive-world/discover.wav b/assets/audio/archive-world/discover.wav deleted file mode 100755 index e9958c0..0000000 Binary files a/assets/audio/archive-world/discover.wav and /dev/null differ diff --git a/assets/audio/archive-world/portal.wav b/assets/audio/archive-world/portal.wav deleted file mode 100755 index 1bf1cff..0000000 Binary files a/assets/audio/archive-world/portal.wav and /dev/null differ diff --git a/assets/audio/archive-world/step.wav b/assets/audio/archive-world/step.wav deleted file mode 100755 index c64b62a..0000000 Binary files a/assets/audio/archive-world/step.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/attack.wav b/assets/audio/archive-world/v2/attack.wav deleted file mode 100755 index 1be00bc..0000000 Binary files a/assets/audio/archive-world/v2/attack.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/boss-attack.wav b/assets/audio/archive-world/v2/boss-attack.wav deleted file mode 100755 index f761703..0000000 Binary files a/assets/audio/archive-world/v2/boss-attack.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/damage.wav b/assets/audio/archive-world/v2/damage.wav deleted file mode 100755 index c72950d..0000000 Binary files a/assets/audio/archive-world/v2/damage.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/dialogue.wav b/assets/audio/archive-world/v2/dialogue.wav deleted file mode 100755 index 4a30c2e..0000000 Binary files a/assets/audio/archive-world/v2/dialogue.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/enemy-defeat.wav b/assets/audio/archive-world/v2/enemy-defeat.wav deleted file mode 100755 index 9d91b0f..0000000 Binary files a/assets/audio/archive-world/v2/enemy-defeat.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/item.wav b/assets/audio/archive-world/v2/item.wav deleted file mode 100755 index 745e651..0000000 Binary files a/assets/audio/archive-world/v2/item.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/portal.wav b/assets/audio/archive-world/v2/portal.wav deleted file mode 100755 index 819ceea..0000000 Binary files a/assets/audio/archive-world/v2/portal.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/puzzle.wav b/assets/audio/archive-world/v2/puzzle.wav deleted file mode 100755 index f188e0c..0000000 Binary files a/assets/audio/archive-world/v2/puzzle.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/quest.wav b/assets/audio/archive-world/v2/quest.wav deleted file mode 100755 index 8f9b164..0000000 Binary files a/assets/audio/archive-world/v2/quest.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/step.wav b/assets/audio/archive-world/v2/step.wav deleted file mode 100755 index 7e3d4c2..0000000 Binary files a/assets/audio/archive-world/v2/step.wav and /dev/null differ diff --git a/assets/audio/archive-world/v2/town-ambient.ogg b/assets/audio/archive-world/v2/town-ambient.ogg deleted file mode 100755 index 012b334..0000000 Binary files a/assets/audio/archive-world/v2/town-ambient.ogg and /dev/null differ diff --git a/assets/audio/archive-world/v2/vault-ambient.ogg b/assets/audio/archive-world/v2/vault-ambient.ogg deleted file mode 100755 index f4e2f02..0000000 Binary files a/assets/audio/archive-world/v2/vault-ambient.ogg and /dev/null differ diff --git a/assets/audio/archive-world/v2/victory.wav b/assets/audio/archive-world/v2/victory.wav deleted file mode 100755 index b09e887..0000000 Binary files a/assets/audio/archive-world/v2/victory.wav and /dev/null differ diff --git a/assets/audio/princess-lima/attack.wav b/assets/audio/princess-lima/attack.wav new file mode 100644 index 0000000..9390fc1 Binary files /dev/null and b/assets/audio/princess-lima/attack.wav differ diff --git a/assets/audio/princess-lima/boss-theme.wav b/assets/audio/princess-lima/boss-theme.wav new file mode 100644 index 0000000..047b621 Binary files /dev/null and b/assets/audio/princess-lima/boss-theme.wav differ diff --git a/assets/audio/princess-lima/damage.wav b/assets/audio/princess-lima/damage.wav new file mode 100644 index 0000000..434d417 Binary files /dev/null and b/assets/audio/princess-lima/damage.wav differ diff --git a/assets/audio/princess-lima/defeat.wav b/assets/audio/princess-lima/defeat.wav new file mode 100644 index 0000000..a940946 Binary files /dev/null and b/assets/audio/princess-lima/defeat.wav differ diff --git a/assets/audio/princess-lima/door.wav b/assets/audio/princess-lima/door.wav new file mode 100644 index 0000000..5b1772d Binary files /dev/null and b/assets/audio/princess-lima/door.wav differ diff --git a/assets/audio/princess-lima/forest-theme.wav b/assets/audio/princess-lima/forest-theme.wav new file mode 100644 index 0000000..941d901 Binary files /dev/null and b/assets/audio/princess-lima/forest-theme.wav differ diff --git a/assets/audio/princess-lima/fortress-theme.wav b/assets/audio/princess-lima/fortress-theme.wav new file mode 100644 index 0000000..d648305 Binary files /dev/null and b/assets/audio/princess-lima/fortress-theme.wav differ diff --git a/assets/audio/princess-lima/mountain-theme.wav b/assets/audio/princess-lima/mountain-theme.wav new file mode 100644 index 0000000..90877ac Binary files /dev/null and b/assets/audio/princess-lima/mountain-theme.wav differ diff --git a/assets/audio/princess-lima/pickup.wav b/assets/audio/princess-lima/pickup.wav new file mode 100644 index 0000000..ebddfe6 Binary files /dev/null and b/assets/audio/princess-lima/pickup.wav differ diff --git a/assets/audio/princess-lima/puzzle.wav b/assets/audio/princess-lima/puzzle.wav new file mode 100644 index 0000000..258a266 Binary files /dev/null and b/assets/audio/princess-lima/puzzle.wav differ diff --git a/assets/audio/princess-lima/quest.wav b/assets/audio/princess-lima/quest.wav new file mode 100644 index 0000000..b380183 Binary files /dev/null and b/assets/audio/princess-lima/quest.wav differ diff --git a/assets/audio/princess-lima/step.wav b/assets/audio/princess-lima/step.wav new file mode 100644 index 0000000..6085b30 Binary files /dev/null and b/assets/audio/princess-lima/step.wav differ diff --git a/assets/audio/princess-lima/victory-theme.wav b/assets/audio/princess-lima/victory-theme.wav new file mode 100644 index 0000000..ebfd306 Binary files /dev/null and b/assets/audio/princess-lima/victory-theme.wav differ diff --git a/assets/audio/princess-lima/victory.wav b/assets/audio/princess-lima/victory.wav new file mode 100644 index 0000000..8d3f96d Binary files /dev/null and b/assets/audio/princess-lima/victory.wav differ diff --git a/assets/audio/princess-lima/village-theme.wav b/assets/audio/princess-lima/village-theme.wav new file mode 100644 index 0000000..9e85f6e Binary files /dev/null and b/assets/audio/princess-lima/village-theme.wav differ diff --git a/assets/images/play/archive-world/archive-town.webp b/assets/images/play/archive-world/archive-town.webp deleted file mode 100755 index 57b5988..0000000 Binary files a/assets/images/play/archive-world/archive-town.webp and /dev/null differ diff --git a/assets/images/play/archive-world/traveler.png b/assets/images/play/archive-world/traveler.png deleted file mode 100755 index f488664..0000000 Binary files a/assets/images/play/archive-world/traveler.png and /dev/null differ diff --git a/assets/images/play/archive-world/v2/archive-town.webp b/assets/images/play/archive-world/v2/archive-town.webp deleted file mode 100755 index a86869a..0000000 Binary files a/assets/images/play/archive-world/v2/archive-town.webp and /dev/null differ diff --git a/assets/images/play/archive-world/v2/areas.webp b/assets/images/play/archive-world/v2/areas.webp deleted file mode 100755 index 5b2e58e..0000000 Binary files a/assets/images/play/archive-world/v2/areas.webp and /dev/null differ diff --git a/assets/images/play/archive-world/v2/cast-atlas.png b/assets/images/play/archive-world/v2/cast-atlas.png deleted file mode 100755 index edd2fdc..0000000 Binary files a/assets/images/play/archive-world/v2/cast-atlas.png and /dev/null differ diff --git a/assets/images/play/archive-world/v2/item-atlas.png b/assets/images/play/archive-world/v2/item-atlas.png deleted file mode 100755 index ae637fa..0000000 Binary files a/assets/images/play/archive-world/v2/item-atlas.png and /dev/null differ diff --git a/assets/images/play/archive-world/v2/traveler-berry.png b/assets/images/play/archive-world/v2/traveler-berry.png deleted file mode 100755 index a99f77c..0000000 Binary files a/assets/images/play/archive-world/v2/traveler-berry.png and /dev/null differ diff --git a/assets/images/play/archive-world/v2/traveler-brass.png b/assets/images/play/archive-world/v2/traveler-brass.png deleted file mode 100755 index 439ce20..0000000 Binary files a/assets/images/play/archive-world/v2/traveler-brass.png and /dev/null differ diff --git a/assets/images/play/archive-world/v2/traveler-moss.png b/assets/images/play/archive-world/v2/traveler-moss.png deleted file mode 100755 index 9feb21b..0000000 Binary files a/assets/images/play/archive-world/v2/traveler-moss.png and /dev/null differ diff --git a/assets/images/play/princess-lima/cast-atlas.png b/assets/images/play/princess-lima/cast-atlas.png new file mode 100644 index 0000000..e34094e Binary files /dev/null and b/assets/images/play/princess-lima/cast-atlas.png differ diff --git a/assets/images/play/princess-lima/title-landscape.png b/assets/images/play/princess-lima/title-landscape.png new file mode 100644 index 0000000..f9a2897 Binary files /dev/null and b/assets/images/play/princess-lima/title-landscape.png differ diff --git a/assets/scripts/pages/archive-world-audio.js b/assets/scripts/pages/archive-world-audio.js deleted file mode 100755 index 296548d..0000000 --- a/assets/scripts/pages/archive-world-audio.js +++ /dev/null @@ -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)); diff --git a/assets/scripts/pages/archive-world-data.js b/assets/scripts/pages/archive-world-data.js deleted file mode 100755 index 343666d..0000000 --- a/assets/scripts/pages/archive-world-data.js +++ /dev/null @@ -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)); diff --git a/assets/scripts/pages/archive-world-scenes.js b/assets/scripts/pages/archive-world-scenes.js deleted file mode 100755 index 688bc09..0000000 --- a/assets/scripts/pages/archive-world-scenes.js +++ /dev/null @@ -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)); diff --git a/assets/scripts/pages/archive-world-state.js b/assets/scripts/pages/archive-world-state.js deleted file mode 100755 index 8339506..0000000 --- a/assets/scripts/pages/archive-world-state.js +++ /dev/null @@ -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 - }); -})); diff --git a/assets/scripts/pages/archive-world-systems.js b/assets/scripts/pages/archive-world-systems.js deleted file mode 100755 index e95c938..0000000 --- a/assets/scripts/pages/archive-world-systems.js +++ /dev/null @@ -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 - }); -})); diff --git a/assets/scripts/pages/archive-world-ui.js b/assets/scripts/pages/archive-world-ui.js deleted file mode 100755 index 2529872..0000000 --- a/assets/scripts/pages/archive-world-ui.js +++ /dev/null @@ -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]) => `${label}`).join(""); - let action = ""; - if (questState.status === "locked") action = ``; - else if (questState.status === "active") { - action = quest.kind === "combat" - ? `
Combat progress: ${questState.count} / ${quest.target}
` - : id === "playroom" - ? `` - : quest.kind === "boss" - ? `` - : ``; - } else action = `✓ Quest complete · Archive Sigil restored
`; - - if (id === "library" && state.sigils.length === 8) { - action += state.defeatedBosses.includes("redactor") - ? `` - : ``; - } else if (place.area && questState.status === "complete") { - action += ``; - } - - show("landmark", place.title, place.eyebrow, ` - -${place.description}
-${npc.name}: ${npc.intro}-
E/Enter confirms · Escape returns to town
- `); - } - - 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 = ``; - } - show("dialogue", npc.name, "Conversation", `${npc.intro}${action}`); - } - - 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) => - ``).join(""); - show("puzzle", quest.title, "Archive puzzle", ` -
${quest.objective}
Choose the first symbol.
- -${npc.hint}
${npc.hint}
${Systems.currentObjective(state)}
`; - } else if (active === "inventory") { - const rows = state.inventory.map((entry) => { - const item = Data.ITEMS[entry.id]; - const use = item.type === "consumable" ? `` : ""; - return `The Redactor could not remove what you have already restored.
`); - } - - function openEnding(state) { - show("ending", "Context restored", "The Archive remembers", ` -The eight sigils do not destroy the Redactor. They give it back the connections it removed.
-“I was built to preserve everything,” it remembers. “I mistook emptiness for safety.”-
${state.name} closes the Living Bookmark. Across town, lamps relight, signs regain their names, and every unfinished story is allowed to remain unfinished.
- - `); - } - - function openReset() { - show("confirm", "Reset this traveler?", "Confirmation", `This clears both Archive World save versions on this device. It cannot be undone.
`); - } - - 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", `Nell asks whether an unaddressed letter should be opened, delivered to the Museum, or kept private.
- - - `); - } 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)); diff --git a/assets/scripts/pages/archive-world.js b/assets/scripts/pages/archive-world.js deleted file mode 100755 index 75be00f..0000000 --- a/assets/scripts/pages/archive-world.js +++ /dev/null @@ -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 - }); -}()); diff --git a/assets/scripts/pages/house.js b/assets/scripts/pages/house.js index 1d1a75d..0c46b00 100755 --- a/assets/scripts/pages/house.js +++ b/assets/scripts/pages/house.js @@ -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/"], diff --git a/assets/scripts/pages/princess-lima-audio.js b/assets/scripts/pages/princess-lima-audio.js new file mode 100644 index 0000000..ed6a74a --- /dev/null +++ b/assets/scripts/pages/princess-lima-audio.js @@ -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)); diff --git a/assets/scripts/pages/princess-lima-data.js b/assets/scripts/pages/princess-lima-data.js new file mode 100644 index 0000000..6c19059 --- /dev/null +++ b/assets/scripts/pages/princess-lima-data.js @@ -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 + }); +})); diff --git a/assets/scripts/pages/princess-lima-game.js b/assets/scripts/pages/princess-lima-game.js new file mode 100644 index 0000000..4c2337f --- /dev/null +++ b/assets/scripts/pages/princess-lima-game.js @@ -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", + "Designed and built for zainezq.com. Original fantasy artwork and locally generated audio. Powered by locally vendored Phaser.
" + )); + 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 = `The adventure could not start.${message || "A required local game file is unavailable."}Exit to Website`; + } + + Object.assign(controller, { + ready, travel, reloadRegion, persistPosition, setState, updateHud, status, prompt, boss, + openDialogue, openPanel, gameOver, openEnding, useTonic + }); +}()); diff --git a/assets/scripts/pages/princess-lima-scenes.js b/assets/scripts/pages/princess-lima-scenes.js new file mode 100644 index 0000000..4e8d50c --- /dev/null +++ b/assets/scripts/pages/princess-lima-scenes.js @@ -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)); diff --git a/assets/scripts/pages/princess-lima-state.js b/assets/scripts/pages/princess-lima-state.js new file mode 100644 index 0000000..c5a6113 --- /dev/null +++ b/assets/scripts/pages/princess-lima-state.js @@ -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 + }); +})); diff --git a/assets/scripts/pages/princess-lima-systems.js b/assets/scripts/pages/princess-lima-systems.js new file mode 100644 index 0000000..4d9685b --- /dev/null +++ b/assets/scripts/pages/princess-lima-systems.js @@ -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 + }); +})); diff --git a/assets/scripts/pages/princess-lima-ui.js b/assets/scripts/pages/princess-lima-ui.js new file mode 100644 index 0000000..ef2abfb --- /dev/null +++ b/assets/scripts/pages/princess-lima-ui.js @@ -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, ` +${escapeHtml(line)}
+${escapeHtml(item.description)}
${escapeHtml(quest.description)} · ${escapeHtml(quest.region)}
+${escapeHtml(Systems.currentObjective(state))}
${escapeHtml(Systems.currentObjective(state))}
+ + `); + 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?", ` +This permanently removes the Princess Lima save on this device.
+ + `, 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", ` +You awaken at the latest safe checkpoint with your quests and important items intact.
+ + `, false); + body.querySelector("[data-respawn]").addEventListener("click", actions.onRespawn, { once: true }); + } + + function ending(state) { + show("ending", "Princess Lima Rescued", ` +At dawn, the roads reopen. The villages ring their bells, the forest paths quiet, and the mountain fires become beacons instead of warnings.
+${escapeHtml(state.player.name)} is offered a place at the royal table—and chooses first to walk the repaired road home with Lima.
+The kingdom remains explorable from your final save.
+ + Exit to Website + `, 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)); diff --git a/assets/styles/pages/play.css b/assets/styles/pages/play.css index 81824a6..81e9aa3 100755 --- a/assets/styles/pages/play.css +++ b/assets/styles/pages/play.css @@ -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; - } -} diff --git a/assets/styles/pages/princess-lima-rpg.css b/assets/styles/pages/princess-lima-rpg.css new file mode 100644 index 0000000..3e15db1 --- /dev/null +++ b/assets/styles/pages/princess-lima-rpg.css @@ -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; + } +} diff --git a/build-logs/gitea-build-monitor.log b/build-logs/gitea-build-monitor.log index a045037..89e6894 100755 --- a/build-logs/gitea-build-monitor.log +++ b/build-logs/gitea-build-monitor.log @@ -784,3 +784,10 @@ at09 / Role Playing World
-A complete story-forward RPG about names, memories, and the connections that give an archive meaning.
-Archive Cartographer · all eight landmarks discovered
- -Opening the Archive Town gate…
-Follow the paths, signs, and warm window light.
-- Choose your traveler to enter Archive Town. -
-- Move: WASD/arrows · Attack: Space · Explore: E/Enter · Use item: Q · Cycle item: Tab · Pause: Escape. - Audio begins muted and remains optional. -
+This directory remains usable if JavaScript, Canvas, Phaser, or optional game assets are unavailable.
-Move with WASD or arrow keys.
+Audio begins muted. Press Sound to enable it.
+