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