297 lines
16 KiB
JavaScript
297 lines
16 KiB
JavaScript
(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));
|