Files
org_web/assets/scripts/pages/archive-world-ui.js
gitea-actions c06af38d80
All checks were successful
Build Org Website / build (push) Successful in 39s
Harden archive-world setup and refresh site links
2026-07-30 09:08:12 +01:00

297 lines
16 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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));