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) {
|
(function (root, factory) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
const data = root.ArchiveWorldData || (typeof require === "function" ? require("./archive-world-data.js") : null);
|
||||||
const api = factory();
|
const api = factory(data);
|
||||||
if (typeof module === "object" && module.exports) module.exports = api;
|
if (typeof module === "object" && module.exports) module.exports = api;
|
||||||
root.ArchiveWorldState = api;
|
root.ArchiveWorldState = api;
|
||||||
}(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const VERSION = 1;
|
const VERSION = 2;
|
||||||
const STORAGE_KEY = "zxh_archive_world_v1";
|
const STORAGE_KEY = "zxh_archive_world_v2";
|
||||||
|
const LEGACY_KEY = "zxh_archive_world_v1";
|
||||||
const PALETTES = Object.freeze(["brass", "moss", "berry"]);
|
const PALETTES = Object.freeze(["brass", "moss", "berry"]);
|
||||||
const LANDMARK_IDS = Object.freeze([
|
const FACES = Object.freeze(["north", "south", "east", "west"]);
|
||||||
"gate",
|
const SPAWN = Object.freeze({ area: "town", spawn: "gate", x: 724, y: 965 });
|
||||||
"library",
|
const LANDMARK_IDS = Data ? Data.LANDMARK_IDS : Object.freeze(["gate", "library", "study", "inn", "workshop", "playroom", "museum", "garden"]);
|
||||||
"study",
|
const QUEST_IDS = Data ? Data.QUEST_IDS : Object.freeze(LANDMARK_IDS.concat("lost_letter"));
|
||||||
"inn",
|
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"]);
|
||||||
"workshop",
|
const AREA_IDS = Data ? Data.AREA_IDS : Object.freeze(["town", "vault", "workshop", "playroom", "boss"]);
|
||||||
"playroom",
|
const BOSS_IDS = Object.freeze(["sentinel", "redactor"]);
|
||||||
"museum",
|
const LEVELS = Object.freeze([0, 80, 200, 380, 620]);
|
||||||
"garden"
|
|
||||||
]);
|
|
||||||
const SPAWN = Object.freeze({ x: 640, y: 830 });
|
|
||||||
|
|
||||||
function validateName(value) {
|
function validateName(value) {
|
||||||
const name = String(value || "").trim().replace(/\s+/g, " ");
|
const name = String(value || "").trim().replace(/\s+/g, " ");
|
||||||
@@ -31,21 +29,92 @@
|
|||||||
return PALETTES.includes(value) ? value : null;
|
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) {
|
function fresh(name, palette) {
|
||||||
return {
|
return {
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
name: validateName(name) || "",
|
name: validateName(name) || "",
|
||||||
palette: validatePalette(palette) || "brass",
|
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: [],
|
discovered: [],
|
||||||
complete: false,
|
complete: false,
|
||||||
position: { x: SPAWN.x, y: SPAWN.y },
|
story: { stage: "arrival", ending: false, midpointSeen: false },
|
||||||
soundEnabled: 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) {
|
function normalizePosition(candidate, fallback) {
|
||||||
const number = Number(value);
|
const area = candidate && AREA_IDS.includes(candidate.area) ? candidate.area : fallback.area;
|
||||||
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
|
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) {
|
function normalize(candidate) {
|
||||||
@@ -54,61 +123,115 @@
|
|||||||
const palette = validatePalette(candidate.palette);
|
const palette = validatePalette(candidate.palette);
|
||||||
if (!name || !palette) return null;
|
if (!name || !palette) return null;
|
||||||
|
|
||||||
const discovered = Array.isArray(candidate.discovered)
|
const discovered = unique(candidate.discovered, LANDMARK_IDS);
|
||||||
? Array.from(new Set(candidate.discovered.filter((id) => LANDMARK_IDS.includes(id))))
|
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 {
|
return {
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
name,
|
name,
|
||||||
palette,
|
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,
|
discovered,
|
||||||
complete: LANDMARK_IDS.every((id) => discovered.includes(id)),
|
complete: LANDMARK_IDS.every((id) => discovered.includes(id)),
|
||||||
position: {
|
story: {
|
||||||
x: clamp(candidate.position && candidate.position.x, 36, 1244, SPAWN.x),
|
stage: ["arrival", "first_act", "midpoint", "escalation", "vault", "postgame"].includes(candidate.story && candidate.story.stage)
|
||||||
y: clamp(candidate.position && candidate.position.y, 36, 924, SPAWN.y)
|
? 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) {
|
function parse(raw) {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
return normalize(JSON.parse(raw));
|
const candidate = JSON.parse(raw);
|
||||||
|
return candidate.version === 1 ? migrateV1(candidate) : normalize(candidate);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return null;
|
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);
|
const current = normalize(state);
|
||||||
if (!current || !LANDMARK_IDS.includes(landmarkId)) return current;
|
if (!current) return null;
|
||||||
if (!current.discovered.includes(landmarkId)) current.discovered.push(landmarkId);
|
current.position = normalizePosition({ area, x, y, facing, spawn }, current.position);
|
||||||
current.complete = LANDMARK_IDS.every((id) => current.discovered.includes(id));
|
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withPosition(state, x, y) {
|
function withCheckpoint(state, area, spawn, x, y) {
|
||||||
const current = normalize(state);
|
const current = normalize(state);
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
current.position.x = clamp(x, 36, 1244, SPAWN.x);
|
const next = normalizePosition({ area, spawn, x, y }, SPAWN);
|
||||||
current.position.y = clamp(y, 36, 924, SPAWN.y);
|
current.checkpoint = { area: next.area, spawn: next.spawn, x: next.x, y: next.y };
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
VERSION,
|
VERSION, STORAGE_KEY, LEGACY_KEY, PALETTES, LANDMARK_IDS, QUEST_IDS, ITEM_IDS, AREA_IDS, BOSS_IDS, LEVELS, SPAWN,
|
||||||
STORAGE_KEY,
|
validateName, validatePalette, fresh, normalize, parse, migrateV1, normalizePosition, normalizeInventory,
|
||||||
PALETTES,
|
normalizeQuests, levelForXp, withPosition, withCheckpoint
|
||||||
LANDMARK_IDS,
|
|
||||||
SPAWN,
|
|
||||||
validateName,
|
|
||||||
validatePalette,
|
|
||||||
fresh,
|
|
||||||
normalize,
|
|
||||||
parse,
|
|
||||||
discover,
|
|
||||||
withPosition
|
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|||||||
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 () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
const Data = window.ArchiveWorldData;
|
||||||
const State = window.ArchiveWorldState;
|
const State = window.ArchiveWorldState;
|
||||||
const LANDMARKS = Object.freeze({
|
const Systems = window.ArchiveWorldSystems;
|
||||||
gate: landmark("Town Gate", "Arrivals", 640, 830,
|
const UI = window.ArchiveWorldUI;
|
||||||
"The old gate remembers every arrival. Begin at the dashboard or follow the newest paths.",
|
const Scenes = window.ArchiveWorldScenes;
|
||||||
[["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"]]),
|
const Audio = window.ArchiveWorldAudio;
|
||||||
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 controller = {
|
const controller = {
|
||||||
|
root: null,
|
||||||
state: null,
|
state: null,
|
||||||
|
game: null,
|
||||||
scene: null,
|
scene: null,
|
||||||
|
ui: null,
|
||||||
|
audio: null,
|
||||||
lock: false,
|
lock: false,
|
||||||
move: { up: false, down: false, left: false, right: false },
|
move: { up: false, down: false, left: false, right: false },
|
||||||
reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
prompt: "",
|
||||||
saveTimer: 0
|
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);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
const root = document.querySelector('.archive-world[data-play-page="archive-world"]');
|
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));
|
const loaded = loadState();
|
||||||
controller.state = stored;
|
if (loaded.state) {
|
||||||
bindInterface(root);
|
controller.state = loaded.state;
|
||||||
|
renderHud();
|
||||||
if (!window.Phaser) {
|
startGame();
|
||||||
setStatus(root, "The world engine could not load. Every destination remains available in the plain directory.");
|
if (loaded.message) status(loaded.message);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stored) {
|
|
||||||
startGame(root);
|
|
||||||
} else {
|
} else {
|
||||||
openSetup(root);
|
openSetup(loaded.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindInterface(root) {
|
function loadState() {
|
||||||
const setup = root.querySelector("[data-world-setup]");
|
try {
|
||||||
const setupForm = root.querySelector("[data-world-setup-form]");
|
const rawV2 = localStorage.getItem(State.STORAGE_KEY);
|
||||||
const preview = root.querySelector("[data-world-preview]");
|
if (rawV2) {
|
||||||
const journal = root.querySelector("[data-world-journal]");
|
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());
|
function ensureSafePosition(state) {
|
||||||
setupForm.addEventListener("submit", (event) => {
|
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();
|
event.preventDefault();
|
||||||
const data = new FormData(setupForm);
|
const formData = new FormData(form);
|
||||||
const name = State.validateName(data.get("name"));
|
const name = State.validateName(formData.get("name"));
|
||||||
const palette = State.validatePalette(data.get("palette"));
|
const palette = State.validatePalette(formData.get("palette"));
|
||||||
const error = root.querySelector("[data-world-setup-error]");
|
const error = controller.root.querySelector("[data-world-setup-error]");
|
||||||
if (!name || !palette) {
|
if (!name || !palette) {
|
||||||
error.textContent = "Choose a palette and enter a name between 1 and 20 characters.";
|
error.textContent = "Choose a palette and enter a name between 1 and 20 characters.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controller.state = State.fresh(name, palette);
|
controller.state = State.fresh(name, palette);
|
||||||
persist();
|
persistState(controller.state);
|
||||||
setup.close();
|
dialog.close();
|
||||||
startGame(root);
|
renderHud();
|
||||||
});
|
startGame();
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSetup(root) {
|
function openSetup(message) {
|
||||||
const dialog = root.querySelector("[data-world-setup]");
|
const dialog = controller.root.querySelector("[data-world-setup]");
|
||||||
|
if (message) {
|
||||||
|
controller.root.querySelector("[data-world-setup-error]").textContent = message;
|
||||||
|
status(message);
|
||||||
|
}
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
dialog.querySelector("input[name=name]").focus();
|
dialog.querySelector("input[name=name]").focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startGame(root) {
|
function bindControls() {
|
||||||
renderHud(root);
|
controller.root.querySelectorAll("[data-world-move]").forEach((button) => {
|
||||||
const game = new Phaser.Game({
|
const direction = button.dataset.worldMove;
|
||||||
type: Phaser.AUTO,
|
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",
|
parent: "archive-world-game",
|
||||||
width: 960,
|
width: 960,
|
||||||
height: 640,
|
height: 640,
|
||||||
backgroundColor: "#17251e",
|
backgroundColor: "#101b18",
|
||||||
pixelArt: true,
|
pixelArt: true,
|
||||||
roundPixels: true,
|
roundPixels: true,
|
||||||
physics: {
|
physics: { default: "arcade", arcade: { debug: false } },
|
||||||
default: "arcade",
|
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
|
||||||
arcade: { debug: false }
|
scene: sceneClasses,
|
||||||
},
|
input: { keyboard: true, mouse: true, touch: true },
|
||||||
scale: {
|
render: { antialias: false, pixelArt: true, roundPixels: true }
|
||||||
mode: Phaser.Scale.FIT,
|
|
||||||
autoCenter: Phaser.Scale.CENTER_BOTH
|
|
||||||
},
|
|
||||||
scene: ArchiveTownScene,
|
|
||||||
input: {
|
|
||||||
keyboard: true,
|
|
||||||
mouse: true,
|
|
||||||
touch: true
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
root.dataset.gameReady = "true";
|
controller.root.dataset.gameReady = "true";
|
||||||
root.querySelector("#archive-world-game").focus();
|
controller.root.querySelector("#archive-world-game").focus();
|
||||||
window.archiveWorldGame = game;
|
window.archiveWorldGame = controller.game;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ArchiveTownScene extends Phaser.Scene {
|
function startQuest(id) {
|
||||||
constructor() {
|
setState(Systems.startQuest(controller.state, id), `${Data.QUESTS[id].title} added to the quest log.`);
|
||||||
super("ArchiveTown");
|
controller.audio.play("quest");
|
||||||
this.nearest = null;
|
|
||||||
this.lastStepAt = 0;
|
|
||||||
this.wasMoving = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
preload() {
|
function completeQuest(id) {
|
||||||
this.load.image("archive-town", "/assets/images/play/archive-world/archive-town.webp");
|
let next = Systems.completeQuest(controller.state, id);
|
||||||
this.load.image("traveler", "/assets/images/play/archive-world/traveler.png");
|
if (id === "museum" && next.sigils.length >= 4) {
|
||||||
this.load.audio("ambient", "/assets/audio/archive-world/ambient.ogg");
|
next.story.midpointSeen = true;
|
||||||
this.load.audio("step", "/assets/audio/archive-world/step.wav");
|
next = Systems.updateStory(next);
|
||||||
this.load.audio("discover", "/assets/audio/archive-world/discover.wav");
|
status("Midpoint revelation: the Redactor was built to preserve the archive, but learned to mistake emptiness for safety.");
|
||||||
this.load.audio("portal", "/assets/audio/archive-world/portal.wav");
|
}
|
||||||
|
setState(next, `${Data.QUESTS[id].title} complete. The ${Data.LANDMARKS[Data.QUESTS[id].landmark].title} Sigil is restored.`);
|
||||||
|
controller.audio.play("quest");
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
function completeChoice(choice) {
|
||||||
controller.scene = this;
|
let next = Systems.startQuest(controller.state, "lost_letter");
|
||||||
this.add.image(0, 0, "archive-town").setOrigin(0).setDepth(0);
|
next.npcFlags.nellChoice = choice;
|
||||||
this.physics.world.setBounds(0, 0, 1280, 960);
|
next = Systems.completeQuest(next, "lost_letter");
|
||||||
|
setState(next, "Nell remembers that choosing what to preserve is part of the story.");
|
||||||
this.markers = {};
|
controller.audio.play("dialogue");
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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) {
|
function openLandmark(id) {
|
||||||
if (!this.player) return;
|
const wasNew = !controller.state.discovered.includes(id);
|
||||||
let dx = 0;
|
setState(Systems.discover(controller.state, id), wasNew ? `${Data.LANDMARKS[id].title} added to the discovery journal.` : null);
|
||||||
let dy = 0;
|
if (wasNew) controller.audio.play("quest", { volume: 0.7 });
|
||||||
if (!controller.lock) {
|
controller.ui.openLandmark(id, controller.state);
|
||||||
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;
|
function openNpc(id) {
|
||||||
if (moving) {
|
controller.audio.play("dialogue", { volume: 0.45 });
|
||||||
const vector = new Phaser.Math.Vector2(dx, dy).normalize().scale(175);
|
controller.ui.openNpc(id, controller.state);
|
||||||
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() {
|
function openChallenge(id) {
|
||||||
let nearest = null;
|
if (controller.state.quests[id].status === "locked") controller.state = Systems.startQuest(controller.state, id);
|
||||||
let distance = Infinity;
|
controller.ui.openChallenge(id, controller.state);
|
||||||
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() {
|
function openMenu() {
|
||||||
if (controller.lock) return;
|
if (!controller.state || controller.ui.isOpen()) return;
|
||||||
if (!this.nearest) {
|
controller.ui.openMenu(controller.state, "quests");
|
||||||
setStatus(document.querySelector(".archive-world"), "Move closer to one of the glowing landmark circles.");
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
openPreview(document.querySelector(".archive-world"), this.nearest);
|
setState(result.state, `${Data.ITEMS[id].name} restored ${result.amount} health.`);
|
||||||
|
controller.audio.play("item");
|
||||||
}
|
}
|
||||||
|
|
||||||
enableSound() {
|
function cycleItem() {
|
||||||
if (this.sound.locked && this.sound.unlock) this.sound.unlock();
|
const consumables = controller.state.inventory.filter((entry) => Data.ITEMS[entry.id].type === "consumable");
|
||||||
this.sound.mute = false;
|
if (!consumables.length) return status("No usable items are in the inventory.");
|
||||||
if (!this.ambient.isPlaying) this.ambient.play();
|
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.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
disableSound() {
|
function respawn() {
|
||||||
this.sound.mute = true;
|
const next = Systems.respawn(controller.state);
|
||||||
if (this.ambient.isPlaying) this.ambient.pause();
|
controller.state = next;
|
||||||
|
persistState(next);
|
||||||
|
travel(next.position.area);
|
||||||
|
status("The Living Bookmark returns you to a safe place. Story and items were preserved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSound() {
|
||||||
|
if (!controller.state) return;
|
||||||
|
controller.state.settings.soundEnabled = !controller.state.settings.soundEnabled;
|
||||||
|
if (controller.state.settings.soundEnabled) controller.audio.unlock();
|
||||||
|
controller.audio.apply();
|
||||||
|
setState(controller.state, controller.state.settings.soundEnabled ? "Archive World audio enabled." : "Audio muted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenEnabled) return;
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) await document.exitFullscreen();
|
||||||
|
else await controller.root.querySelector("[data-world-shell]").requestFullscreen();
|
||||||
|
} catch (_error) {
|
||||||
|
status("Fullscreen is unavailable in this browser.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPreview(root, id) {
|
function updateFullscreen() {
|
||||||
const item = LANDMARKS[id];
|
const active = Boolean(document.fullscreenElement);
|
||||||
if (!item) return;
|
controller.root.querySelectorAll("[data-world-fullscreen]").forEach((button) => {
|
||||||
const wasDiscovered = controller.state.discovered.includes(id);
|
button.textContent = active ? "Exit fullscreen" : "Fullscreen";
|
||||||
controller.state = State.discover(controller.state, id);
|
button.setAttribute("aria-pressed", String(active));
|
||||||
persist();
|
});
|
||||||
|
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;
|
controller.lock = true;
|
||||||
|
controller.ui.openDefeat();
|
||||||
|
}
|
||||||
|
|
||||||
root.querySelector("[data-world-preview-eyebrow]").textContent = item.eyebrow;
|
function openEnding() {
|
||||||
root.querySelector("[data-world-preview-title]").textContent = item.title;
|
controller.ui.openEnding(controller.state);
|
||||||
root.querySelector("[data-world-preview-description]").textContent = item.description;
|
}
|
||||||
const links = root.querySelector("[data-world-preview-links]");
|
|
||||||
links.replaceChildren();
|
function text(selector, value) {
|
||||||
item.links.forEach(([label, href]) => {
|
const node = controller.root.querySelector(selector);
|
||||||
const anchor = document.createElement("a");
|
if (node) node.textContent = value;
|
||||||
anchor.href = href;
|
}
|
||||||
anchor.textContent = label;
|
|
||||||
anchor.addEventListener("click", () => {
|
Object.assign(controller, {
|
||||||
if (controller.scene && controller.state.soundEnabled) controller.scene.portalSound.play();
|
setState, renderHud, status, setPrompt, showBoss, hideBoss, openDefeat, openEnding,
|
||||||
savePosition();
|
openLandmark, openNpc, openChallenge, openMenu, travel, useSelectedItem, cycleItem
|
||||||
});
|
});
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 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;
|
|
||||||
}
|
|
||||||
}());
|
}());
|
||||||
|
|||||||
0
assets/scripts/vendor/phaser-4.1.0.min.js
vendored
Normal file → Executable file
@@ -447,13 +447,13 @@
|
|||||||
|
|
||||||
.archive-world {
|
.archive-world {
|
||||||
--world-ink: #17251e;
|
--world-ink: #17251e;
|
||||||
--world-panel: #f3ead2;
|
--world-panel: #efe2c2;
|
||||||
--world-paper: #fff9e9;
|
--world-paper: #fff9e9;
|
||||||
--world-line: #8b6841;
|
--world-line: #8b6841;
|
||||||
--world-brass: #d49a3a;
|
--world-brass: #d49a3a;
|
||||||
--world-moss: #527657;
|
--world-moss: #527657;
|
||||||
--world-berry: #9d5268;
|
--world-berry: #9d5268;
|
||||||
max-width: 1440px;
|
max-width: 1540px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__heading {
|
.archive-world__heading {
|
||||||
@@ -461,6 +461,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__shell {
|
.archive-world__shell {
|
||||||
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 2px solid var(--world-line);
|
border: 2px solid var(--world-line);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -468,12 +469,26 @@
|
|||||||
box-shadow: 0 22px 70px rgba(23, 37, 30, 0.28);
|
box-shadow: 0 22px 70px rgba(23, 37, 30, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__toolbar {
|
.archive-world.is-restored .archive-world__shell {
|
||||||
display: flex;
|
box-shadow: 0 22px 80px rgba(215, 184, 94, 0.32), 0 0 0 3px rgba(239, 213, 137, 0.22);
|
||||||
flex-wrap: wrap;
|
}
|
||||||
|
|
||||||
|
.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;
|
gap: 0.65rem;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
padding: 0.75rem;
|
padding: 0.65rem;
|
||||||
border-bottom: 2px solid #6e5738;
|
border-bottom: 2px solid #6e5738;
|
||||||
background:
|
background:
|
||||||
linear-gradient(rgba(255, 255, 255, 0.04), transparent),
|
linear-gradient(rgba(255, 255, 255, 0.04), transparent),
|
||||||
@@ -481,12 +496,18 @@
|
|||||||
color: #fff6dc;
|
color: #fff6dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__toolbar > div {
|
.archive-world__hud > div:not(.rpg-hud__actions) {
|
||||||
display: grid;
|
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;
|
color: #d8c9a6;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@@ -494,9 +515,49 @@
|
|||||||
text-transform: uppercase;
|
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__controls button,
|
||||||
.archive-world__dialog button {
|
.archive-world__dialog button,
|
||||||
|
.archive-world__panel button {
|
||||||
border: 1px solid #d6b777;
|
border: 1px solid #d6b777;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #fff3d2;
|
background: #fff3d2;
|
||||||
@@ -506,24 +567,40 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__toolbar button {
|
.archive-world__hud button {
|
||||||
margin-left: auto;
|
padding: 0.48rem 0.62rem;
|
||||||
padding: 0.55rem 0.75rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__toolbar button + button {
|
.archive-world__hud button:hover,
|
||||||
margin-left: 0;
|
.archive-world__hud button:focus-visible,
|
||||||
}
|
|
||||||
|
|
||||||
.archive-world__toolbar button:hover,
|
|
||||||
.archive-world__toolbar button:focus-visible,
|
|
||||||
.archive-world__controls button:hover,
|
.archive-world__controls button:hover,
|
||||||
.archive-world__controls button:focus-visible,
|
.archive-world__controls button:focus-visible,
|
||||||
.archive-world__dialog button:hover,
|
.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;
|
border-color: #fff0b5;
|
||||||
background: #fffaf0;
|
background: #fffaf0;
|
||||||
box-shadow: 0 0 0 3px rgba(255, 236, 173, 0.24);
|
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 {
|
.archive-world__badge {
|
||||||
@@ -537,6 +614,12 @@
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-world__stage {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
background: #101b18;
|
||||||
|
}
|
||||||
|
|
||||||
.archive-world__game {
|
.archive-world__game {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -548,6 +631,13 @@
|
|||||||
image-rendering: pixelated;
|
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 {
|
.archive-world__game:focus-visible {
|
||||||
box-shadow: inset 0 0 0 4px #fff0a9;
|
box-shadow: inset 0 0 0 4px #fff0a9;
|
||||||
}
|
}
|
||||||
@@ -572,6 +662,31 @@
|
|||||||
display: none;
|
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 {
|
.archive-world__controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -590,6 +705,12 @@
|
|||||||
gap: 0.3rem;
|
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"] {
|
.archive-world__dpad [data-world-move="up"] {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
}
|
}
|
||||||
@@ -613,21 +734,29 @@
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__interact {
|
.archive-world__action-pad [data-world-attack],
|
||||||
min-width: 110px;
|
.archive-world__action-pad [data-world-interact] {
|
||||||
min-height: 64px !important;
|
min-height: 54px;
|
||||||
|
background: #f5d98d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__status,
|
.archive-world__status,
|
||||||
.archive-world__instructions {
|
.archive-world__instructions,
|
||||||
|
.archive-world__prompt {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.65rem 1rem;
|
padding: 0.65rem 1rem;
|
||||||
color: #f5e4b7;
|
color: #f5e4b7;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-world__prompt {
|
||||||
|
padding-bottom: 0.15rem;
|
||||||
|
color: #fff0b7;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
.archive-world__status {
|
.archive-world__status {
|
||||||
border-top: 1px solid rgba(245, 228, 183, 0.16);
|
padding-top: 0.2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,6 +766,283 @@
|
|||||||
font-size: 0.85rem;
|
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 {
|
.archive-world__dialog {
|
||||||
width: min(92vw, 620px);
|
width: min(92vw, 620px);
|
||||||
max-height: 86vh;
|
max-height: 86vh;
|
||||||
@@ -658,7 +1064,6 @@
|
|||||||
|
|
||||||
.archive-world__dialog h2 {
|
.archive-world__dialog h2 {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
font-family: Georgia, "Times New Roman", serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__dialog form,
|
.archive-world__dialog form,
|
||||||
@@ -711,51 +1116,6 @@
|
|||||||
font-weight: 800;
|
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 {
|
.archive-world__directory {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -769,6 +1129,12 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-world__directory > p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1rem 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.archive-world__directory-grid {
|
.archive-world__directory-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -813,12 +1179,13 @@
|
|||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__toolbar > div {
|
.archive-world__hud {
|
||||||
min-width: 82px;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__toolbar button {
|
.rpg-hud__actions {
|
||||||
margin-left: 0;
|
grid-column: 1 / -1;
|
||||||
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__game {
|
.archive-world__game {
|
||||||
@@ -827,11 +1194,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__dialog fieldset,
|
.archive-world__dialog fieldset,
|
||||||
.archive-world__preview-links,
|
.rpg-panel__facts,
|
||||||
|
.rpg-panel__destinations,
|
||||||
.archive-world__directory-grid {
|
.archive-world__directory-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rpg-objective {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
@@ -841,10 +1213,43 @@
|
|||||||
|
|
||||||
.archive-world__controls {
|
.archive-world__controls {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-world__dpad {
|
.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-duration: 0.01ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rpg-meter i {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
blogs/2025/2025-list.org
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#+TITLE: 2025 Writing
|
||||||
|
#+OPTIONS: num:nil toc:nil
|
||||||
|
#+NO_SIDENOTES: t
|
||||||
|
#+DATE: <2025-12-31 Wed>
|
||||||
|
|
||||||
|
* 2025 archive
|
||||||
|
|
||||||
|
- [[file:12-december/28-12-week-review.org][28 December — Week review]]
|
||||||
|
- [[file:12-december/21-12-week-review.org][21 December — Week review]]
|
||||||
|
- [[file:12-december/14-12-week-review.org][14 December — Week review]]
|
||||||
|
- [[file:12-december/07-12-week-review.org][7 December — Week review]]
|
||||||
|
- [[file:11-november/30-11-week-review.org][30 November — Week review]]
|
||||||
|
- [[file:11-november/23-11-week-review.org][23 November — Week review]]
|
||||||
|
- [[file:11-november/16-11-week-review.org][16 November — Week review]]
|
||||||
|
- [[file:11-november/09-11-week-review.org][9 November — Week review]]
|
||||||
|
- [[file:11-november/02-11-week-review.org][2 November — Week review]]
|
||||||
|
- [[file:08-august/zettelkasten.org][Zettelkasten]]
|
||||||
|
- [[file:08-august/benefits-of-reading.org][Benefits of reading]]
|
||||||
|
- [[file:08-august/spending-the-whole-day-on-this-website.org][Spending the whole day on this website]]
|
||||||
|
- [[file:08-august/wacom-with-arch.org][Wacom with Arch]]
|
||||||
|
- [[file:08-august/what-do-i-want-to-do-with-emacs.org][What do I want to do with Emacs?]]
|
||||||
|
- [[file:08-august/hilberts.hotel.org][Hilbert's Hotel]]
|
||||||
@@ -760,3 +760,9 @@ at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/gitea-build-mon
|
|||||||
at Send-BuildStatusNotification, /home/zaine/master-folder/org-platform/org_web/build-logs/gitea-build-monitor.ps1: line 985
|
at Send-BuildStatusNotification, /home/zaine/master-folder/org-platform/org_web/build-logs/gitea-build-monitor.ps1: line 985
|
||||||
at Start-BuildMonitor, /home/zaine/master-folder/org-platform/org_web/build-logs/gitea-build-monitor.ps1: line 1262
|
at Start-BuildMonitor, /home/zaine/master-folder/org-platform/org_web/build-logs/gitea-build-monitor.ps1: line 1262
|
||||||
at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/build-logs/gitea-build-monitor.ps1: line 1287
|
at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/build-logs/gitea-build-monitor.ps1: line 1287
|
||||||
|
2026-07-29T15:18:04.1654950+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
|
||||||
|
2026-07-29T15:18:04.1791447+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
|
||||||
|
2026-07-29T15:18:06.0246063+01:00 [INFO] Sent build status notification with 1 embed(s).
|
||||||
|
2026-07-29T15:18:06.0538065+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
|
||||||
|
2026-07-29T15:18:06.1955865+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
|
||||||
|
2026-07-29T15:18:06.5283574+01:00 [INFO] Sent authoring server test notification.
|
||||||
|
|||||||
103
play/rpg.org
@@ -10,34 +10,73 @@
|
|||||||
<header class="play-page-head archive-world__heading">
|
<header class="play-page-head archive-world__heading">
|
||||||
<p class="play-kicker">09 / Role Playing World</p>
|
<p class="play-kicker">09 / Role Playing World</p>
|
||||||
<h1>The Archive World</h1>
|
<h1>The Archive World</h1>
|
||||||
<p>Walk through the website as a living town. Every lit doorway opens another part of the archive.</p>
|
<p>A complete story-forward RPG about names, memories, and the connections that give an archive meaning.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="archive-world__shell" aria-labelledby="archive-world-title">
|
<section class="archive-world__shell" data-world-shell aria-labelledby="archive-world-title">
|
||||||
<div class="archive-world__toolbar">
|
<div class="archive-world__hud">
|
||||||
<div>
|
<div class="rpg-hud__identity">
|
||||||
<span>Traveler</span>
|
<span>Traveler</span>
|
||||||
<strong data-world-player>New arrival</strong>
|
<strong data-world-player>New arrival</strong>
|
||||||
|
<small data-world-level>Lv 1</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="rpg-hud__health">
|
||||||
<span>Landmarks</span>
|
<span>Health <strong data-world-health-text>100 / 100</strong></span>
|
||||||
<strong data-world-discovery-count>0 / 8</strong>
|
<div class="rpg-meter" aria-hidden="true"><i data-world-health-bar style="width:100%"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="archive-world__badge" data-world-badge hidden>Archive Cartographer</p>
|
<div class="rpg-hud__sigils">
|
||||||
<button type="button" data-world-journal-open>Discovery journal</button>
|
<span>Archive Sigils</span>
|
||||||
|
<strong data-world-sigil-count>0 / 8</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rpg-hud__item">
|
||||||
|
<span>Selected item</span>
|
||||||
|
<strong data-world-selected-item>Ink Vial ×2</strong>
|
||||||
|
</div>
|
||||||
|
<div class="rpg-hud__actions">
|
||||||
|
<button type="button" data-world-quests>Quests</button>
|
||||||
|
<button type="button" data-world-inventory>Inventory</button>
|
||||||
|
<button type="button" data-world-journal-open>Journal</button>
|
||||||
<button type="button" data-world-sound>Sound: muted</button>
|
<button type="button" data-world-sound>Sound: muted</button>
|
||||||
<button type="button" data-world-reset>Reset traveler</button>
|
<button type="button" data-world-fullscreen aria-pressed="false">Fullscreen</button>
|
||||||
|
<button type="button" data-world-reset>Menu</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 id="archive-world-title" class="visually-hidden">Archive Town game</h2>
|
<div class="rpg-objective" aria-live="polite">
|
||||||
|
<span>Main objective</span>
|
||||||
|
<strong data-world-objective>Speak with Gatekeeper Orin at the Town Gate.</strong>
|
||||||
|
</div>
|
||||||
|
<p class="archive-world__badge" data-world-badge hidden>Archive Cartographer · all eight landmarks discovered</p>
|
||||||
|
|
||||||
|
<h2 id="archive-world-title" class="visually-hidden">Archive World role-playing game</h2>
|
||||||
|
<div class="archive-world__stage">
|
||||||
<div
|
<div
|
||||||
id="archive-world-game"
|
id="archive-world-game"
|
||||||
class="archive-world__game"
|
class="archive-world__game"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="application"
|
role="application"
|
||||||
aria-label="The Archive World. Move with arrow keys or WASD and interact with E, Space, or Enter."
|
aria-label="The Archive World. Move with arrow keys or WASD, attack with Space, interact with E or Enter, use an item with Q, and open the menu with Escape."
|
||||||
>
|
>
|
||||||
<p class="archive-world__loading">Preparing Archive Town…</p>
|
<p class="archive-world__loading">Opening the Archive Town gate…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rpg-boss" data-world-boss hidden aria-live="polite">
|
||||||
|
<span data-world-boss-name>The Redactor</span>
|
||||||
|
<div class="rpg-meter rpg-meter--boss"><i data-world-boss-bar style="width:100%"></i></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="archive-world__overlay" data-world-overlay hidden>
|
||||||
|
<section class="archive-world__panel" data-world-panel role="dialog" aria-modal="true" aria-labelledby="archive-world-panel-title">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<p class="play-kicker" data-world-panel-eyebrow>Archive</p>
|
||||||
|
<h2 id="archive-world-panel-title" data-world-panel-title>Traveler’s folio</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-world-panel-close aria-label="Close panel">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="archive-world__panel-body" data-world-panel-body></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="archive-world__controls" aria-label="Touch game controls">
|
<div class="archive-world__controls" aria-label="Touch game controls">
|
||||||
@@ -47,15 +86,21 @@
|
|||||||
<button type="button" data-world-move="down" aria-label="Move down">↓</button>
|
<button type="button" data-world-move="down" aria-label="Move down">↓</button>
|
||||||
<button type="button" data-world-move="right" aria-label="Move right">→</button>
|
<button type="button" data-world-move="right" aria-label="Move right">→</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="archive-world__interact" type="button" data-world-interact>Explore</button>
|
<div class="archive-world__action-pad">
|
||||||
|
<button type="button" data-world-attack>Attack</button>
|
||||||
|
<button type="button" data-world-interact>Explore</button>
|
||||||
|
<button type="button" data-world-item>Item</button>
|
||||||
|
<button type="button" data-world-menu>Menu</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="archive-world__prompt" data-world-prompt>Follow the paths, signs, and warm window light.</p>
|
||||||
<p class="archive-world__status" data-world-status aria-live="polite">
|
<p class="archive-world__status" data-world-status aria-live="polite">
|
||||||
Choose your traveler to enter Archive Town.
|
Choose your traveler to enter Archive Town.
|
||||||
</p>
|
</p>
|
||||||
<p class="archive-world__instructions">
|
<p class="archive-world__instructions">
|
||||||
Move with arrow keys or WASD. Press E, Space, Enter, or Explore near a glowing landmark.
|
Move: WASD/arrows · Attack: Space · Explore: E/Enter · Use item: Q · Cycle item: Tab · Pause: Escape.
|
||||||
Sound remains muted until you enable it.
|
Audio begins muted and remains optional.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -63,12 +108,13 @@
|
|||||||
<form data-world-setup-form>
|
<form data-world-setup-form>
|
||||||
<p class="play-kicker">New traveler</p>
|
<p class="play-kicker">New traveler</p>
|
||||||
<h2>Who has arrived?</h2>
|
<h2>Who has arrived?</h2>
|
||||||
|
<p>The Town Gate is losing names. Yours has held long enough for the Living Bookmark to find you.</p>
|
||||||
<label>
|
<label>
|
||||||
Traveler name
|
Traveler name
|
||||||
<input name="name" type="text" minlength="1" maxlength="20" autocomplete="nickname" required />
|
<input name="name" type="text" minlength="1" maxlength="20" autocomplete="nickname" required />
|
||||||
</label>
|
</label>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Coat palette</legend>
|
<legend>Traveller coat</legend>
|
||||||
<label><input type="radio" name="palette" value="brass" checked /> <span class="palette-swatch palette-swatch--brass"></span> Brass</label>
|
<label><input type="radio" name="palette" value="brass" checked /> <span class="palette-swatch palette-swatch--brass"></span> Brass</label>
|
||||||
<label><input type="radio" name="palette" value="moss" /> <span class="palette-swatch palette-swatch--moss"></span> Moss</label>
|
<label><input type="radio" name="palette" value="moss" /> <span class="palette-swatch palette-swatch--moss"></span> Moss</label>
|
||||||
<label><input type="radio" name="palette" value="berry" /> <span class="palette-swatch palette-swatch--berry"></span> Berry</label>
|
<label><input type="radio" name="palette" value="berry" /> <span class="palette-swatch palette-swatch--berry"></span> Berry</label>
|
||||||
@@ -78,23 +124,9 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog class="archive-world__dialog" data-world-preview>
|
|
||||||
<p class="play-kicker" data-world-preview-eyebrow>Landmark</p>
|
|
||||||
<h2 data-world-preview-title>Archive Town</h2>
|
|
||||||
<p data-world-preview-description></p>
|
|
||||||
<nav class="archive-world__preview-links" data-world-preview-links aria-label="Landmark destinations"></nav>
|
|
||||||
<button type="button" data-world-preview-close>Return to town</button>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog class="archive-world__dialog" data-world-journal>
|
|
||||||
<p class="play-kicker">Discovery journal</p>
|
|
||||||
<h2>Archive Town landmarks</h2>
|
|
||||||
<ul class="archive-world__journal" data-world-journal-list></ul>
|
|
||||||
<button type="button" data-world-journal-close>Close journal</button>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<details class="archive-world__directory">
|
<details class="archive-world__directory">
|
||||||
<summary>Plain directory of Archive Town</summary>
|
<summary>Plain directory of Archive Town</summary>
|
||||||
|
<p>This directory remains usable if JavaScript, Canvas, Phaser, or optional game assets are unavailable.</p>
|
||||||
<div class="archive-world__directory-grid">
|
<div class="archive-world__directory-grid">
|
||||||
<section><h2>Town Gate</h2><a href="/">Home</a> · <a href="/recently-updated.html">Recently updated</a> · <a href="/home/contact.html">Contact</a></section>
|
<section><h2>Town Gate</h2><a href="/">Home</a> · <a href="/recently-updated.html">Recently updated</a> · <a href="/home/contact.html">Contact</a></section>
|
||||||
<section><h2>Grand Library</h2><a href="/posts/posts-list.html">Posts</a> · <a href="/home/categories.html">Categories</a> · <a href="/posts/posts-intro.html">Introduction</a></section>
|
<section><h2>Grand Library</h2><a href="/posts/posts-list.html">Posts</a> · <a href="/home/categories.html">Categories</a> · <a href="/posts/posts-intro.html">Introduction</a></section>
|
||||||
@@ -107,10 +139,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<noscript><p class="archive-world__noscript">The game needs JavaScript, but every destination remains available in the plain directory above.</p></noscript>
|
<noscript><p class="archive-world__noscript">The RPG needs JavaScript, but every destination remains available in the plain directory above.</p></noscript>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js" defer></script>
|
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js" defer></script>
|
||||||
|
<script src="/assets/scripts/pages/archive-world-data.js" defer></script>
|
||||||
<script src="/assets/scripts/pages/archive-world-state.js" defer></script>
|
<script src="/assets/scripts/pages/archive-world-state.js" defer></script>
|
||||||
|
<script src="/assets/scripts/pages/archive-world-systems.js" defer></script>
|
||||||
|
<script src="/assets/scripts/pages/archive-world-audio.js" defer></script>
|
||||||
|
<script src="/assets/scripts/pages/archive-world-ui.js" defer></script>
|
||||||
|
<script src="/assets/scripts/pages/archive-world-scenes.js" defer></script>
|
||||||
<script src="/assets/scripts/pages/archive-world.js" defer></script>
|
<script src="/assets/scripts/pages/archive-world.js" defer></script>
|
||||||
#+END_EXPORT
|
#+END_EXPORT
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
||||||
|
|
||||||
* Posts:
|
* Posts:
|
||||||
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">28-07-2026 01:01</span>@@
|
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">29-07-2026 14:58</span>@@
|
||||||
- [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
- [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||||
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||||
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||||
|
|||||||
30
sitemap.org
@@ -110,17 +110,17 @@ flowchart TD
|
|||||||
n46 --> n52
|
n46 --> n52
|
||||||
n53["Tag: website"]
|
n53["Tag: website"]
|
||||||
n46 --> n53
|
n46 --> n53
|
||||||
n54["Tag: update"]
|
n54["Tag: life"]
|
||||||
n46 --> n54
|
n46 --> n54
|
||||||
n55["Tag: life"]
|
n55["Tag: update"]
|
||||||
n46 --> n55
|
n46 --> n55
|
||||||
n56["Tag: education"]
|
n56["Tag: insights"]
|
||||||
n46 --> n56
|
n46 --> n56
|
||||||
n57["Tag: insights"]
|
n57["Tag: emacs"]
|
||||||
n46 --> n57
|
n46 --> n57
|
||||||
n58["Tag: reading"]
|
n58["Tag: education"]
|
||||||
n46 --> n58
|
n46 --> n58
|
||||||
n59["Tag: emacs"]
|
n59["Tag: reading"]
|
||||||
n46 --> n59
|
n46 --> n59
|
||||||
n60["Tag: maths"]
|
n60["Tag: maths"]
|
||||||
n46 --> n60
|
n46 --> n60
|
||||||
@@ -196,12 +196,12 @@ flowchart TD
|
|||||||
click n51 "tags/notes.html" "Tag: notes"
|
click n51 "tags/notes.html" "Tag: notes"
|
||||||
click n52 "tags/review.html" "Tag: review"
|
click n52 "tags/review.html" "Tag: review"
|
||||||
click n53 "tags/website.html" "Tag: website"
|
click n53 "tags/website.html" "Tag: website"
|
||||||
click n54 "tags/update.html" "Tag: update"
|
click n54 "tags/life.html" "Tag: life"
|
||||||
click n55 "tags/life.html" "Tag: life"
|
click n55 "tags/update.html" "Tag: update"
|
||||||
click n56 "tags/education.html" "Tag: education"
|
click n56 "tags/insights.html" "Tag: insights"
|
||||||
click n57 "tags/insights.html" "Tag: insights"
|
click n57 "tags/emacs.html" "Tag: emacs"
|
||||||
click n58 "tags/reading.html" "Tag: reading"
|
click n58 "tags/education.html" "Tag: education"
|
||||||
click n59 "tags/emacs.html" "Tag: emacs"
|
click n59 "tags/reading.html" "Tag: reading"
|
||||||
click n60 "tags/maths.html" "Tag: maths"
|
click n60 "tags/maths.html" "Tag: maths"
|
||||||
click n62 "play/sigil.html" "Sigil Press"
|
click n62 "play/sigil.html" "Sigil Press"
|
||||||
click n63 "play/ink.html" "Ink Pond"
|
click n63 "play/ink.html" "Ink Pond"
|
||||||
@@ -271,12 +271,12 @@ flowchart TD
|
|||||||
- [[file:tags/notes.org][Tag: notes]]
|
- [[file:tags/notes.org][Tag: notes]]
|
||||||
- [[file:tags/review.org][Tag: review]]
|
- [[file:tags/review.org][Tag: review]]
|
||||||
- [[file:tags/website.org][Tag: website]]
|
- [[file:tags/website.org][Tag: website]]
|
||||||
- [[file:tags/update.org][Tag: update]]
|
|
||||||
- [[file:tags/life.org][Tag: life]]
|
- [[file:tags/life.org][Tag: life]]
|
||||||
- [[file:tags/education.org][Tag: education]]
|
- [[file:tags/update.org][Tag: update]]
|
||||||
- [[file:tags/insights.org][Tag: insights]]
|
- [[file:tags/insights.org][Tag: insights]]
|
||||||
- [[file:tags/reading.org][Tag: reading]]
|
|
||||||
- [[file:tags/emacs.org][Tag: emacs]]
|
- [[file:tags/emacs.org][Tag: emacs]]
|
||||||
|
- [[file:tags/education.org][Tag: education]]
|
||||||
|
- [[file:tags/reading.org][Tag: reading]]
|
||||||
- [[file:tags/maths.org][Tag: maths]]
|
- [[file:tags/maths.org][Tag: maths]]
|
||||||
- play
|
- play
|
||||||
- [[file:play/sigil.org][Sigil Press]]
|
- [[file:play/sigil.org][Sigil Press]]
|
||||||
|
|||||||
@@ -1,70 +1,91 @@
|
|||||||
const test = require("node:test");
|
const test = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
|
const data = require("../assets/scripts/pages/archive-world-data.js");
|
||||||
const state = require("../assets/scripts/pages/archive-world-state.js");
|
const state = require("../assets/scripts/pages/archive-world-state.js");
|
||||||
|
|
||||||
test("fresh state applies defaults", () => {
|
test("fresh v2 state applies safe defaults", () => {
|
||||||
assert.deepEqual(state.fresh(" Ada ", "moss"), {
|
const fresh = state.fresh(" Ada ", "moss");
|
||||||
version: 1,
|
assert.equal(fresh.version, 2);
|
||||||
name: "Ada",
|
assert.equal(fresh.name, "Ada");
|
||||||
palette: "moss",
|
assert.equal(fresh.palette, "moss");
|
||||||
discovered: [],
|
assert.equal(fresh.position.area, "town");
|
||||||
complete: false,
|
assert.equal(fresh.health, 100);
|
||||||
position: { x: 640, y: 830 },
|
assert.equal(fresh.settings.soundEnabled, false);
|
||||||
soundEnabled: false
|
assert.deepEqual(fresh.sigils, []);
|
||||||
});
|
assert.equal(fresh.inventory.find((item) => item.id === "ink_vial").quantity, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("names are trimmed, collapsed, and limited to twenty characters", () => {
|
test("names and palettes are validated", () => {
|
||||||
assert.equal(state.validateName(" Archive Guest "), "Archive Guest");
|
assert.equal(state.validateName(" Archive Guest "), "Archive Guest");
|
||||||
assert.equal(state.validateName(""), null);
|
assert.equal(state.validateName(""), null);
|
||||||
assert.equal(state.validateName("x".repeat(21)), null);
|
assert.equal(state.validateName("x".repeat(21)), null);
|
||||||
});
|
|
||||||
|
|
||||||
test("only declared palettes are accepted", () => {
|
|
||||||
assert.equal(state.validatePalette("berry"), "berry");
|
assert.equal(state.validatePalette("berry"), "berry");
|
||||||
assert.equal(state.validatePalette("ultraviolet"), null);
|
assert.equal(state.validatePalette("ultraviolet"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("discoveries are deduplicated and completion requires all landmarks", () => {
|
test("v1 data migrates identity, discoveries, position and sound", () => {
|
||||||
let current = state.fresh("Ada", "brass");
|
const migrated = state.migrateV1({
|
||||||
current = state.discover(current, "gate");
|
|
||||||
current = state.discover(current, "gate");
|
|
||||||
assert.deepEqual(current.discovered, ["gate"]);
|
|
||||||
assert.equal(current.complete, false);
|
|
||||||
|
|
||||||
for (const id of state.LANDMARK_IDS) current = state.discover(current, id);
|
|
||||||
assert.equal(current.complete, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normalization filters unknown discoveries and clamps position", () => {
|
|
||||||
const candidate = {
|
|
||||||
version: 1,
|
version: 1,
|
||||||
name: "Zaine",
|
name: "Mira",
|
||||||
palette: "brass",
|
palette: "berry",
|
||||||
discovered: ["gate", "gate", "missing"],
|
discovered: ["gate", "gate", "museum", "missing"],
|
||||||
complete: true,
|
|
||||||
position: { x: -20, y: 5000 },
|
|
||||||
soundEnabled: true
|
|
||||||
};
|
|
||||||
assert.deepEqual(state.normalize(candidate), {
|
|
||||||
version: 1,
|
|
||||||
name: "Zaine",
|
|
||||||
palette: "brass",
|
|
||||||
discovered: ["gate"],
|
|
||||||
complete: false,
|
complete: false,
|
||||||
position: { x: 36, y: 924 },
|
position: { x: 700, y: 850 },
|
||||||
soundEnabled: true
|
soundEnabled: true
|
||||||
});
|
});
|
||||||
|
assert.equal(migrated.version, 2);
|
||||||
|
assert.deepEqual(migrated.discovered, ["gate", "museum"]);
|
||||||
|
assert.equal(migrated.position.area, "town");
|
||||||
|
assert.equal(migrated.position.x, 700);
|
||||||
|
assert.equal(migrated.settings.soundEnabled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parse rejects malformed, unsupported, and incomplete saves", () => {
|
test("normalization deduplicates and filters restored collections", () => {
|
||||||
|
const candidate = state.fresh("Zaine", "brass");
|
||||||
|
candidate.discovered = ["gate", "gate", "missing"];
|
||||||
|
candidate.sigils = ["garden", "garden", "bad"];
|
||||||
|
candidate.inventory.push({ id: "ink_vial", quantity: 99 }, { id: "invalid", quantity: 2 });
|
||||||
|
candidate.defeatedBosses = ["sentinel", "sentinel", "fake"];
|
||||||
|
candidate.solvedPuzzles = ["gate", "gate", "fake"];
|
||||||
|
const restored = state.normalize(candidate);
|
||||||
|
assert.deepEqual(restored.discovered, ["gate"]);
|
||||||
|
assert.deepEqual(restored.sigils, ["garden"]);
|
||||||
|
assert.equal(restored.inventory.find((item) => item.id === "ink_vial").quantity, data.ITEMS.ink_vial.stack);
|
||||||
|
assert.deepEqual(restored.defeatedBosses, ["sentinel"]);
|
||||||
|
assert.deepEqual(restored.solvedPuzzles, ["gate"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quest restoration accepts only known statuses and bounded counters", () => {
|
||||||
|
const candidate = state.fresh("Ada", "brass");
|
||||||
|
candidate.quests.gate = { status: "active", step: 200, count: -5 };
|
||||||
|
candidate.quests.library = { status: "nonsense", step: 2, count: 3 };
|
||||||
|
const restored = state.normalize(candidate);
|
||||||
|
assert.deepEqual(restored.quests.gate, { status: "active", step: 20, count: 0 });
|
||||||
|
assert.deepEqual(restored.quests.library, { status: "locked", step: 2, count: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("position, health, settings and unsupported schemas recover safely", () => {
|
||||||
|
const candidate = state.fresh("Ada", "brass");
|
||||||
|
candidate.position = { area: "missing", x: -100, y: 9000, facing: "downward" };
|
||||||
|
candidate.health = 999;
|
||||||
|
candidate.settings = { soundEnabled: true, ambience: 9, music: -2, effects: "bad", assist: true };
|
||||||
|
const restored = state.normalize(candidate);
|
||||||
|
assert.equal(restored.position.area, "town");
|
||||||
|
assert.equal(restored.position.x, 24);
|
||||||
|
assert.equal(restored.position.y, 1062);
|
||||||
|
assert.equal(restored.position.facing, "north");
|
||||||
|
assert.equal(restored.health, restored.maxHealth);
|
||||||
|
assert.equal(restored.settings.ambience, 1);
|
||||||
|
assert.equal(restored.settings.music, 0);
|
||||||
|
assert.equal(restored.settings.effects, 0.65);
|
||||||
assert.equal(state.parse("{"), null);
|
assert.equal(state.parse("{"), null);
|
||||||
assert.equal(state.parse(JSON.stringify({ version: 2 })), null);
|
assert.equal(state.parse(JSON.stringify({ version: 99 })), null);
|
||||||
assert.equal(state.parse(JSON.stringify(state.fresh("", "brass"))), null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a valid save round-trips and reset creates an empty state", () => {
|
test("level thresholds and reset-ready fresh state are deterministic", () => {
|
||||||
const saved = state.discover(state.fresh("Mira", "berry"), "garden");
|
assert.equal(state.levelForXp(0), 1);
|
||||||
assert.deepEqual(state.parse(JSON.stringify(saved)), saved);
|
assert.equal(state.levelForXp(80), 2);
|
||||||
|
assert.equal(state.levelForXp(620), 5);
|
||||||
assert.deepEqual(state.fresh("Mira", "berry").discovered, []);
|
assert.deepEqual(state.fresh("Mira", "berry").discovered, []);
|
||||||
|
assert.equal(state.fresh("Mira", "berry").quests.gate.status, "locked");
|
||||||
});
|
});
|
||||||
|
|||||||
85
tests/archive-world-systems.test.cjs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const data = require("../assets/scripts/pages/archive-world-data.js");
|
||||||
|
const state = require("../assets/scripts/pages/archive-world-state.js");
|
||||||
|
const systems = require("../assets/scripts/pages/archive-world-systems.js");
|
||||||
|
|
||||||
|
test("movement normalization prevents faster diagonals", () => {
|
||||||
|
const straight = systems.normalizedVector(1, 0, 175);
|
||||||
|
const diagonal = systems.normalizedVector(1, 1, 175);
|
||||||
|
assert.equal(Math.round(Math.hypot(straight.x, straight.y)), 175);
|
||||||
|
assert.equal(Math.round(Math.hypot(diagonal.x, diagonal.y)), 175);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("landmark discoveries deduplicate and complete at eight", () => {
|
||||||
|
let current = state.fresh("Ada", "brass");
|
||||||
|
current = systems.discover(current, "gate");
|
||||||
|
current = systems.discover(current, "gate");
|
||||||
|
assert.deepEqual(current.discovered, ["gate"]);
|
||||||
|
data.LANDMARK_IDS.forEach((id) => { current = systems.discover(current, id); });
|
||||||
|
assert.equal(current.complete, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quest progression and completion grant each reward once", () => {
|
||||||
|
let current = state.fresh("Ada", "brass");
|
||||||
|
current = systems.startQuest(current, "gate");
|
||||||
|
assert.equal(current.quests.gate.status, "active");
|
||||||
|
current = systems.completeQuest(current, "gate");
|
||||||
|
const xp = current.xp;
|
||||||
|
assert.equal(current.quests.gate.status, "complete");
|
||||||
|
assert.deepEqual(current.sigils, ["gate"]);
|
||||||
|
assert.equal(current.inventory.some((item) => item.id === "lantern"), true);
|
||||||
|
current = systems.completeQuest(current, "gate");
|
||||||
|
assert.equal(current.xp, xp);
|
||||||
|
assert.deepEqual(current.sigils, ["gate"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inventory collection caps stacks and consumables heal", () => {
|
||||||
|
let current = state.fresh("Ada", "brass");
|
||||||
|
current = systems.addItem(current, "ink_vial", 30);
|
||||||
|
assert.equal(systems.itemQuantity(current, "ink_vial"), 9);
|
||||||
|
current.health = 30;
|
||||||
|
const used = systems.consumeItem(current, "ink_vial");
|
||||||
|
assert.equal(used.used, true);
|
||||||
|
assert.equal(used.state.health, 65);
|
||||||
|
assert.equal(systems.itemQuantity(used.state, "ink_vial"), 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("damage respects invulnerability and assist, defeat preserves progress", () => {
|
||||||
|
let current = state.fresh("Ada", "brass");
|
||||||
|
current.sigils = ["gate"];
|
||||||
|
const assisted = state.normalize({ ...current, settings: { ...current.settings, assist: true } });
|
||||||
|
const first = systems.takeDamage(assisted, 20, 2000, 0);
|
||||||
|
assert.equal(first.state.health, 90);
|
||||||
|
const ignored = systems.takeDamage(first.state, 20, 2200, 2000);
|
||||||
|
assert.equal(ignored.hit, false);
|
||||||
|
const defeated = systems.takeDamage({ ...first.state, health: 5, settings: { ...first.state.settings, assist: false } }, 20, 5000, 0);
|
||||||
|
assert.equal(defeated.defeated, true);
|
||||||
|
const respawned = systems.respawn(defeated.state);
|
||||||
|
assert.deepEqual(respawned.sigils, ["gate"]);
|
||||||
|
assert.equal(respawned.health >= 50, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("XP levels, boss completion and sigil story stage restore correctly", () => {
|
||||||
|
let current = state.fresh("Ada", "brass");
|
||||||
|
current = systems.grantXp(current, 200);
|
||||||
|
assert.equal(current.level, 3);
|
||||||
|
assert.equal(current.maxHealth, 120);
|
||||||
|
current = systems.recordBoss(current, "sentinel");
|
||||||
|
assert.equal(current.defeatedBosses.includes("sentinel"), true);
|
||||||
|
assert.equal(current.quests.workshop.status, "complete");
|
||||||
|
data.LANDMARK_IDS.forEach((id) => {
|
||||||
|
if (!current.sigils.includes(id)) current = systems.completeQuest(current, id);
|
||||||
|
});
|
||||||
|
assert.equal(current.sigils.length, 8);
|
||||||
|
assert.equal(current.story.stage, "vault");
|
||||||
|
current = systems.recordBoss(current, "redactor");
|
||||||
|
assert.equal(current.story.stage, "postgame");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid collision positions resolve to a named safe spawn", () => {
|
||||||
|
assert.equal(systems.isSafe("town", 200, 150), false);
|
||||||
|
const safe = systems.nearestSafeSpawn("town", 200, 150);
|
||||||
|
assert.equal(systems.isSafe(safe.area, safe.x, safe.y), true);
|
||||||
|
assert.ok(["gate", "square", "library", "study", "inn", "workshop", "playroom", "museum", "garden"].includes(safe.spawn));
|
||||||
|
});
|
||||||