Refactor org platform navigation and content flows
All checks were successful
Build Org Website / build (push) Successful in 38s

This commit is contained in:
gitea-actions
2026-07-29 21:42:31 +01:00
parent 7afc554f5d
commit 5b59f5745c
42 changed files with 2564 additions and 588 deletions

0
assets/audio/archive-world/ambient.ogg Normal file → Executable file
View File

0
assets/audio/archive-world/discover.wav Normal file → Executable file
View File

0
assets/audio/archive-world/portal.wav Normal file → Executable file
View File

0
assets/audio/archive-world/step.wav Normal file → Executable file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

0
assets/images/play/archive-world/archive-town.webp Normal file → Executable file
View 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
View File

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

View 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));

View 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));

View 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));

View File

@@ -1,26 +1,24 @@
(function (root, factory) {
"use strict";
const api = factory();
const data = root.ArchiveWorldData || (typeof require === "function" ? require("./archive-world-data.js") : null);
const api = factory(data);
if (typeof module === "object" && module.exports) module.exports = api;
root.ArchiveWorldState = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function () {
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data) {
"use strict";
const VERSION = 1;
const STORAGE_KEY = "zxh_archive_world_v1";
const VERSION = 2;
const STORAGE_KEY = "zxh_archive_world_v2";
const LEGACY_KEY = "zxh_archive_world_v1";
const PALETTES = Object.freeze(["brass", "moss", "berry"]);
const LANDMARK_IDS = Object.freeze([
"gate",
"library",
"study",
"inn",
"workshop",
"playroom",
"museum",
"garden"
]);
const SPAWN = Object.freeze({ x: 640, y: 830 });
const FACES = Object.freeze(["north", "south", "east", "west"]);
const SPAWN = Object.freeze({ area: "town", spawn: "gate", x: 724, y: 965 });
const LANDMARK_IDS = Data ? Data.LANDMARK_IDS : Object.freeze(["gate", "library", "study", "inn", "workshop", "playroom", "museum", "garden"]);
const QUEST_IDS = Data ? Data.QUEST_IDS : Object.freeze(LANDMARK_IDS.concat("lost_letter"));
const ITEM_IDS = Data ? Data.ITEM_IDS : Object.freeze(["living_bookmark", "lantern", "archive_key", "swiftstep_boots", "ink_vial", "memory_fragment", "garden_seed", "workshop_cog", "museum_lens", "recall_stew", "lore_page", "archive_sigil"]);
const AREA_IDS = Data ? Data.AREA_IDS : Object.freeze(["town", "vault", "workshop", "playroom", "boss"]);
const BOSS_IDS = Object.freeze(["sentinel", "redactor"]);
const LEVELS = Object.freeze([0, 80, 200, 380, 620]);
function validateName(value) {
const name = String(value || "").trim().replace(/\s+/g, " ");
@@ -31,21 +29,92 @@
return PALETTES.includes(value) ? value : null;
}
function finite(value, fallback, min, max) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
}
function unique(values, allowed) {
return Array.isArray(values) ? Array.from(new Set(values.filter((id) => allowed.includes(id)))) : [];
}
function questDefaults() {
return Object.fromEntries(QUEST_IDS.map((id) => [id, { status: "locked", step: 0, count: 0 }]));
}
function fresh(name, palette) {
return {
version: VERSION,
name: validateName(name) || "",
palette: validatePalette(palette) || "brass",
health: 100,
maxHealth: 100,
level: 1,
xp: 0,
attack: 1,
position: { area: SPAWN.area, spawn: SPAWN.spawn, x: SPAWN.x, y: SPAWN.y, facing: "north" },
checkpoint: { area: SPAWN.area, spawn: SPAWN.spawn, x: SPAWN.x, y: SPAWN.y },
discovered: [],
complete: false,
position: { x: SPAWN.x, y: SPAWN.y },
soundEnabled: false
story: { stage: "arrival", ending: false, midpointSeen: false },
quests: questDefaults(),
inventory: [{ id: "living_bookmark", quantity: 1 }, { id: "ink_vial", quantity: 2 }],
selectedItem: "ink_vial",
equipment: { bookmark: true, boots: false, lens: false },
sigils: [],
defeatedBosses: [],
openedChests: [],
solvedPuzzles: [],
npcFlags: {},
settings: {
soundEnabled: false,
ambience: 0.35,
music: 0.3,
effects: 0.65,
assist: false,
reducedMotion: null
}
};
}
function clamp(value, min, max, fallback) {
const number = Number(value);
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
function normalizePosition(candidate, fallback) {
const area = candidate && AREA_IDS.includes(candidate.area) ? candidate.area : fallback.area;
const areaData = Data && Data.AREAS[area];
const defaultSpawn = areaData ? areaData.spawn : { x: fallback.x, y: fallback.y };
const width = areaData ? areaData.width : 1448;
const height = areaData ? areaData.height : 1086;
return {
area,
spawn: String(candidate && candidate.spawn || fallback.spawn || "entrance").slice(0, 30),
x: finite(candidate && candidate.x, defaultSpawn.x, 24, width - 24),
y: finite(candidate && candidate.y, defaultSpawn.y, 24, height - 24),
facing: candidate && FACES.includes(candidate.facing) ? candidate.facing : "north"
};
}
function normalizeInventory(value) {
const quantities = new Map();
if (Array.isArray(value)) value.forEach((entry) => {
if (!entry || !ITEM_IDS.includes(entry.id)) return;
const cap = Data && Data.ITEMS[entry.id] && Data.ITEMS[entry.id].stack || 1;
quantities.set(entry.id, Math.min(cap, (quantities.get(entry.id) || 0) + Math.floor(finite(entry.quantity, 1, 1, cap))));
});
if (!quantities.has("living_bookmark")) quantities.set("living_bookmark", 1);
return Array.from(quantities, ([id, quantity]) => ({ id, quantity }));
}
function normalizeQuests(value) {
const defaults = questDefaults();
QUEST_IDS.forEach((id) => {
const source = value && value[id];
if (!source) return;
defaults[id] = {
status: ["locked", "active", "complete"].includes(source.status) ? source.status : "locked",
step: Math.floor(finite(source.step, 0, 0, 20)),
count: Math.floor(finite(source.count, 0, 0, 99))
};
});
return defaults;
}
function normalize(candidate) {
@@ -54,61 +123,115 @@
const palette = validatePalette(candidate.palette);
if (!name || !palette) return null;
const discovered = Array.isArray(candidate.discovered)
? Array.from(new Set(candidate.discovered.filter((id) => LANDMARK_IDS.includes(id))))
: [];
const discovered = unique(candidate.discovered, LANDMARK_IDS);
const sigils = unique(candidate.sigils, LANDMARK_IDS);
const xp = Math.floor(finite(candidate.xp, 0, 0, 99999));
const level = levelForXp(xp);
const expectedMax = 100 + (level >= 2 ? 20 : 0) + (level >= 4 ? 20 : 0);
const maxHealth = finite(candidate.maxHealth, expectedMax, expectedMax, expectedMax);
const position = normalizePosition(candidate.position, SPAWN);
const checkpoint = normalizePosition(candidate.checkpoint, SPAWN);
const inventory = normalizeInventory(candidate.inventory);
const selected = ITEM_IDS.includes(candidate.selectedItem) ? candidate.selectedItem : "ink_vial";
return {
version: VERSION,
name,
palette,
health: finite(candidate.health, maxHealth, 0, maxHealth),
maxHealth,
level,
xp,
attack: sigils.includes("workshop") ? 2 : 1,
position,
checkpoint: { area: checkpoint.area, spawn: checkpoint.spawn, x: checkpoint.x, y: checkpoint.y },
discovered,
complete: LANDMARK_IDS.every((id) => discovered.includes(id)),
position: {
x: clamp(candidate.position && candidate.position.x, 36, 1244, SPAWN.x),
y: clamp(candidate.position && candidate.position.y, 36, 924, SPAWN.y)
story: {
stage: ["arrival", "first_act", "midpoint", "escalation", "vault", "postgame"].includes(candidate.story && candidate.story.stage)
? candidate.story.stage : "arrival",
ending: candidate.story && candidate.story.ending === true,
midpointSeen: candidate.story && candidate.story.midpointSeen === true
},
soundEnabled: candidate.soundEnabled === true
quests: normalizeQuests(candidate.quests),
inventory,
selectedItem: selected,
equipment: {
bookmark: true,
boots: candidate.equipment && candidate.equipment.boots === true,
lens: candidate.equipment && candidate.equipment.lens === true
},
sigils,
defeatedBosses: unique(candidate.defeatedBosses, BOSS_IDS),
openedChests: Array.isArray(candidate.openedChests) ? Array.from(new Set(candidate.openedChests.filter((id) => typeof id === "string"))).slice(0, 64) : [],
solvedPuzzles: unique(candidate.solvedPuzzles, QUEST_IDS),
npcFlags: candidate.npcFlags && typeof candidate.npcFlags === "object" && !Array.isArray(candidate.npcFlags)
? Object.fromEntries(Object.entries(candidate.npcFlags).filter(([key, value]) => /^[a-z0-9_-]{1,40}$/.test(key) && ["string", "number", "boolean"].includes(typeof value)).slice(0, 64))
: {},
settings: {
soundEnabled: candidate.settings && candidate.settings.soundEnabled === true,
ambience: finite(candidate.settings && candidate.settings.ambience, 0.35, 0, 1),
music: finite(candidate.settings && candidate.settings.music, 0.3, 0, 1),
effects: finite(candidate.settings && candidate.settings.effects, 0.65, 0, 1),
assist: candidate.settings && candidate.settings.assist === true,
reducedMotion: candidate.settings && typeof candidate.settings.reducedMotion === "boolean" ? candidate.settings.reducedMotion : null
}
};
}
function migrateV1(candidate) {
if (!candidate || candidate.version !== 1) return null;
const name = validateName(candidate.name);
const palette = validatePalette(candidate.palette);
if (!name || !palette) return null;
const migrated = fresh(name, palette);
migrated.discovered = unique(candidate.discovered, LANDMARK_IDS);
migrated.complete = LANDMARK_IDS.every((id) => migrated.discovered.includes(id));
migrated.position = normalizePosition({
area: "town", spawn: "gate",
x: candidate.position && candidate.position.x,
y: candidate.position && candidate.position.y,
facing: "north"
}, SPAWN);
migrated.checkpoint = { area: "town", spawn: "gate", x: SPAWN.x, y: SPAWN.y };
migrated.settings.soundEnabled = candidate.soundEnabled === true;
return normalize(migrated);
}
function parse(raw) {
if (!raw) return null;
try {
return normalize(JSON.parse(raw));
const candidate = JSON.parse(raw);
return candidate.version === 1 ? migrateV1(candidate) : normalize(candidate);
} catch (_error) {
return null;
}
}
function discover(state, landmarkId) {
function levelForXp(xp) {
let level = 1;
LEVELS.forEach((threshold, index) => { if (xp >= threshold) level = index + 1; });
return Math.min(5, level);
}
function withPosition(state, area, x, y, facing, spawn) {
const current = normalize(state);
if (!current || !LANDMARK_IDS.includes(landmarkId)) return current;
if (!current.discovered.includes(landmarkId)) current.discovered.push(landmarkId);
current.complete = LANDMARK_IDS.every((id) => current.discovered.includes(id));
if (!current) return null;
current.position = normalizePosition({ area, x, y, facing, spawn }, current.position);
return current;
}
function withPosition(state, x, y) {
function withCheckpoint(state, area, spawn, x, y) {
const current = normalize(state);
if (!current) return null;
current.position.x = clamp(x, 36, 1244, SPAWN.x);
current.position.y = clamp(y, 36, 924, SPAWN.y);
const next = normalizePosition({ area, spawn, x, y }, SPAWN);
current.checkpoint = { area: next.area, spawn: next.spawn, x: next.x, y: next.y };
return current;
}
return Object.freeze({
VERSION,
STORAGE_KEY,
PALETTES,
LANDMARK_IDS,
SPAWN,
validateName,
validatePalette,
fresh,
normalize,
parse,
discover,
withPosition
VERSION, STORAGE_KEY, LEGACY_KEY, PALETTES, LANDMARK_IDS, QUEST_IDS, ITEM_IDS, AREA_IDS, BOSS_IDS, LEVELS, SPAWN,
validateName, validatePalette, fresh, normalize, parse, migrateV1, normalizePosition, normalizeInventory,
normalizeQuests, levelForXp, withPosition, withCheckpoint
});
}));

View 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
});
}));

View 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));

View File

@@ -1,420 +1,401 @@
(function () {
"use strict";
const Data = window.ArchiveWorldData;
const State = window.ArchiveWorldState;
const LANDMARKS = Object.freeze({
gate: landmark("Town Gate", "Arrivals", 640, 830,
"The old gate remembers every arrival. Begin at the dashboard or follow the newest paths.",
[["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"]]),
library: landmark("Grand Library", "Knowledge", 260, 285,
"Long-lived explanations, categories, and learning notes fill the blue-roofed library.",
[["Posts", "/posts/posts-list.html"], ["Categories", "/home/categories.html"], ["Posts introduction", "/posts/posts-intro.html"]]),
study: landmark("Guild Study", "Work", 640, 250,
"Engineering notes, professional lessons, and active competencies cover every desk.",
[["Career", "/posts/career/career-list.html"], ["Competency status", "/home/status.html"], ["Probation objectives", "/posts/career/probation-objectives.html"]]),
inn: landmark("Kitchen Inn", "Daily life", 1005, 270,
"Weekly reviews and ordinary days stay warm beside the inn's oven.",
[["Blogs", "/blogs/blogs-list.html"], ["Weekly reviews", "/tags/review.html"], ["Blogs introduction", "/blogs/blogs-intro.html"]]),
workshop: landmark("Workshop", "Living systems", 1030, 535,
"Trackers, services, plans, and practical machinery keep the town moving.",
[["Services", "/home/services.html"], ["Wird tracker", "/home/wird-tracker.html"], ["Countdowns", "/home/countdown.html"], ["Backlog", "/home/backlog.html"]]),
playroom: landmark("Playroom", "Experiments", 1005, 765,
"Games, generators, and stranger little mechanisms glow behind the bright windows.",
[["Play hub", "/play/play.html"], ["The Rain Index", "/play/the-rain-index.html"], ["House of Pages", "/play/house.html"]]),
museum: landmark("Lima Museum", "Keepsakes", 275, 765,
"Personal fragments, older writing, and small memories rest beneath the glass roof.",
[["Lima archive", "/lima/index.html"], ["Older writing", "/blogs/2025/2025-list.html"], ["Memory Cabinet", "/play/memory.html"]]),
garden: landmark("Notes Garden", "Connections", 220, 515,
"Loose notes, paths between topics, and recently tended pages grow beyond the trellis.",
[["Notes wall", "/home/notes.html"], ["Categories", "/home/categories.html"], ["Recently updated", "/recently-updated.html"], ["Sitemap", "/sitemap.html"]])
});
const PALETTE_TINTS = Object.freeze({
brass: 0xf1c46f,
moss: 0x91c788,
berry: 0xd894ad
});
const Systems = window.ArchiveWorldSystems;
const UI = window.ArchiveWorldUI;
const Scenes = window.ArchiveWorldScenes;
const Audio = window.ArchiveWorldAudio;
const controller = {
root: null,
state: null,
game: null,
scene: null,
ui: null,
audio: null,
lock: false,
move: { up: false, down: false, left: false, right: false },
reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
saveTimer: 0
prompt: "",
lastSaveAt: 0,
systemReducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
getState: () => controller.state,
locked: () => controller.lock,
reducedMotion: () => controller.state && controller.state.settings.reducedMotion !== null
? controller.state.settings.reducedMotion : controller.systemReducedMotion
};
function landmark(title, eyebrow, x, y, description, links) {
return Object.freeze({ title, eyebrow, x, y, description, links: Object.freeze(links) });
}
document.addEventListener("DOMContentLoaded", init);
function init() {
const root = document.querySelector('.archive-world[data-play-page="archive-world"]');
if (!root || !State) return;
if (!root || !Data || !State || !Systems || !UI || !Scenes || !Audio) return;
controller.root = root;
controller.audio = Audio.create(() => controller.state);
bindSetup();
bindControls();
bindSettings();
controller.ui = UI.create(root, {
getState: () => controller.state,
onLock: (locked) => { controller.lock = locked; },
onStartQuest: startQuest,
onCompleteQuest: completeQuest,
onTravel: travel,
onUseItem: useItem,
onChoice: completeChoice,
onRespawn: respawn,
onFullscreen: toggleFullscreen,
onReset: resetAll
});
const stored = State.parse(localStorage.getItem(State.STORAGE_KEY));
controller.state = stored;
bindInterface(root);
if (!window.Phaser) {
setStatus(root, "The world engine could not load. Every destination remains available in the plain directory.");
return;
}
if (stored) {
startGame(root);
const loaded = loadState();
if (loaded.state) {
controller.state = loaded.state;
renderHud();
startGame();
if (loaded.message) status(loaded.message);
} else {
openSetup(root);
openSetup(loaded.message);
}
}
function bindInterface(root) {
const setup = root.querySelector("[data-world-setup]");
const setupForm = root.querySelector("[data-world-setup-form]");
const preview = root.querySelector("[data-world-preview]");
const journal = root.querySelector("[data-world-journal]");
function loadState() {
try {
const rawV2 = localStorage.getItem(State.STORAGE_KEY);
if (rawV2) {
const parsed = State.parse(rawV2);
return parsed
? { state: ensureSafePosition(parsed), message: null }
: { state: null, message: "The saved Archive World data was malformed, so a clean traveler setup is ready." };
}
const rawV1 = localStorage.getItem(State.LEGACY_KEY);
if (rawV1) {
const migrated = State.parse(rawV1);
if (migrated) {
persistState(migrated);
return { state: ensureSafePosition(migrated), message: "Your original Archive World traveler was safely migrated to the new adventure." };
}
return { state: null, message: "The older save could not be restored, so a clean traveler setup is ready." };
}
} catch (_error) {
return { state: null, message: "Local saving is unavailable. You can still play during this visit." };
}
return { state: null, message: null };
}
setup.addEventListener("cancel", (event) => event.preventDefault());
setupForm.addEventListener("submit", (event) => {
function ensureSafePosition(state) {
if (state.health <= 0) state = Systems.respawn(state);
const safe = Systems.nearestSafeSpawn(state.position.area, state.position.x, state.position.y);
return State.withPosition(state, safe.area, safe.x, safe.y, state.position.facing, safe.spawn);
}
function bindSetup() {
const dialog = controller.root.querySelector("[data-world-setup]");
const form = controller.root.querySelector("[data-world-setup-form]");
dialog.addEventListener("cancel", (event) => event.preventDefault());
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(setupForm);
const name = State.validateName(data.get("name"));
const palette = State.validatePalette(data.get("palette"));
const error = root.querySelector("[data-world-setup-error]");
const formData = new FormData(form);
const name = State.validateName(formData.get("name"));
const palette = State.validatePalette(formData.get("palette"));
const error = controller.root.querySelector("[data-world-setup-error]");
if (!name || !palette) {
error.textContent = "Choose a palette and enter a name between 1 and 20 characters.";
return;
}
controller.state = State.fresh(name, palette);
persist();
setup.close();
startGame(root);
});
root.querySelector("[data-world-preview-close]").addEventListener("click", () => preview.close());
preview.addEventListener("close", () => {
controller.lock = false;
root.querySelector("#archive-world-game").focus();
});
root.querySelector("[data-world-journal-open]").addEventListener("click", () => {
renderJournal(root);
controller.lock = true;
journal.showModal();
});
root.querySelector("[data-world-journal-close]").addEventListener("click", () => journal.close());
journal.addEventListener("close", () => {
controller.lock = false;
root.querySelector("[data-world-journal-open]").focus();
});
root.querySelector("[data-world-sound]").addEventListener("click", () => toggleSound(root));
root.querySelector("[data-world-reset]").addEventListener("click", () => {
if (!window.confirm("Reset your traveler, discoveries, and saved position?")) return;
localStorage.removeItem(State.STORAGE_KEY);
window.location.reload();
});
root.querySelectorAll("[data-world-move]").forEach((button) => {
const direction = button.dataset.worldMove;
const down = (event) => {
event.preventDefault();
controller.move[direction] = true;
};
const up = (event) => {
event.preventDefault();
controller.move[direction] = false;
};
button.addEventListener("pointerdown", down);
button.addEventListener("pointerup", up);
button.addEventListener("pointercancel", up);
button.addEventListener("pointerleave", up);
});
root.querySelector("[data-world-interact]").addEventListener("click", () => {
if (controller.scene) controller.scene.interact();
});
window.addEventListener("pagehide", savePosition);
document.addEventListener("visibilitychange", () => {
if (document.hidden) savePosition();
persistState(controller.state);
dialog.close();
renderHud();
startGame();
});
}
function openSetup(root) {
const dialog = root.querySelector("[data-world-setup]");
function openSetup(message) {
const dialog = controller.root.querySelector("[data-world-setup]");
if (message) {
controller.root.querySelector("[data-world-setup-error]").textContent = message;
status(message);
}
window.setTimeout(() => {
dialog.showModal();
dialog.querySelector("input[name=name]").focus();
}, 0);
}
function startGame(root) {
renderHud(root);
const game = new Phaser.Game({
type: Phaser.AUTO,
function bindControls() {
controller.root.querySelectorAll("[data-world-move]").forEach((button) => {
const direction = button.dataset.worldMove;
const down = (event) => {
event.preventDefault();
controller.move[direction] = true;
controller.root.dataset.touching = "true";
};
const up = (event) => {
event.preventDefault();
controller.move[direction] = false;
controller.root.dataset.touching = "false";
};
button.addEventListener("pointerdown", down);
["pointerup", "pointercancel", "pointerleave"].forEach((name) => button.addEventListener(name, up));
});
controller.root.querySelector("[data-world-interact]").addEventListener("click", () => {
if (controller.scene) controller.scene.interact();
});
controller.root.querySelector("[data-world-attack]").addEventListener("click", () => {
if (controller.scene) controller.scene.attack(controller.scene.time.now);
});
controller.root.querySelector("[data-world-item]").addEventListener("click", useSelectedItem);
controller.root.querySelector("[data-world-menu]").addEventListener("click", openMenu);
controller.root.querySelector("[data-world-quests]").addEventListener("click", () => controller.ui.openMenu(controller.state, "quests"));
controller.root.querySelector("[data-world-inventory]").addEventListener("click", () => controller.ui.openMenu(controller.state, "inventory"));
controller.root.querySelector("[data-world-journal-open]").addEventListener("click", () => controller.ui.openMenu(controller.state, "journal"));
controller.root.querySelector("[data-world-sound]").addEventListener("click", toggleSound);
controller.root.querySelector("[data-world-fullscreen]").addEventListener("click", toggleFullscreen);
controller.root.querySelector("[data-world-reset]").addEventListener("click", () => controller.ui.openMenu(controller.state, "settings"));
document.addEventListener("fullscreenchange", updateFullscreen);
document.addEventListener("visibilitychange", () => {
if (document.hidden && controller.scene) controller.scene.persistPosition(false);
});
window.addEventListener("pagehide", () => {
if (controller.scene) controller.scene.persistPosition(false);
});
if (!document.fullscreenEnabled) controller.root.querySelectorAll("[data-world-fullscreen]").forEach((button) => { button.hidden = true; });
}
function bindSettings() {
controller.root.addEventListener("change", (event) => {
if (!controller.state) return;
if (event.target.matches("[data-setting='assist']")) {
controller.state.settings.assist = event.target.checked;
setState(controller.state, event.target.checked ? "Assist mode enabled." : "Assist mode disabled.");
} else if (event.target.matches("[data-volume]")) {
controller.state.settings[event.target.dataset.volume] = Number(event.target.value);
setState(controller.state, null, true);
controller.audio.apply();
}
});
}
function startGame() {
if (!window.Phaser) {
status("The world engine could not load. Every destination remains available in the plain directory.");
return;
}
const sceneClasses = Scenes.createSceneClasses(controller);
controller.game = new Phaser.Game({
type: Phaser.CANVAS,
parent: "archive-world-game",
width: 960,
height: 640,
backgroundColor: "#17251e",
backgroundColor: "#101b18",
pixelArt: true,
roundPixels: true,
physics: {
default: "arcade",
arcade: { debug: false }
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
scene: ArchiveTownScene,
input: {
keyboard: true,
mouse: true,
touch: true
}
physics: { default: "arcade", arcade: { debug: false } },
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
scene: sceneClasses,
input: { keyboard: true, mouse: true, touch: true },
render: { antialias: false, pixelArt: true, roundPixels: true }
});
root.dataset.gameReady = "true";
root.querySelector("#archive-world-game").focus();
window.archiveWorldGame = game;
controller.root.dataset.gameReady = "true";
controller.root.querySelector("#archive-world-game").focus();
window.archiveWorldGame = controller.game;
}
class ArchiveTownScene extends Phaser.Scene {
constructor() {
super("ArchiveTown");
this.nearest = null;
this.lastStepAt = 0;
this.wasMoving = false;
function startQuest(id) {
setState(Systems.startQuest(controller.state, id), `${Data.QUESTS[id].title} added to the quest log.`);
controller.audio.play("quest");
}
preload() {
this.load.image("archive-town", "/assets/images/play/archive-world/archive-town.webp");
this.load.image("traveler", "/assets/images/play/archive-world/traveler.png");
this.load.audio("ambient", "/assets/audio/archive-world/ambient.ogg");
this.load.audio("step", "/assets/audio/archive-world/step.wav");
this.load.audio("discover", "/assets/audio/archive-world/discover.wav");
this.load.audio("portal", "/assets/audio/archive-world/portal.wav");
function completeQuest(id) {
let next = Systems.completeQuest(controller.state, id);
if (id === "museum" && next.sigils.length >= 4) {
next.story.midpointSeen = true;
next = Systems.updateStory(next);
status("Midpoint revelation: the Redactor was built to preserve the archive, but learned to mistake emptiness for safety.");
}
setState(next, `${Data.QUESTS[id].title} complete. The ${Data.LANDMARKS[Data.QUESTS[id].landmark].title} Sigil is restored.`);
controller.audio.play("quest");
}
create() {
controller.scene = this;
this.add.image(0, 0, "archive-town").setOrigin(0).setDepth(0);
this.physics.world.setBounds(0, 0, 1280, 960);
this.markers = {};
Object.entries(LANDMARKS).forEach(([id, item]) => {
const marker = this.add.circle(item.x, item.y, 22, 0xf4d98a, 0.16)
.setStrokeStyle(3, 0xf8e6a7, 0.9)
.setDepth(2);
marker.setData("landmarkId", id);
this.markers[id] = marker;
if (!controller.reducedMotion) {
this.tweens.add({
targets: marker,
scale: 1.18,
alpha: 0.58,
duration: 900,
yoyo: true,
repeat: -1,
delay: Object.keys(LANDMARKS).indexOf(id) * 90
});
}
});
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();
function completeChoice(choice) {
let next = Systems.startQuest(controller.state, "lost_letter");
next.npcFlags.nellChoice = choice;
next = Systems.completeQuest(next, "lost_letter");
setState(next, "Nell remembers that choosing what to preserve is part of the story.");
controller.audio.play("dialogue");
}
update(time) {
if (!this.player) return;
let dx = 0;
let dy = 0;
if (!controller.lock) {
if (this.cursors.left.isDown || this.keys.left.isDown || controller.move.left) dx -= 1;
if (this.cursors.right.isDown || this.keys.right.isDown || controller.move.right) dx += 1;
if (this.cursors.up.isDown || this.keys.up.isDown || controller.move.up) dy -= 1;
if (this.cursors.down.isDown || this.keys.down.isDown || controller.move.down) dy += 1;
function openLandmark(id) {
const wasNew = !controller.state.discovered.includes(id);
setState(Systems.discover(controller.state, id), wasNew ? `${Data.LANDMARKS[id].title} added to the discovery journal.` : null);
if (wasNew) controller.audio.play("quest", { volume: 0.7 });
controller.ui.openLandmark(id, controller.state);
}
const moving = dx !== 0 || dy !== 0;
if (moving) {
const vector = new Phaser.Math.Vector2(dx, dy).normalize().scale(175);
this.player.setVelocity(vector.x, vector.y);
if (dx) this.player.setFlipX(dx < 0);
if (!controller.reducedMotion) {
this.player.rotation = Math.sin(time / 80) * 0.025;
this.player.setScale(1, 0.98 + Math.abs(Math.sin(time / 95)) * 0.04);
}
if (controller.state.soundEnabled && time - this.lastStepAt > 340) {
this.stepSound.play();
this.lastStepAt = time;
}
if (time - controller.saveTimer > 700) {
controller.saveTimer = time;
savePosition();
}
} else {
this.player.setVelocity(0, 0);
this.player.rotation = 0;
this.player.setScale(1);
}
this.wasMoving = moving;
this.updateNearest();
if (!controller.lock && (
Phaser.Input.Keyboard.JustDown(this.keys.interact) ||
Phaser.Input.Keyboard.JustDown(this.keys.enter) ||
Phaser.Input.Keyboard.JustDown(this.cursors.space)
)) this.interact();
function openNpc(id) {
controller.audio.play("dialogue", { volume: 0.45 });
controller.ui.openNpc(id, controller.state);
}
updateNearest() {
let nearest = null;
let distance = Infinity;
Object.entries(LANDMARKS).forEach(([id, item]) => {
const nextDistance = Phaser.Math.Distance.Between(this.player.x, this.player.y, item.x, item.y);
if (nextDistance < distance) {
nearest = id;
distance = nextDistance;
}
this.markers[id].setStrokeStyle(nextDistance < 95 ? 5 : 3, nextDistance < 95 ? 0xffffff : 0xf8e6a7, 0.95);
});
const next = distance < 95 ? nearest : null;
if (next !== this.nearest) {
this.nearest = next;
const root = document.querySelector(".archive-world");
setStatus(root, next
? `${LANDMARKS[next].title} is nearby. Press E, Space, Enter, or Explore.`
: "Follow the stone paths toward a glowing landmark.");
}
function openChallenge(id) {
if (controller.state.quests[id].status === "locked") controller.state = Systems.startQuest(controller.state, id);
controller.ui.openChallenge(id, controller.state);
}
interact() {
if (controller.lock) return;
if (!this.nearest) {
setStatus(document.querySelector(".archive-world"), "Move closer to one of the glowing landmark circles.");
function openMenu() {
if (!controller.state || controller.ui.isOpen()) return;
controller.ui.openMenu(controller.state, "quests");
}
function travel(area) {
if (!Data.AREAS[area]) return;
const spawn = Data.AREAS[area].spawn;
controller.state = State.withPosition(controller.state, area, spawn.x, spawn.y, "north", "entrance");
controller.state = State.withCheckpoint(controller.state, area, "entrance", spawn.x, spawn.y);
persistState(controller.state);
if (controller.scene) controller.scene.scene.restart({ area });
}
function useSelectedItem() {
useItem(controller.state.selectedItem);
}
function useItem(id) {
const result = Systems.consumeItem(controller.state, id);
if (!result.used) {
status(controller.state.health >= controller.state.maxHealth ? "Health is already full." : "That item cannot be used now.");
return;
}
openPreview(document.querySelector(".archive-world"), this.nearest);
setState(result.state, `${Data.ITEMS[id].name} restored ${result.amount} health.`);
controller.audio.play("item");
}
enableSound() {
if (this.sound.locked && this.sound.unlock) this.sound.unlock();
this.sound.mute = false;
if (!this.ambient.isPlaying) this.ambient.play();
function cycleItem() {
const consumables = controller.state.inventory.filter((entry) => Data.ITEMS[entry.id].type === "consumable");
if (!consumables.length) return status("No usable items are in the inventory.");
const index = consumables.findIndex((entry) => entry.id === controller.state.selectedItem);
controller.state.selectedItem = consumables[(index + 1) % consumables.length].id;
setState(controller.state, `${Data.ITEMS[controller.state.selectedItem].name} selected.`);
}
disableSound() {
this.sound.mute = true;
if (this.ambient.isPlaying) this.ambient.pause();
function respawn() {
const next = Systems.respawn(controller.state);
controller.state = next;
persistState(next);
travel(next.position.area);
status("The Living Bookmark returns you to a safe place. Story and items were preserved.");
}
function toggleSound() {
if (!controller.state) return;
controller.state.settings.soundEnabled = !controller.state.settings.soundEnabled;
if (controller.state.settings.soundEnabled) controller.audio.unlock();
controller.audio.apply();
setState(controller.state, controller.state.settings.soundEnabled ? "Archive World audio enabled." : "Audio muted.");
}
async function toggleFullscreen() {
if (!document.fullscreenEnabled) return;
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await controller.root.querySelector("[data-world-shell]").requestFullscreen();
} catch (_error) {
status("Fullscreen is unavailable in this browser.");
}
}
function openPreview(root, id) {
const item = LANDMARKS[id];
if (!item) return;
const wasDiscovered = controller.state.discovered.includes(id);
controller.state = State.discover(controller.state, id);
persist();
function updateFullscreen() {
const active = Boolean(document.fullscreenElement);
controller.root.querySelectorAll("[data-world-fullscreen]").forEach((button) => {
button.textContent = active ? "Exit fullscreen" : "Fullscreen";
button.setAttribute("aria-pressed", String(active));
});
if (controller.game) controller.game.scale.refresh();
if (!active) controller.root.querySelector("#archive-world-game").focus();
}
function resetAll() {
try {
localStorage.removeItem(State.STORAGE_KEY);
localStorage.removeItem(State.LEGACY_KEY);
} catch (_error) {
// Reload still provides a fresh in-memory state.
}
window.location.reload();
}
function setState(next, message, quiet) {
controller.state = State.normalize(next) || controller.state;
if (!quiet) renderHud();
persistState(controller.state);
if (message) status(message);
}
function persistState(state) {
try {
localStorage.setItem(State.STORAGE_KEY, JSON.stringify(State.normalize(state)));
} catch (_error) {
status("Progress could not be saved on this device.");
}
}
function renderHud() {
if (!controller.state) return;
const state = controller.state;
text("[data-world-player]", state.name);
text("[data-world-level]", `Lv ${state.level}`);
text("[data-world-health-text]", `${Math.ceil(state.health)} / ${state.maxHealth}`);
controller.root.querySelector("[data-world-health-bar]").style.width = `${(state.health / state.maxHealth) * 100}%`;
text("[data-world-objective]", Systems.currentObjective(state));
text("[data-world-discovery-count]", `${state.discovered.length} / 8`);
text("[data-world-sigil-count]", `${state.sigils.length} / 8`);
text("[data-world-sound]", state.settings.soundEnabled ? "Sound: on" : "Sound: muted");
const selected = Data.ITEMS[state.selectedItem];
text("[data-world-selected-item]", selected ? `${selected.name} ×${Systems.itemQuantity(state, state.selectedItem)}` : "None");
controller.root.querySelector("[data-world-badge]").hidden = !state.complete;
controller.root.classList.toggle("is-restored", state.story.ending);
}
function setPrompt(message) {
if (message === controller.prompt) return;
controller.prompt = message;
text("[data-world-prompt]", message || "Follow the paths, signs, and warm window light.");
}
function status(message) {
text("[data-world-status]", message);
}
function showBoss(name, health, maximum) {
const boss = controller.root.querySelector("[data-world-boss]");
boss.hidden = false;
text("[data-world-boss-name]", name);
boss.querySelector("[data-world-boss-bar]").style.width = `${Math.max(0, health / maximum) * 100}%`;
}
function hideBoss() {
controller.root.querySelector("[data-world-boss]").hidden = true;
}
function openDefeat() {
controller.lock = true;
controller.ui.openDefeat();
}
root.querySelector("[data-world-preview-eyebrow]").textContent = item.eyebrow;
root.querySelector("[data-world-preview-title]").textContent = item.title;
root.querySelector("[data-world-preview-description]").textContent = item.description;
const links = root.querySelector("[data-world-preview-links]");
links.replaceChildren();
item.links.forEach(([label, href]) => {
const anchor = document.createElement("a");
anchor.href = href;
anchor.textContent = label;
anchor.addEventListener("click", () => {
if (controller.scene && controller.state.soundEnabled) controller.scene.portalSound.play();
savePosition();
function openEnding() {
controller.ui.openEnding(controller.state);
}
function text(selector, value) {
const node = controller.root.querySelector(selector);
if (node) node.textContent = value;
}
Object.assign(controller, {
setState, renderHud, status, setPrompt, showBoss, hideBoss, openDefeat, openEnding,
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
View File

View File

@@ -447,13 +447,13 @@
.archive-world {
--world-ink: #17251e;
--world-panel: #f3ead2;
--world-panel: #efe2c2;
--world-paper: #fff9e9;
--world-line: #8b6841;
--world-brass: #d49a3a;
--world-moss: #527657;
--world-berry: #9d5268;
max-width: 1440px;
max-width: 1540px;
}
.archive-world__heading {
@@ -461,6 +461,7 @@
}
.archive-world__shell {
position: relative;
overflow: hidden;
border: 2px solid var(--world-line);
border-radius: 12px;
@@ -468,12 +469,26 @@
box-shadow: 0 22px 70px rgba(23, 37, 30, 0.28);
}
.archive-world__toolbar {
display: flex;
flex-wrap: wrap;
.archive-world.is-restored .archive-world__shell {
box-shadow: 0 22px 80px rgba(215, 184, 94, 0.32), 0 0 0 3px rgba(239, 213, 137, 0.22);
}
.archive-world__shell:fullscreen {
width: 100vw;
height: 100vh;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto auto auto auto;
border: 0;
border-radius: 0;
background: #101b18;
}
.archive-world__hud {
display: grid;
grid-template-columns: 1.1fr 1.4fr 0.8fr 1.15fr auto;
gap: 0.65rem;
align-items: center;
padding: 0.75rem;
align-items: stretch;
padding: 0.65rem;
border-bottom: 2px solid #6e5738;
background:
linear-gradient(rgba(255, 255, 255, 0.04), transparent),
@@ -481,12 +496,18 @@
color: #fff6dc;
}
.archive-world__toolbar > div {
.archive-world__hud > div:not(.rpg-hud__actions) {
display: grid;
min-width: 110px;
align-content: center;
min-width: 0;
padding: 0.45rem 0.6rem;
border: 1px solid rgba(229, 200, 135, 0.25);
border-radius: 6px;
background: rgba(9, 20, 15, 0.28);
}
.archive-world__toolbar span {
.archive-world__hud span,
.rpg-objective span {
color: #d8c9a6;
font-size: 0.68rem;
font-weight: 900;
@@ -494,9 +515,49 @@
text-transform: uppercase;
}
.archive-world__toolbar button,
.archive-world__hud strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.archive-world__hud small {
color: #b8d4bb;
}
.rpg-hud__health span {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.rpg-meter {
height: 9px;
overflow: hidden;
border: 1px solid #d6b777;
border-radius: 999px;
background: #211c18;
}
.rpg-meter i {
display: block;
height: 100%;
background: linear-gradient(90deg, #8dbb73, #d8db78);
transition: width 180ms ease;
}
.rpg-hud__actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-content: center;
justify-content: flex-end;
}
.archive-world__hud button,
.archive-world__controls button,
.archive-world__dialog button {
.archive-world__dialog button,
.archive-world__panel button {
border: 1px solid #d6b777;
border-radius: 6px;
background: #fff3d2;
@@ -506,24 +567,40 @@
cursor: pointer;
}
.archive-world__toolbar button {
margin-left: auto;
padding: 0.55rem 0.75rem;
.archive-world__hud button {
padding: 0.48rem 0.62rem;
font-size: 0.76rem;
}
.archive-world__toolbar button + button {
margin-left: 0;
}
.archive-world__toolbar button:hover,
.archive-world__toolbar button:focus-visible,
.archive-world__hud button:hover,
.archive-world__hud button:focus-visible,
.archive-world__controls button:hover,
.archive-world__controls button:focus-visible,
.archive-world__dialog button:hover,
.archive-world__dialog button:focus-visible {
.archive-world__dialog button:focus-visible,
.archive-world__panel button:hover,
.archive-world__panel button:focus-visible,
.archive-world__panel a:focus-visible,
.archive-world__panel select:focus-visible,
.archive-world__panel input:focus-visible {
border-color: #fff0b5;
background: #fffaf0;
box-shadow: 0 0 0 3px rgba(255, 236, 173, 0.24);
outline: none;
}
.rpg-objective {
display: flex;
gap: 0.75rem;
align-items: baseline;
padding: 0.55rem 0.8rem;
border-bottom: 1px solid rgba(245, 228, 183, 0.16);
background: #24342b;
color: #fff4cf;
}
.rpg-objective strong {
font-size: 0.88rem;
}
.archive-world__badge {
@@ -537,6 +614,12 @@
font-weight: 900;
}
.archive-world__stage {
position: relative;
min-height: 0;
background: #101b18;
}
.archive-world__game {
position: relative;
width: 100%;
@@ -548,6 +631,13 @@
image-rendering: pixelated;
}
.archive-world__shell:fullscreen .archive-world__stage,
.archive-world__shell:fullscreen .archive-world__game {
height: 100%;
max-height: none;
aspect-ratio: auto;
}
.archive-world__game:focus-visible {
box-shadow: inset 0 0 0 4px #fff0a9;
}
@@ -572,6 +662,31 @@
display: none;
}
.rpg-boss {
position: absolute;
z-index: 25;
top: 0.65rem;
left: 50%;
width: min(520px, 82%);
padding: 0.45rem 0.65rem;
border: 1px solid #f0bfd0;
border-radius: 7px;
background: rgba(21, 12, 20, 0.9);
color: #ffe9f0;
text-align: center;
transform: translateX(-50%);
}
.rpg-meter--boss {
height: 11px;
margin-top: 0.25rem;
border-color: #df8ca8;
}
.rpg-meter--boss i {
background: linear-gradient(90deg, #80324e, #d97691);
}
.archive-world__controls {
display: flex;
gap: 1rem;
@@ -590,6 +705,12 @@
gap: 0.3rem;
}
.archive-world__action-pad {
display: grid;
grid-template-columns: repeat(2, minmax(84px, 1fr));
gap: 0.4rem;
}
.archive-world__dpad [data-world-move="up"] {
grid-column: 2;
}
@@ -613,21 +734,29 @@
-webkit-user-select: none;
}
.archive-world__interact {
min-width: 110px;
min-height: 64px !important;
.archive-world__action-pad [data-world-attack],
.archive-world__action-pad [data-world-interact] {
min-height: 54px;
background: #f5d98d;
}
.archive-world__status,
.archive-world__instructions {
.archive-world__instructions,
.archive-world__prompt {
margin: 0;
padding: 0.65rem 1rem;
color: #f5e4b7;
text-align: center;
}
.archive-world__prompt {
padding-bottom: 0.15rem;
color: #fff0b7;
font-weight: 900;
}
.archive-world__status {
border-top: 1px solid rgba(245, 228, 183, 0.16);
padding-top: 0.2rem;
font-weight: 800;
}
@@ -637,6 +766,283 @@
font-size: 0.85rem;
}
.archive-world__overlay {
position: absolute;
z-index: 60;
inset: 0;
display: grid;
place-items: center;
padding: clamp(0.6rem, 2vw, 1.5rem);
background: rgba(9, 15, 12, 0.78);
backdrop-filter: blur(2px);
}
.archive-world__overlay[hidden] {
display: none;
}
.archive-world__panel {
width: min(720px, 100%);
max-height: 96%;
overflow: auto;
border: 3px double #9c7846;
border-radius: 6px;
background:
linear-gradient(rgba(255, 255, 255, 0.26), transparent 30%),
var(--world-panel);
color: #2a2118;
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.58);
}
.archive-world__panel > header {
position: sticky;
z-index: 2;
top: 0;
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: start;
padding: 0.85rem 1rem;
border-bottom: 2px solid #a6834f;
background: #e8d5aa;
}
.archive-world__panel h2,
.archive-world__panel h3,
.archive-world__dialog h2 {
font-family: Georgia, "Times New Roman", serif;
}
.archive-world__panel h2 {
margin: 0.15rem 0 0;
}
.archive-world__panel > header button {
min-width: 42px;
min-height: 42px;
padding: 0;
font-size: 1.4rem;
}
.archive-world__panel-body {
padding: 1rem;
}
.archive-world__panel blockquote {
margin: 0.9rem 0;
padding: 0.8rem;
border-left: 4px solid var(--world-moss);
background: rgba(255, 255, 255, 0.46);
}
.rpg-panel__landmark-icon {
float: right;
margin: 0 0 0.5rem 0.8rem;
color: #8b6828;
font-size: 3rem;
}
.rpg-panel__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.rpg-panel__facts div {
padding: 0.55rem;
border: 1px solid #c1a87d;
border-radius: 4px;
background: rgba(255, 255, 255, 0.34);
}
.rpg-panel__facts dt {
color: #66563d;
font-size: 0.7rem;
font-weight: 900;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.rpg-panel__facts dd {
margin: 0.2rem 0 0;
}
.rpg-panel__actions,
.rpg-panel__puzzle-buttons,
.rpg-menu-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin: 0.9rem 0;
}
.archive-world__panel button {
min-height: 42px;
padding: 0.6rem 0.8rem;
}
.rpg-panel__complete {
color: #2e6237;
font-weight: 900;
}
.rpg-panel__destinations {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 1rem;
padding-top: 0.8rem;
border-top: 1px solid #b99d70;
}
.rpg-panel__destinations h3 {
grid-column: 1 / -1;
margin: 0;
}
.rpg-panel__destinations a {
padding: 0.65rem;
border: 1px solid #a88654;
border-radius: 5px;
background: var(--world-paper);
color: #302516;
font-weight: 800;
text-decoration: none;
}
.rpg-panel__hint,
.rpg-panel__note {
color: #6f614d;
font-size: 0.82rem;
}
.rpg-panel__sequence {
min-height: 1.5em;
font-weight: 900;
}
.archive-world__panel form,
.archive-world__panel label {
display: grid;
gap: 0.4rem;
}
.archive-world__panel form {
gap: 0.75rem;
}
.archive-world__panel select,
.archive-world__panel input[type="range"] {
width: 100%;
}
.archive-world__panel select {
min-height: 42px;
border: 1px solid #9c7846;
border-radius: 4px;
padding: 0.45rem;
background: #fff9e9;
color: #2a2118;
font: inherit;
}
.rpg-menu-tabs {
position: sticky;
top: 76px;
z-index: 1;
padding: 0.45rem;
background: #efe2c2;
}
.rpg-menu-tabs [aria-current="page"] {
background: #385341;
color: #fff8e7;
}
.rpg-menu-list {
display: grid;
gap: 0.55rem;
padding: 0;
list-style: none;
}
.rpg-menu-list li {
display: grid;
gap: 0.18rem;
padding: 0.7rem;
border: 1px solid #b89b6c;
border-radius: 5px;
background: rgba(255, 255, 255, 0.38);
}
.rpg-menu-list li.is-complete {
border-color: #6d966c;
background: #e1edd5;
}
.rpg-menu-list small,
.rpg-menu-list span {
color: #665a46;
}
.rpg-inventory li {
grid-template-columns: 48px minmax(0, 1fr) auto;
align-items: center;
}
.rpg-item-icon {
display: block;
width: 44px;
height: 44px;
border: 1px solid #9b7948;
border-radius: 6px;
background-color: #e7d4aa;
background-image: url("/assets/images/play/archive-world/v2/item-atlas.png");
background-repeat: no-repeat;
background-size: 176px 176px;
image-rendering: pixelated;
}
.rpg-item-icon--0 { background-position: 0 0; }
.rpg-item-icon--1 { background-position: -44px 0; }
.rpg-item-icon--2 { background-position: -88px 0; }
.rpg-item-icon--3 { background-position: -132px 0; }
.rpg-item-icon--4 { background-position: 0 -44px; }
.rpg-item-icon--5 { background-position: -44px -44px; }
.rpg-item-icon--6 { background-position: -88px -44px; }
.rpg-item-icon--7 { background-position: -132px -44px; }
.rpg-item-icon--8 { background-position: 0 -88px; }
.rpg-item-icon--9 { background-position: -44px -88px; }
.rpg-item-icon--10 { background-position: -88px -88px; }
.rpg-item-icon--11 { background-position: -132px -88px; }
.rpg-item-icon--12 { background-position: 0 -132px; }
.rpg-controls-list div {
display: grid;
grid-template-columns: minmax(90px, 0.35fr) 1fr;
gap: 0.6rem;
padding: 0.45rem 0;
border-bottom: 1px solid #c9b38e;
}
.rpg-controls-list dt {
font-weight: 900;
}
.rpg-controls-list dd {
margin: 0;
}
.rpg-toggle {
grid-template-columns: auto 1fr !important;
align-items: center;
}
.rpg-danger {
border-color: #a34e57 !important;
background: #f3c8c5 !important;
}
.archive-world__dialog {
width: min(92vw, 620px);
max-height: 86vh;
@@ -658,7 +1064,6 @@
.archive-world__dialog h2 {
margin-top: 0.25rem;
font-family: Georgia, "Times New Roman", serif;
}
.archive-world__dialog form,
@@ -711,51 +1116,6 @@
font-weight: 800;
}
.archive-world__preview-links {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
margin: 1rem 0;
}
.archive-world__preview-links a {
padding: 0.75rem;
border: 1px solid var(--world-line);
border-radius: 6px;
background: var(--world-paper);
color: #3a2b18;
font-weight: 800;
text-decoration: none;
}
.archive-world__preview-links a:hover,
.archive-world__preview-links a:focus-visible {
background: #fff;
box-shadow: 0 0 0 3px rgba(82, 118, 87, 0.25);
}
.archive-world__journal {
display: grid;
gap: 0.5rem;
padding: 0;
list-style: none;
}
.archive-world__journal li {
padding: 0.65rem;
border: 1px dashed #aa977b;
border-radius: 5px;
color: #756956;
}
.archive-world__journal li.is-discovered {
border-style: solid;
border-color: #638568;
background: #e5efdc;
color: #29452e;
font-weight: 800;
}
.archive-world__directory {
margin-top: 1.5rem;
border: 1px solid var(--border);
@@ -769,6 +1129,12 @@
cursor: pointer;
}
.archive-world__directory > p {
margin: 0;
padding: 0 1rem 0.8rem;
color: var(--muted);
}
.archive-world__directory-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -813,12 +1179,13 @@
min-height: 160px;
}
.archive-world__toolbar > div {
min-width: 82px;
.archive-world__hud {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.archive-world__toolbar button {
margin-left: 0;
.rpg-hud__actions {
grid-column: 1 / -1;
justify-content: start;
}
.archive-world__game {
@@ -827,11 +1194,16 @@
}
.archive-world__dialog fieldset,
.archive-world__preview-links,
.rpg-panel__facts,
.rpg-panel__destinations,
.archive-world__directory-grid {
grid-template-columns: 1fr;
}
.rpg-objective {
display: grid;
gap: 0.2rem;
}
}
@media (max-width: 480px) {
@@ -841,10 +1213,43 @@
.archive-world__controls {
justify-content: space-between;
gap: 0.4rem;
padding: 0.5rem;
}
.archive-world__dpad {
grid-template-columns: repeat(3, 42px);
grid-template-columns: repeat(3, 38px);
grid-template-rows: repeat(2, 40px);
}
.archive-world__action-pad {
grid-template-columns: repeat(2, minmax(64px, 1fr));
}
.archive-world__controls button {
min-height: 40px;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
}
.archive-world__panel {
max-height: 100%;
}
.archive-world__panel-body {
padding: 0.75rem;
}
.rpg-menu-tabs {
top: 70px;
}
.rpg-inventory li {
grid-template-columns: 44px minmax(0, 1fr);
}
.rpg-inventory li button {
grid-column: 1 / -1;
}
}
@@ -857,4 +1262,8 @@
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
.rpg-meter i {
transition: none;
}
}

22
blogs/2025/2025-list.org Normal file
View 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]]

View File

@@ -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 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
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.

View File

@@ -10,34 +10,73 @@
<header class="play-page-head archive-world__heading">
<p class="play-kicker">09 / Role Playing World</p>
<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>
<section class="archive-world__shell" aria-labelledby="archive-world-title">
<div class="archive-world__toolbar">
<div>
<section class="archive-world__shell" data-world-shell aria-labelledby="archive-world-title">
<div class="archive-world__hud">
<div class="rpg-hud__identity">
<span>Traveler</span>
<strong data-world-player>New arrival</strong>
<small data-world-level>Lv 1</small>
</div>
<div>
<span>Landmarks</span>
<strong data-world-discovery-count>0 / 8</strong>
<div class="rpg-hud__health">
<span>Health <strong data-world-health-text>100 / 100</strong></span>
<div class="rpg-meter" aria-hidden="true"><i data-world-health-bar style="width:100%"></i></div>
</div>
<p class="archive-world__badge" data-world-badge hidden>Archive Cartographer</p>
<button type="button" data-world-journal-open>Discovery journal</button>
<div class="rpg-hud__sigils">
<span>Archive Sigils</span>
<strong data-world-sigil-count>0 / 8</strong>
</div>
<div class="rpg-hud__item">
<span>Selected item</span>
<strong data-world-selected-item>Ink Vial ×2</strong>
</div>
<div class="rpg-hud__actions">
<button type="button" data-world-quests>Quests</button>
<button type="button" data-world-inventory>Inventory</button>
<button type="button" data-world-journal-open>Journal</button>
<button type="button" data-world-sound>Sound: muted</button>
<button type="button" data-world-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>
<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
id="archive-world-game"
class="archive-world__game"
tabindex="0"
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>Travelers folio</h2>
</div>
<button type="button" data-world-panel-close aria-label="Close panel">×</button>
</header>
<div class="archive-world__panel-body" data-world-panel-body></div>
</section>
</div>
</div>
<div class="archive-world__controls" aria-label="Touch game controls">
@@ -47,15 +86,21 @@
<button type="button" data-world-move="down" aria-label="Move down"></button>
<button type="button" data-world-move="right" aria-label="Move right"></button>
</div>
<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>
<p class="archive-world__prompt" data-world-prompt>Follow the paths, signs, and warm window light.</p>
<p class="archive-world__status" data-world-status aria-live="polite">
Choose your traveler to enter Archive Town.
</p>
<p class="archive-world__instructions">
Move with arrow keys or WASD. Press E, Space, Enter, or Explore near a glowing landmark.
Sound remains muted until you enable it.
Move: WASD/arrows · Attack: Space · Explore: E/Enter · Use item: Q · Cycle item: Tab · Pause: Escape.
Audio begins muted and remains optional.
</p>
</section>
@@ -63,12 +108,13 @@
<form data-world-setup-form>
<p class="play-kicker">New traveler</p>
<h2>Who has arrived?</h2>
<p>The Town Gate is losing names. Yours has held long enough for the Living Bookmark to find you.</p>
<label>
Traveler name
<input name="name" type="text" minlength="1" maxlength="20" autocomplete="nickname" required />
</label>
<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="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>
@@ -78,23 +124,9 @@
</form>
</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">
<summary>Plain directory of Archive Town</summary>
<p>This directory remains usable if JavaScript, Canvas, Phaser, or optional game assets are unavailable.</p>
<div class="archive-world__directory-grid">
<section><h2>Town Gate</h2><a href="/">Home</a> · <a href="/recently-updated.html">Recently updated</a> · <a href="/home/contact.html">Contact</a></section>
<section><h2>Grand Library</h2><a href="/posts/posts-list.html">Posts</a> · <a href="/home/categories.html">Categories</a> · <a href="/posts/posts-intro.html">Introduction</a></section>
@@ -107,10 +139,15 @@
</div>
</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>
<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-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>
#+END_EXPORT

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">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/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>@@

View File

@@ -110,17 +110,17 @@ flowchart TD
n46 --> n52
n53["Tag: website"]
n46 --> n53
n54["Tag: update"]
n54["Tag: life"]
n46 --> n54
n55["Tag: life"]
n55["Tag: update"]
n46 --> n55
n56["Tag: education"]
n56["Tag: insights"]
n46 --> n56
n57["Tag: insights"]
n57["Tag: emacs"]
n46 --> n57
n58["Tag: reading"]
n58["Tag: education"]
n46 --> n58
n59["Tag: emacs"]
n59["Tag: reading"]
n46 --> n59
n60["Tag: maths"]
n46 --> n60
@@ -196,12 +196,12 @@ flowchart TD
click n51 "tags/notes.html" "Tag: notes"
click n52 "tags/review.html" "Tag: review"
click n53 "tags/website.html" "Tag: website"
click n54 "tags/update.html" "Tag: update"
click n55 "tags/life.html" "Tag: life"
click n56 "tags/education.html" "Tag: education"
click n57 "tags/insights.html" "Tag: insights"
click n58 "tags/reading.html" "Tag: reading"
click n59 "tags/emacs.html" "Tag: emacs"
click n54 "tags/life.html" "Tag: life"
click n55 "tags/update.html" "Tag: update"
click n56 "tags/insights.html" "Tag: insights"
click n57 "tags/emacs.html" "Tag: emacs"
click n58 "tags/education.html" "Tag: education"
click n59 "tags/reading.html" "Tag: reading"
click n60 "tags/maths.html" "Tag: maths"
click n62 "play/sigil.html" "Sigil Press"
click n63 "play/ink.html" "Ink Pond"
@@ -271,12 +271,12 @@ flowchart TD
- [[file:tags/notes.org][Tag: notes]]
- [[file:tags/review.org][Tag: review]]
- [[file:tags/website.org][Tag: website]]
- [[file:tags/update.org][Tag: update]]
- [[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/reading.org][Tag: reading]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/reading.org][Tag: reading]]
- [[file:tags/maths.org][Tag: maths]]
- play
- [[file:play/sigil.org][Sigil Press]]

View File

@@ -1,70 +1,91 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const data = require("../assets/scripts/pages/archive-world-data.js");
const state = require("../assets/scripts/pages/archive-world-state.js");
test("fresh state applies defaults", () => {
assert.deepEqual(state.fresh(" Ada ", "moss"), {
version: 1,
name: "Ada",
palette: "moss",
discovered: [],
complete: false,
position: { x: 640, y: 830 },
soundEnabled: false
});
test("fresh v2 state applies safe defaults", () => {
const fresh = state.fresh(" Ada ", "moss");
assert.equal(fresh.version, 2);
assert.equal(fresh.name, "Ada");
assert.equal(fresh.palette, "moss");
assert.equal(fresh.position.area, "town");
assert.equal(fresh.health, 100);
assert.equal(fresh.settings.soundEnabled, false);
assert.deepEqual(fresh.sigils, []);
assert.equal(fresh.inventory.find((item) => item.id === "ink_vial").quantity, 2);
});
test("names 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(""), 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("ultraviolet"), null);
});
test("discoveries are deduplicated and completion requires all landmarks", () => {
let current = state.fresh("Ada", "brass");
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 = {
test("v1 data migrates identity, discoveries, position and sound", () => {
const migrated = state.migrateV1({
version: 1,
name: "Zaine",
palette: "brass",
discovered: ["gate", "gate", "missing"],
complete: true,
position: { x: -20, y: 5000 },
soundEnabled: true
};
assert.deepEqual(state.normalize(candidate), {
version: 1,
name: "Zaine",
palette: "brass",
discovered: ["gate"],
name: "Mira",
palette: "berry",
discovered: ["gate", "gate", "museum", "missing"],
complete: false,
position: { x: 36, y: 924 },
position: { x: 700, y: 850 },
soundEnabled: true
});
assert.equal(migrated.version, 2);
assert.deepEqual(migrated.discovered, ["gate", "museum"]);
assert.equal(migrated.position.area, "town");
assert.equal(migrated.position.x, 700);
assert.equal(migrated.settings.soundEnabled, true);
});
test("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(JSON.stringify({ version: 2 })), null);
assert.equal(state.parse(JSON.stringify(state.fresh("", "brass"))), null);
assert.equal(state.parse(JSON.stringify({ version: 99 })), null);
});
test("a valid save round-trips and reset creates an empty state", () => {
const saved = state.discover(state.fresh("Mira", "berry"), "garden");
assert.deepEqual(state.parse(JSON.stringify(saved)), saved);
test("level thresholds and reset-ready fresh state are deterministic", () => {
assert.equal(state.levelForXp(0), 1);
assert.equal(state.levelForXp(80), 2);
assert.equal(state.levelForXp(620), 5);
assert.deepEqual(state.fresh("Mira", "berry").discovered, []);
assert.equal(state.fresh("Mira", "berry").quests.gate.status, "locked");
});

View 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));
});