diff --git a/assets/audio/archive-world/ambient.ogg b/assets/audio/archive-world/ambient.ogg old mode 100644 new mode 100755 diff --git a/assets/audio/archive-world/discover.wav b/assets/audio/archive-world/discover.wav old mode 100644 new mode 100755 diff --git a/assets/audio/archive-world/portal.wav b/assets/audio/archive-world/portal.wav old mode 100644 new mode 100755 diff --git a/assets/audio/archive-world/step.wav b/assets/audio/archive-world/step.wav old mode 100644 new mode 100755 diff --git a/assets/audio/archive-world/v2/attack.wav b/assets/audio/archive-world/v2/attack.wav new file mode 100644 index 0000000..1be00bc Binary files /dev/null and b/assets/audio/archive-world/v2/attack.wav differ diff --git a/assets/audio/archive-world/v2/boss-attack.wav b/assets/audio/archive-world/v2/boss-attack.wav new file mode 100644 index 0000000..f761703 Binary files /dev/null and b/assets/audio/archive-world/v2/boss-attack.wav differ diff --git a/assets/audio/archive-world/v2/damage.wav b/assets/audio/archive-world/v2/damage.wav new file mode 100644 index 0000000..c72950d Binary files /dev/null and b/assets/audio/archive-world/v2/damage.wav differ diff --git a/assets/audio/archive-world/v2/dialogue.wav b/assets/audio/archive-world/v2/dialogue.wav new file mode 100644 index 0000000..4a30c2e Binary files /dev/null and b/assets/audio/archive-world/v2/dialogue.wav differ diff --git a/assets/audio/archive-world/v2/enemy-defeat.wav b/assets/audio/archive-world/v2/enemy-defeat.wav new file mode 100644 index 0000000..9d91b0f Binary files /dev/null and b/assets/audio/archive-world/v2/enemy-defeat.wav differ diff --git a/assets/audio/archive-world/v2/item.wav b/assets/audio/archive-world/v2/item.wav new file mode 100644 index 0000000..745e651 Binary files /dev/null and b/assets/audio/archive-world/v2/item.wav differ diff --git a/assets/audio/archive-world/v2/portal.wav b/assets/audio/archive-world/v2/portal.wav new file mode 100644 index 0000000..819ceea Binary files /dev/null and b/assets/audio/archive-world/v2/portal.wav differ diff --git a/assets/audio/archive-world/v2/puzzle.wav b/assets/audio/archive-world/v2/puzzle.wav new file mode 100644 index 0000000..f188e0c Binary files /dev/null and b/assets/audio/archive-world/v2/puzzle.wav differ diff --git a/assets/audio/archive-world/v2/quest.wav b/assets/audio/archive-world/v2/quest.wav new file mode 100644 index 0000000..8f9b164 Binary files /dev/null and b/assets/audio/archive-world/v2/quest.wav differ diff --git a/assets/audio/archive-world/v2/step.wav b/assets/audio/archive-world/v2/step.wav new file mode 100644 index 0000000..7e3d4c2 Binary files /dev/null and b/assets/audio/archive-world/v2/step.wav differ diff --git a/assets/audio/archive-world/v2/town-ambient.ogg b/assets/audio/archive-world/v2/town-ambient.ogg new file mode 100644 index 0000000..012b334 Binary files /dev/null and b/assets/audio/archive-world/v2/town-ambient.ogg differ diff --git a/assets/audio/archive-world/v2/vault-ambient.ogg b/assets/audio/archive-world/v2/vault-ambient.ogg new file mode 100644 index 0000000..f4e2f02 Binary files /dev/null and b/assets/audio/archive-world/v2/vault-ambient.ogg differ diff --git a/assets/audio/archive-world/v2/victory.wav b/assets/audio/archive-world/v2/victory.wav new file mode 100644 index 0000000..b09e887 Binary files /dev/null and b/assets/audio/archive-world/v2/victory.wav differ diff --git a/assets/images/play/archive-world/archive-town.webp b/assets/images/play/archive-world/archive-town.webp old mode 100644 new mode 100755 diff --git a/assets/images/play/archive-world/traveler.png b/assets/images/play/archive-world/traveler.png old mode 100644 new mode 100755 diff --git a/assets/images/play/archive-world/v2/archive-town.webp b/assets/images/play/archive-world/v2/archive-town.webp new file mode 100644 index 0000000..a86869a Binary files /dev/null and b/assets/images/play/archive-world/v2/archive-town.webp differ diff --git a/assets/images/play/archive-world/v2/areas.webp b/assets/images/play/archive-world/v2/areas.webp new file mode 100644 index 0000000..5b2e58e Binary files /dev/null and b/assets/images/play/archive-world/v2/areas.webp differ diff --git a/assets/images/play/archive-world/v2/cast-atlas.png b/assets/images/play/archive-world/v2/cast-atlas.png new file mode 100644 index 0000000..edd2fdc Binary files /dev/null and b/assets/images/play/archive-world/v2/cast-atlas.png differ diff --git a/assets/images/play/archive-world/v2/item-atlas.png b/assets/images/play/archive-world/v2/item-atlas.png new file mode 100644 index 0000000..ae637fa Binary files /dev/null and b/assets/images/play/archive-world/v2/item-atlas.png differ diff --git a/assets/images/play/archive-world/v2/traveler-berry.png b/assets/images/play/archive-world/v2/traveler-berry.png new file mode 100644 index 0000000..a99f77c Binary files /dev/null and b/assets/images/play/archive-world/v2/traveler-berry.png differ diff --git a/assets/images/play/archive-world/v2/traveler-brass.png b/assets/images/play/archive-world/v2/traveler-brass.png new file mode 100644 index 0000000..439ce20 Binary files /dev/null and b/assets/images/play/archive-world/v2/traveler-brass.png differ diff --git a/assets/images/play/archive-world/v2/traveler-moss.png b/assets/images/play/archive-world/v2/traveler-moss.png new file mode 100644 index 0000000..9feb21b Binary files /dev/null and b/assets/images/play/archive-world/v2/traveler-moss.png differ diff --git a/assets/scripts/pages/archive-world-audio.js b/assets/scripts/pages/archive-world-audio.js new file mode 100644 index 0000000..296548d --- /dev/null +++ b/assets/scripts/pages/archive-world-audio.js @@ -0,0 +1,69 @@ +(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 new file mode 100644 index 0000000..fd99dcc --- /dev/null +++ b/assets/scripts/pages/archive-world-data.js @@ -0,0 +1,189 @@ +(function (root) { + "use strict"; + + function rect(x, y, width, height, depth) { + return Object.freeze({ x, y, width, height, depth: depth || 2 }); + } + + 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, 280, "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, 250, "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, 285, "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, 545, "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, 805, "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, 805, "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: 965 }, + safeSpawns: Object.freeze({ + gate: { x: 724, y: 965 }, square: { x: 724, y: 555 }, library: { x: 270, y: 350 }, + study: { x: 720, y: 330 }, inn: { x: 1110, y: 350 }, workshop: { x: 1060, y: 550 }, + playroom: { x: 1060, y: 805 }, museum: { x: 330, y: 805 }, garden: { x: 330, y: 545 } + }), + collisions: Object.freeze([ + rect(0, 0, 1448, 58), rect(0, 0, 58, 1086), rect(1390, 0, 58, 1086), + rect(0, 930, 645, 156), rect(803, 930, 645, 156), + rect(125, 68, 330, 230, 7), rect(580, 70, 285, 210, 7), rect(960, 70, 355, 230, 7), + rect(1010, 420, 335, 210, 7), rect(1000, 690, 345, 225, 7), + rect(105, 685, 340, 230, 7), rect(85, 390, 350, 225, 7), + rect(470, 75, 95, 350), rect(885, 75, 88, 350), + rect(465, 615, 100, 275), rect(885, 615, 98, 275) + ]), + npcs: Object.freeze([ + ["orin", 724, 875], ["sera", 270, 335], ["vale", 720, 315], ["mara", 1110, 335], + ["pell", 1045, 545], ["pip", 1045, 800], ["lima", 335, 800], ["rowan", 335, 545], + ["nell", 760, 720], ["otho", 675, 520], ["remy", 790, 585] + ]), + enemies: Object.freeze([ + ["ink_blot", 510, 490], ["lost_footnote", 930, 640], ["null_scribe", 520, 780], + ["ink_blot", 1320, 380], ["lost_footnote", 120, 640] + ]) + }), + vault: Object.freeze({ + width: 724, height: 543, background: "areas", frame: 0, spawn: { x: 362, y: 485 }, + safeSpawns: Object.freeze({ entrance: { x: 362, y: 485 } }), + collisions: Object.freeze([rect(0, 0, 724, 55), rect(0, 0, 45, 543), rect(679, 0, 45, 543), rect(0, 500, 300, 43), rect(424, 500, 300, 43)]), + npcs: Object.freeze([]), enemies: Object.freeze([]) + }), + workshop: Object.freeze({ + width: 724, height: 543, background: "areas", frame: 1, spawn: { x: 362, y: 475 }, + safeSpawns: Object.freeze({ entrance: { x: 362, y: 475 } }), + collisions: Object.freeze([rect(0, 0, 724, 48), rect(0, 0, 42, 543), rect(682, 0, 42, 543), rect(0, 500, 300, 43), rect(424, 500, 300, 43), rect(50, 50, 220, 105), rect(470, 70, 190, 95)]), + npcs: Object.freeze([]), enemies: Object.freeze([["sentinel", 365, 210]]) + }), + playroom: Object.freeze({ + width: 724, height: 543, background: "areas", frame: 2, spawn: { x: 362, y: 485 }, + safeSpawns: Object.freeze({ entrance: { x: 362, y: 485 } }), + collisions: Object.freeze([ + rect(0, 0, 724, 45), rect(0, 0, 42, 543), rect(682, 0, 42, 543), rect(0, 500, 300, 43), rect(424, 500, 300, 43), + rect(100, 95, 210, 42), rect(405, 95, 210, 42), rect(200, 190, 320, 42), rect(90, 285, 220, 42), rect(410, 285, 220, 42), rect(220, 380, 285, 42) + ]), + npcs: Object.freeze([]), enemies: Object.freeze([["lost_footnote", 120, 180], ["ink_blot", 590, 360]]) + }), + boss: Object.freeze({ + width: 724, height: 543, background: "areas", frame: 3, spawn: { x: 362, y: 470 }, + safeSpawns: Object.freeze({ entrance: { x: 362, y: 470 } }), + collisions: Object.freeze([rect(0, 0, 724, 35), rect(0, 0, 35, 543), rect(689, 0, 35, 543), rect(0, 508, 285, 35), rect(439, 508, 285, 35)]), + npcs: Object.freeze([]), enemies: Object.freeze([["redactor", 362, 190]]) + }) + }); + + 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 new file mode 100644 index 0000000..34ddffd --- /dev/null +++ b/assets/scripts/pages/archive-world-scenes.js @@ -0,0 +1,546 @@ +(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; + } + + 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); + 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.createAnimations(); + this.npcSprites = []; + 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(900, 900).setMaxVelocity(190, 190); + this.physics.add.collider(this.player, this.collisions); + + this.interactables = []; + this.markers = []; + this.createWorldObjects(); + 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.startFollow(this.player, true, + controller.reducedMotion() ? 1 : 0.14, controller.reducedMotion() ? 1 : 0.14); + 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.add.sprite(x, y, "cast", Data.NPCS[id].frame).setScale(0.23).setDepth(y + 60); + npc.setData("interaction", { type: "npc", id, x, y }); + this.npcSprites.push(npc); + this.interactables.push(npc.getData("interaction")); + if (!controller.reducedMotion()) this.tweens.add({ targets: npc, y: y - 2, duration: 1200 + Data.NPCS[id].frame * 30, yoyo: true, repeat: -1 }); + }); + this.addChest("town-garden", 115, 550, "memory_fragment", 2); + this.addChest("town-wall", 1280, 870, "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, 95, 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: 110 }); + this.add.circle(this.area.width / 2, 110, 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.area.enemies.forEach(([type, x, y]) => { + if ((type === "sentinel" || type === "redactor") && controller.getState().defeatedBosses.includes(type)) return; + this.spawnEnemy(type, x, y); + }); + } + + spawnEnemy(type, x, y) { + 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, 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.setAcceleration(0).setVelocity(0); + this.player.anims.stop(); + return; + } + 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(); + const acceleration = state.equipment.boots ? 980 : 760; + if (moving) { + const vector = Systems.normalizedVector(dx, dy, acceleration); + this.player.setAcceleration(vector.x, vector.y); + 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); + if (state.settings.soundEnabled && time - this.lastStepAt > 350) { + controller.audio.play("step", { rate: 0.96 + Math.random() * 0.08 }); + this.lastStepAt = time; + } + } else { + this.player.setAcceleration(0); + this.player.anims.stop(); + this.player.setFrame(frameFor(this.lastFacing)); + } + this.player.setDepth(this.player.y + 60); + this.npcSprites.forEach((npc) => npc.setFlipX(this.player.x < npc.x)); + 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() { + let nearest = null; + let best = Infinity; + this.interactables.forEach((item) => { + const distance = Phaser.Math.Distance.Between(this.player.x, this.player.y, item.x, item.y); + if (distance < best) { best = distance; nearest = item; } + }); + this.nearest = best < 92 ? nearest : null; + 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 (state.quests.study.status === "active" && this.areaId === "town") { + 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 distance = Phaser.Math.Distance.Between(enemy.x, enemy.y, this.player.x, this.player.y); + if (spec.behaviour === "chase") { + if (distance < 280) this.physics.moveToObject(enemy, this.player, spec.speed); + else enemy.setVelocity(0); + } else if (spec.behaviour === "wander") { + if (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 (spec.behaviour === "ranged") { + if (distance < 340 && 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 enemy.setVelocity(0); + } 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); + }); + } + + 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 (this.areaId === "town" && isTownSafe(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.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 || this.areaId === "town") { + const safe = Systems.nearestSafeSpawn(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()); + } + + function isTownSafe(x, y) { + return y > 835 || (x > 570 && x < 875 && y > 405 && y < 690); + } + + 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 index e326bd8..da4c037 100644 --- a/assets/scripts/pages/archive-world-state.js +++ b/assets/scripts/pages/archive-world-state.js @@ -1,26 +1,24 @@ (function (root, factory) { "use strict"; - - const api = factory(); + 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 () { +}(typeof globalThis !== "undefined" ? globalThis : this, function (Data) { "use strict"; - const VERSION = 1; - const STORAGE_KEY = "zxh_archive_world_v1"; + 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 LANDMARK_IDS = Object.freeze([ - "gate", - "library", - "study", - "inn", - "workshop", - "playroom", - "museum", - "garden" - ]); - const SPAWN = Object.freeze({ x: 640, y: 830 }); + const FACES = Object.freeze(["north", "south", "east", "west"]); + const SPAWN = Object.freeze({ area: "town", spawn: "gate", x: 724, y: 965 }); + 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, " "); @@ -31,21 +29,92 @@ 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, - position: { x: SPAWN.x, y: SPAWN.y }, - soundEnabled: 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 clamp(value, min, max, fallback) { - const number = Number(value); - return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; + 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) { @@ -54,61 +123,115 @@ const palette = validatePalette(candidate.palette); if (!name || !palette) return null; - const discovered = Array.isArray(candidate.discovered) - ? Array.from(new Set(candidate.discovered.filter((id) => LANDMARK_IDS.includes(id)))) - : []; + 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)), - position: { - x: clamp(candidate.position && candidate.position.x, 36, 1244, SPAWN.x), - y: clamp(candidate.position && candidate.position.y, 36, 924, SPAWN.y) + 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 }, - soundEnabled: candidate.soundEnabled === 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 { - return normalize(JSON.parse(raw)); + const candidate = JSON.parse(raw); + return candidate.version === 1 ? migrateV1(candidate) : normalize(candidate); } catch (_error) { return null; } } - function discover(state, landmarkId) { + 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 || !LANDMARK_IDS.includes(landmarkId)) return current; - if (!current.discovered.includes(landmarkId)) current.discovered.push(landmarkId); - current.complete = LANDMARK_IDS.every((id) => current.discovered.includes(id)); + if (!current) return null; + current.position = normalizePosition({ area, x, y, facing, spawn }, current.position); return current; } - function withPosition(state, x, y) { + function withCheckpoint(state, area, spawn, x, y) { const current = normalize(state); if (!current) return null; - current.position.x = clamp(x, 36, 1244, SPAWN.x); - current.position.y = clamp(y, 36, 924, SPAWN.y); + 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, - PALETTES, - LANDMARK_IDS, - SPAWN, - validateName, - validatePalette, - fresh, - normalize, - parse, - discover, - withPosition + 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 new file mode 100644 index 0000000..6fb6fd9 --- /dev/null +++ b/assets/scripts/pages/archive-world-systems.js @@ -0,0 +1,192 @@ +(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 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 nearestSafeSpawn(areaId, x, y) { + const area = Data.AREAS[areaId] || Data.AREAS.town; + const points = Object.entries(area.safeSpawns || { entrance: area.spawn }); + const safeCurrent = isSafe(areaId, x, y); + if (safeCurrent) return { area: areaId, spawn: "saved", x, y }; + 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, discover, startQuest, progressQuest, completeQuest, updateStory, addItem, itemQuantity, + consumeItem, grantXp, takeDamage, respawn, recordBoss, isSafe, nearestSafeSpawn, currentObjective + }); +})); diff --git a/assets/scripts/pages/archive-world-ui.js b/assets/scripts/pages/archive-world-ui.js new file mode 100644 index 0000000..b99fe49 --- /dev/null +++ b/assets/scripts/pages/archive-world-ui.js @@ -0,0 +1,296 @@ +(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}
` + : 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 index 94f2347..85dc8b2 100644 --- a/assets/scripts/pages/archive-world.js +++ b/assets/scripts/pages/archive-world.js @@ -1,420 +1,401 @@ (function () { "use strict"; + const Data = window.ArchiveWorldData; const State = window.ArchiveWorldState; - const LANDMARKS = Object.freeze({ - gate: landmark("Town Gate", "Arrivals", 640, 830, - "The old gate remembers every arrival. Begin at the dashboard or follow the newest paths.", - [["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"]]), - library: landmark("Grand Library", "Knowledge", 260, 285, - "Long-lived explanations, categories, and learning notes fill the blue-roofed library.", - [["Posts", "/posts/posts-list.html"], ["Categories", "/home/categories.html"], ["Posts introduction", "/posts/posts-intro.html"]]), - study: landmark("Guild Study", "Work", 640, 250, - "Engineering notes, professional lessons, and active competencies cover every desk.", - [["Career", "/posts/career/career-list.html"], ["Competency status", "/home/status.html"], ["Probation objectives", "/posts/career/probation-objectives.html"]]), - inn: landmark("Kitchen Inn", "Daily life", 1005, 270, - "Weekly reviews and ordinary days stay warm beside the inn's oven.", - [["Blogs", "/blogs/blogs-list.html"], ["Weekly reviews", "/tags/review.html"], ["Blogs introduction", "/blogs/blogs-intro.html"]]), - workshop: landmark("Workshop", "Living systems", 1030, 535, - "Trackers, services, plans, and practical machinery keep the town moving.", - [["Services", "/home/services.html"], ["Wird tracker", "/home/wird-tracker.html"], ["Countdowns", "/home/countdown.html"], ["Backlog", "/home/backlog.html"]]), - playroom: landmark("Playroom", "Experiments", 1005, 765, - "Games, generators, and stranger little mechanisms glow behind the bright windows.", - [["Play hub", "/play/play.html"], ["The Rain Index", "/play/the-rain-index.html"], ["House of Pages", "/play/house.html"]]), - museum: landmark("Lima Museum", "Keepsakes", 275, 765, - "Personal fragments, older writing, and small memories rest beneath the glass roof.", - [["Lima archive", "/lima/index.html"], ["Older writing", "/blogs/2025/2025-list.html"], ["Memory Cabinet", "/play/memory.html"]]), - garden: landmark("Notes Garden", "Connections", 220, 515, - "Loose notes, paths between topics, and recently tended pages grow beyond the trellis.", - [["Notes wall", "/home/notes.html"], ["Categories", "/home/categories.html"], ["Recently updated", "/recently-updated.html"], ["Sitemap", "/sitemap.html"]]) - }); - - const PALETTE_TINTS = Object.freeze({ - brass: 0xf1c46f, - moss: 0x91c788, - berry: 0xd894ad - }); + 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 }, - reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches, - saveTimer: 0 + 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 }; - function landmark(title, eyebrow, x, y, description, links) { - return Object.freeze({ title, eyebrow, x, y, description, links: Object.freeze(links) }); - } - document.addEventListener("DOMContentLoaded", init); function init() { const root = document.querySelector('.archive-world[data-play-page="archive-world"]'); - if (!root || !State) return; + 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 stored = State.parse(localStorage.getItem(State.STORAGE_KEY)); - controller.state = stored; - bindInterface(root); - - if (!window.Phaser) { - setStatus(root, "The world engine could not load. Every destination remains available in the plain directory."); - return; - } - - if (stored) { - startGame(root); + const loaded = loadState(); + if (loaded.state) { + controller.state = loaded.state; + renderHud(); + startGame(); + if (loaded.message) status(loaded.message); } else { - openSetup(root); + openSetup(loaded.message); } } - function bindInterface(root) { - const setup = root.querySelector("[data-world-setup]"); - const setupForm = root.querySelector("[data-world-setup-form]"); - const preview = root.querySelector("[data-world-preview]"); - const journal = root.querySelector("[data-world-journal]"); + 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 }; + } - setup.addEventListener("cancel", (event) => event.preventDefault()); - setupForm.addEventListener("submit", (event) => { + function ensureSafePosition(state) { + if (state.health <= 0) state = Systems.respawn(state); + const safe = Systems.nearestSafeSpawn(state.position.area, state.position.x, state.position.y); + 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]"); + dialog.addEventListener("cancel", (event) => event.preventDefault()); + form.addEventListener("submit", (event) => { event.preventDefault(); - const data = new FormData(setupForm); - const name = State.validateName(data.get("name")); - const palette = State.validatePalette(data.get("palette")); - const error = root.querySelector("[data-world-setup-error]"); + 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); - persist(); - setup.close(); - startGame(root); - }); - - root.querySelector("[data-world-preview-close]").addEventListener("click", () => preview.close()); - preview.addEventListener("close", () => { - controller.lock = false; - root.querySelector("#archive-world-game").focus(); - }); - - root.querySelector("[data-world-journal-open]").addEventListener("click", () => { - renderJournal(root); - controller.lock = true; - journal.showModal(); - }); - root.querySelector("[data-world-journal-close]").addEventListener("click", () => journal.close()); - journal.addEventListener("close", () => { - controller.lock = false; - root.querySelector("[data-world-journal-open]").focus(); - }); - - root.querySelector("[data-world-sound]").addEventListener("click", () => toggleSound(root)); - root.querySelector("[data-world-reset]").addEventListener("click", () => { - if (!window.confirm("Reset your traveler, discoveries, and saved position?")) return; - localStorage.removeItem(State.STORAGE_KEY); - window.location.reload(); - }); - - root.querySelectorAll("[data-world-move]").forEach((button) => { - const direction = button.dataset.worldMove; - const down = (event) => { - event.preventDefault(); - controller.move[direction] = true; - }; - const up = (event) => { - event.preventDefault(); - controller.move[direction] = false; - }; - button.addEventListener("pointerdown", down); - button.addEventListener("pointerup", up); - button.addEventListener("pointercancel", up); - button.addEventListener("pointerleave", up); - }); - root.querySelector("[data-world-interact]").addEventListener("click", () => { - if (controller.scene) controller.scene.interact(); - }); - - window.addEventListener("pagehide", savePosition); - document.addEventListener("visibilitychange", () => { - if (document.hidden) savePosition(); + persistState(controller.state); + dialog.close(); + renderHud(); + startGame(); }); } - function openSetup(root) { - const dialog = root.querySelector("[data-world-setup]"); + function openSetup(message) { + const dialog = controller.root.querySelector("[data-world-setup]"); + 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 startGame(root) { - renderHud(root); - const game = new Phaser.Game({ - type: Phaser.AUTO, + 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)); + }); + controller.root.querySelector("[data-world-interact]").addEventListener("click", () => { + if (controller.scene) controller.scene.interact(); + }); + controller.root.querySelector("[data-world-attack]").addEventListener("click", () => { + if (controller.scene) controller.scene.attack(controller.scene.time.now); + }); + controller.root.querySelector("[data-world-item]").addEventListener("click", useSelectedItem); + controller.root.querySelector("[data-world-menu]").addEventListener("click", openMenu); + controller.root.querySelector("[data-world-quests]").addEventListener("click", () => controller.ui.openMenu(controller.state, "quests")); + controller.root.querySelector("[data-world-inventory]").addEventListener("click", () => controller.ui.openMenu(controller.state, "inventory")); + controller.root.querySelector("[data-world-journal-open]").addEventListener("click", () => controller.ui.openMenu(controller.state, "journal")); + controller.root.querySelector("[data-world-sound]").addEventListener("click", toggleSound); + controller.root.querySelector("[data-world-fullscreen]").addEventListener("click", toggleFullscreen); + controller.root.querySelector("[data-world-reset]").addEventListener("click", () => 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: "#17251e", + backgroundColor: "#101b18", pixelArt: true, roundPixels: true, - physics: { - default: "arcade", - arcade: { debug: false } - }, - scale: { - mode: Phaser.Scale.FIT, - autoCenter: Phaser.Scale.CENTER_BOTH - }, - scene: ArchiveTownScene, - input: { - keyboard: true, - mouse: true, - touch: 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 } }); - root.dataset.gameReady = "true"; - root.querySelector("#archive-world-game").focus(); - window.archiveWorldGame = game; + controller.root.dataset.gameReady = "true"; + controller.root.querySelector("#archive-world-game").focus(); + window.archiveWorldGame = controller.game; } - class ArchiveTownScene extends Phaser.Scene { - constructor() { - super("ArchiveTown"); - this.nearest = null; - this.lastStepAt = 0; - this.wasMoving = false; + 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"); + } - preload() { - this.load.image("archive-town", "/assets/images/play/archive-world/archive-town.webp"); - this.load.image("traveler", "/assets/images/play/archive-world/traveler.png"); - this.load.audio("ambient", "/assets/audio/archive-world/ambient.ogg"); - this.load.audio("step", "/assets/audio/archive-world/step.wav"); - this.load.audio("discover", "/assets/audio/archive-world/discover.wav"); - this.load.audio("portal", "/assets/audio/archive-world/portal.wav"); + 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"); + } - create() { - controller.scene = this; - this.add.image(0, 0, "archive-town").setOrigin(0).setDepth(0); - this.physics.world.setBounds(0, 0, 1280, 960); + 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.`); + } - this.markers = {}; - Object.entries(LANDMARKS).forEach(([id, item]) => { - const marker = this.add.circle(item.x, item.y, 22, 0xf4d98a, 0.16) - .setStrokeStyle(3, 0xf8e6a7, 0.9) - .setDepth(2); - marker.setData("landmarkId", id); - this.markers[id] = marker; - if (!controller.reducedMotion) { - this.tweens.add({ - targets: marker, - scale: 1.18, - alpha: 0.58, - duration: 900, - yoyo: true, - repeat: -1, - delay: Object.keys(LANDMARKS).indexOf(id) * 90 - }); - } - }); + 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."); + } - this.player = this.physics.add.sprite( - controller.state.position.x, - controller.state.position.y, - "traveler" - ).setDepth(4).setTint(PALETTE_TINTS[controller.state.palette]); - this.player.setCollideWorldBounds(true); - this.player.body.setSize(28, 34).setOffset(8, 58); + 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."); + } - this.cameras.main.setBounds(0, 0, 1280, 960); - this.cameras.main.startFollow(this.player, true, controller.reducedMotion ? 1 : 0.12, controller.reducedMotion ? 1 : 0.12); - - 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 - }); - - this.ambient = this.sound.add("ambient", { loop: true, volume: 0.24 }); - this.stepSound = this.sound.add("step", { volume: 0.22 }); - this.discoverSound = this.sound.add("discover", { volume: 0.35 }); - this.portalSound = this.sound.add("portal", { volume: 0.3 }); - if (controller.state.soundEnabled) this.enableSound(); - - setStatus(document.querySelector(".archive-world"), `Welcome, ${controller.state.name}. Walk near a glowing landmark and press E, Space, or Enter.`); - this.updateNearest(); - } - - update(time) { - if (!this.player) return; - let dx = 0; - let dy = 0; - if (!controller.lock) { - 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; - if (moving) { - const vector = new Phaser.Math.Vector2(dx, dy).normalize().scale(175); - this.player.setVelocity(vector.x, vector.y); - if (dx) this.player.setFlipX(dx < 0); - if (!controller.reducedMotion) { - this.player.rotation = Math.sin(time / 80) * 0.025; - this.player.setScale(1, 0.98 + Math.abs(Math.sin(time / 95)) * 0.04); - } - if (controller.state.soundEnabled && time - this.lastStepAt > 340) { - this.stepSound.play(); - this.lastStepAt = time; - } - if (time - controller.saveTimer > 700) { - controller.saveTimer = time; - savePosition(); - } - } else { - this.player.setVelocity(0, 0); - this.player.rotation = 0; - this.player.setScale(1); - } - this.wasMoving = moving; - this.updateNearest(); - - if (!controller.lock && ( - Phaser.Input.Keyboard.JustDown(this.keys.interact) || - Phaser.Input.Keyboard.JustDown(this.keys.enter) || - Phaser.Input.Keyboard.JustDown(this.cursors.space) - )) this.interact(); - } - - updateNearest() { - let nearest = null; - let distance = Infinity; - Object.entries(LANDMARKS).forEach(([id, item]) => { - const nextDistance = Phaser.Math.Distance.Between(this.player.x, this.player.y, item.x, item.y); - if (nextDistance < distance) { - nearest = id; - distance = nextDistance; - } - this.markers[id].setStrokeStyle(nextDistance < 95 ? 5 : 3, nextDistance < 95 ? 0xffffff : 0xf8e6a7, 0.95); - }); - const next = distance < 95 ? nearest : null; - if (next !== this.nearest) { - this.nearest = next; - const root = document.querySelector(".archive-world"); - setStatus(root, next - ? `${LANDMARKS[next].title} is nearby. Press E, Space, Enter, or Explore.` - : "Follow the stone paths toward a glowing landmark."); - } - } - - interact() { - if (controller.lock) return; - if (!this.nearest) { - setStatus(document.querySelector(".archive-world"), "Move closer to one of the glowing landmark circles."); - return; - } - openPreview(document.querySelector(".archive-world"), this.nearest); - } - - enableSound() { - if (this.sound.locked && this.sound.unlock) this.sound.unlock(); - this.sound.mute = false; - if (!this.ambient.isPlaying) this.ambient.play(); - } - - disableSound() { - this.sound.mute = true; - if (this.ambient.isPlaying) this.ambient.pause(); + 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 openPreview(root, id) { - const item = LANDMARKS[id]; - if (!item) return; - const wasDiscovered = controller.state.discovered.includes(id); - controller.state = State.discover(controller.state, id); - persist(); + 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}`); + controller.root.querySelector("[data-world-health-bar]").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"); + controller.root.querySelector("[data-world-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; - - root.querySelector("[data-world-preview-eyebrow]").textContent = item.eyebrow; - root.querySelector("[data-world-preview-title]").textContent = item.title; - root.querySelector("[data-world-preview-description]").textContent = item.description; - const links = root.querySelector("[data-world-preview-links]"); - links.replaceChildren(); - item.links.forEach(([label, href]) => { - const anchor = document.createElement("a"); - anchor.href = href; - anchor.textContent = label; - anchor.addEventListener("click", () => { - if (controller.scene && controller.state.soundEnabled) controller.scene.portalSound.play(); - savePosition(); - }); - links.appendChild(anchor); - }); - - renderHud(root); - if (!wasDiscovered && controller.scene && controller.state.soundEnabled) controller.scene.discoverSound.play(); - root.querySelector("[data-world-preview]").showModal(); - setStatus(root, wasDiscovered - ? `${item.title} is already recorded in your journal.` - : `${item.title} was added to your discovery journal.`); + controller.ui.openDefeat(); } - function toggleSound(root) { - if (!controller.state || !controller.scene) return; - controller.state.soundEnabled = !controller.state.soundEnabled; - if (controller.state.soundEnabled) controller.scene.enableSound(); - else controller.scene.disableSound(); - persist(); - renderHud(root); - setStatus(root, controller.state.soundEnabled ? "Ambient sound is on." : "Sound is muted."); + function openEnding() { + controller.ui.openEnding(controller.state); } - function savePosition() { - if (!controller.state || !controller.scene || !controller.scene.player) return; - controller.state = State.withPosition(controller.state, controller.scene.player.x, controller.scene.player.y); - persist(); + function text(selector, value) { + const node = controller.root.querySelector(selector); + if (node) node.textContent = value; } - function persist() { - if (controller.state) localStorage.setItem(State.STORAGE_KEY, JSON.stringify(controller.state)); - } - - function renderHud(root) { - if (!controller.state) return; - root.querySelector("[data-world-player]").textContent = controller.state.name; - root.querySelector("[data-world-discovery-count]").textContent = `${controller.state.discovered.length} / ${State.LANDMARK_IDS.length}`; - root.querySelector("[data-world-sound]").textContent = controller.state.soundEnabled ? "Sound: on" : "Sound: muted"; - const badge = root.querySelector("[data-world-badge]"); - badge.hidden = !controller.state.complete; - renderJournal(root); - } - - function renderJournal(root) { - if (!controller.state) return; - const list = root.querySelector("[data-world-journal-list]"); - list.replaceChildren(); - State.LANDMARK_IDS.forEach((id) => { - const row = document.createElement("li"); - const found = controller.state.discovered.includes(id); - row.className = found ? "is-discovered" : ""; - row.textContent = `${found ? "Discovered" : "Unexplored"} — ${LANDMARKS[id].title}`; - list.appendChild(row); - }); - } - - function setStatus(root, message) { - const status = root && root.querySelector("[data-world-status]"); - if (status) status.textContent = message; - } + Object.assign(controller, { + setState, renderHud, status, setPrompt, showBoss, hideBoss, openDefeat, openEnding, + openLandmark, openNpc, openChallenge, openMenu, travel, useSelectedItem, cycleItem + }); }()); diff --git a/assets/scripts/vendor/phaser-4.1.0.min.js b/assets/scripts/vendor/phaser-4.1.0.min.js old mode 100644 new mode 100755 diff --git a/assets/styles/pages/play.css b/assets/styles/pages/play.css index f9c1a92..81824a6 100755 --- a/assets/styles/pages/play.css +++ b/assets/styles/pages/play.css @@ -447,13 +447,13 @@ .archive-world { --world-ink: #17251e; - --world-panel: #f3ead2; + --world-panel: #efe2c2; --world-paper: #fff9e9; --world-line: #8b6841; --world-brass: #d49a3a; --world-moss: #527657; --world-berry: #9d5268; - max-width: 1440px; + max-width: 1540px; } .archive-world__heading { @@ -461,6 +461,7 @@ } .archive-world__shell { + position: relative; overflow: hidden; border: 2px solid var(--world-line); border-radius: 12px; @@ -468,12 +469,26 @@ box-shadow: 0 22px 70px rgba(23, 37, 30, 0.28); } -.archive-world__toolbar { - display: flex; - flex-wrap: wrap; +.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: center; - padding: 0.75rem; + align-items: stretch; + padding: 0.65rem; border-bottom: 2px solid #6e5738; background: linear-gradient(rgba(255, 255, 255, 0.04), transparent), @@ -481,12 +496,18 @@ color: #fff6dc; } -.archive-world__toolbar > div { +.archive-world__hud > div:not(.rpg-hud__actions) { display: grid; - min-width: 110px; + 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__toolbar span { +.archive-world__hud span, +.rpg-objective span { color: #d8c9a6; font-size: 0.68rem; font-weight: 900; @@ -494,9 +515,49 @@ text-transform: uppercase; } -.archive-world__toolbar button, +.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__dialog button, +.archive-world__panel button { border: 1px solid #d6b777; border-radius: 6px; background: #fff3d2; @@ -506,24 +567,40 @@ cursor: pointer; } -.archive-world__toolbar button { - margin-left: auto; - padding: 0.55rem 0.75rem; +.archive-world__hud button { + padding: 0.48rem 0.62rem; + font-size: 0.76rem; } -.archive-world__toolbar button + button { - margin-left: 0; -} - -.archive-world__toolbar button:hover, -.archive-world__toolbar button:focus-visible, +.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__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 { @@ -537,6 +614,12 @@ font-weight: 900; } +.archive-world__stage { + position: relative; + min-height: 0; + background: #101b18; +} + .archive-world__game { position: relative; width: 100%; @@ -548,6 +631,13 @@ 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; } @@ -572,6 +662,31 @@ 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; @@ -590,6 +705,12 @@ 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; } @@ -613,21 +734,29 @@ -webkit-user-select: none; } -.archive-world__interact { - min-width: 110px; - min-height: 64px !important; +.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__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 { - border-top: 1px solid rgba(245, 228, 183, 0.16); + padding-top: 0.2rem; font-weight: 800; } @@ -637,6 +766,283 @@ 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; @@ -658,7 +1064,6 @@ .archive-world__dialog h2 { margin-top: 0.25rem; - font-family: Georgia, "Times New Roman", serif; } .archive-world__dialog form, @@ -711,51 +1116,6 @@ font-weight: 800; } -.archive-world__preview-links { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.65rem; - margin: 1rem 0; -} - -.archive-world__preview-links a { - padding: 0.75rem; - border: 1px solid var(--world-line); - border-radius: 6px; - background: var(--world-paper); - color: #3a2b18; - font-weight: 800; - text-decoration: none; -} - -.archive-world__preview-links a:hover, -.archive-world__preview-links a:focus-visible { - background: #fff; - box-shadow: 0 0 0 3px rgba(82, 118, 87, 0.25); -} - -.archive-world__journal { - display: grid; - gap: 0.5rem; - padding: 0; - list-style: none; -} - -.archive-world__journal li { - padding: 0.65rem; - border: 1px dashed #aa977b; - border-radius: 5px; - color: #756956; -} - -.archive-world__journal li.is-discovered { - border-style: solid; - border-color: #638568; - background: #e5efdc; - color: #29452e; - font-weight: 800; -} - .archive-world__directory { margin-top: 1.5rem; border: 1px solid var(--border); @@ -769,6 +1129,12 @@ 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)); @@ -813,12 +1179,13 @@ min-height: 160px; } - .archive-world__toolbar > div { - min-width: 82px; + .archive-world__hud { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .archive-world__toolbar button { - margin-left: 0; + .rpg-hud__actions { + grid-column: 1 / -1; + justify-content: start; } .archive-world__game { @@ -827,11 +1194,16 @@ } .archive-world__dialog fieldset, - .archive-world__preview-links, + .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) { @@ -841,10 +1213,43 @@ .archive-world__controls { justify-content: space-between; + gap: 0.4rem; + padding: 0.5rem; } .archive-world__dpad { - grid-template-columns: repeat(3, 42px); + 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; } } @@ -857,4 +1262,8 @@ animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } + + .rpg-meter i { + transition: none; + } } diff --git a/blogs/2025/2025-list.org b/blogs/2025/2025-list.org new file mode 100644 index 0000000..361d98d --- /dev/null +++ b/blogs/2025/2025-list.org @@ -0,0 +1,22 @@ +#+TITLE: 2025 Writing +#+OPTIONS: num:nil toc:nil +#+NO_SIDENOTES: t +#+DATE: <2025-12-31 Wed> + +* 2025 archive + +- [[file:12-december/28-12-week-review.org][28 December — Week review]] +- [[file:12-december/21-12-week-review.org][21 December — Week review]] +- [[file:12-december/14-12-week-review.org][14 December — Week review]] +- [[file:12-december/07-12-week-review.org][7 December — Week review]] +- [[file:11-november/30-11-week-review.org][30 November — Week review]] +- [[file:11-november/23-11-week-review.org][23 November — Week review]] +- [[file:11-november/16-11-week-review.org][16 November — Week review]] +- [[file:11-november/09-11-week-review.org][9 November — Week review]] +- [[file:11-november/02-11-week-review.org][2 November — Week review]] +- [[file:08-august/zettelkasten.org][Zettelkasten]] +- [[file:08-august/benefits-of-reading.org][Benefits of reading]] +- [[file:08-august/spending-the-whole-day-on-this-website.org][Spending the whole day on this website]] +- [[file:08-august/wacom-with-arch.org][Wacom with Arch]] +- [[file:08-august/what-do-i-want-to-do-with-emacs.org][What do I want to do with Emacs?]] +- [[file:08-august/hilberts.hotel.org][Hilbert's Hotel]] diff --git a/build-logs/gitea-build-monitor.log b/build-logs/gitea-build-monitor.log index 7e5bd95..aded2a3 100755 --- a/build-logs/gitea-build-monitor.log +++ b/build-logs/gitea-build-monitor.log @@ -760,3 +760,9 @@ at09 / Role Playing World
Walk through the website as a living town. Every lit doorway opens another part of the archive.
+A complete story-forward RPG about names, memories, and the connections that give an archive meaning.