194 lines
11 KiB
JavaScript
Executable File
194 lines
11 KiB
JavaScript
Executable File
(function (root) {
|
||
"use strict";
|
||
|
||
const Data = root.PrincessLimaData;
|
||
const Systems = root.PrincessLimaSystems;
|
||
|
||
function create(container, actions) {
|
||
const overlay = container.querySelector("[data-lima-overlay]");
|
||
const panel = container.querySelector("[data-lima-panel]");
|
||
const title = container.querySelector("[data-lima-panel-title]");
|
||
const body = container.querySelector("[data-lima-panel-body]");
|
||
const closeButton = container.querySelector("[data-lima-panel-close]");
|
||
let returnFocus = null;
|
||
let dialogue = null;
|
||
let dialogueIndex = 0;
|
||
|
||
function lock(value) {
|
||
actions.onLock(value);
|
||
overlay.hidden = !value;
|
||
container.classList.toggle("is-overlay-open", value);
|
||
}
|
||
|
||
function show(kind, heading, html, closable) {
|
||
returnFocus = document.activeElement;
|
||
panel.dataset.kind = kind;
|
||
title.textContent = heading;
|
||
body.innerHTML = html;
|
||
closeButton.hidden = closable === false;
|
||
lock(true);
|
||
const target = body.querySelector("button, input, select") || closeButton;
|
||
target.focus();
|
||
}
|
||
|
||
function close() {
|
||
if (dialogue) return advanceDialogue();
|
||
lock(false);
|
||
body.innerHTML = "";
|
||
if (returnFocus && document.contains(returnFocus)) returnFocus.focus();
|
||
else container.querySelector("[data-lima-game]").focus();
|
||
}
|
||
|
||
function openDialogue(id, done) {
|
||
const npc = Data.NPCS[id];
|
||
if (!npc) return;
|
||
dialogue = { id, lines: npc.dialogue.slice(), done };
|
||
dialogueIndex = 0;
|
||
renderDialogue();
|
||
}
|
||
|
||
function renderDialogue() {
|
||
const npc = Data.NPCS[dialogue.id];
|
||
const line = dialogue.lines[dialogueIndex];
|
||
show("dialogue", npc.name, `
|
||
<div class="lima-dialogue">
|
||
<div class="lima-dialogue__portrait lima-sprite lima-sprite--${npc.frame}" aria-hidden="true"></div>
|
||
<p data-lima-dialogue-text>${escapeHtml(line)}</p>
|
||
</div>
|
||
<button type="button" class="lima-primary" data-lima-advance>${dialogueIndex + 1 < dialogue.lines.length ? "Continue" : "Finish"}</button>
|
||
`, false);
|
||
body.querySelector("[data-lima-advance]").addEventListener("click", advanceDialogue, { once: true });
|
||
}
|
||
|
||
function advanceDialogue() {
|
||
if (!dialogue) return;
|
||
dialogueIndex += 1;
|
||
if (dialogueIndex < dialogue.lines.length) return renderDialogue();
|
||
const done = dialogue.done;
|
||
dialogue = null;
|
||
lock(false);
|
||
body.innerHTML = "";
|
||
container.querySelector("[data-lima-game]").focus();
|
||
if (done) done();
|
||
}
|
||
|
||
function openPanel(kind, state) {
|
||
if (kind === "inventory") {
|
||
const entries = state.inventory.length ? state.inventory.map((entry) => {
|
||
const item = Data.ITEMS[entry.id];
|
||
const equipped = Object.values(state.equipment).includes(entry.id) ? " · Equipped" : "";
|
||
return `<li><strong>${escapeHtml(item.name)}</strong><span>×${entry.quantity}${equipped}</span><p>${escapeHtml(item.description)}</p></li>`;
|
||
}).join("") : "<li>No items yet.</li>";
|
||
show("inventory", "Inventory", `<ul class="lima-list">${entries}</ul><button type="button" data-lima-use-tonic>Use Healing Tonic</button>`);
|
||
const use = body.querySelector("[data-lima-use-tonic]");
|
||
use.addEventListener("click", () => { actions.onUseTonic(); openPanel("inventory", actions.getState()); });
|
||
} else if (kind === "quests") {
|
||
const quests = Data.QUEST_IDS.filter((id) => state.quests[id].status !== "locked").map((id) => {
|
||
const quest = Data.QUESTS[id];
|
||
const progress = state.quests[id];
|
||
return `<li class="${progress.status === "complete" ? "is-complete" : ""}">
|
||
<strong>${quest.main ? "Main · " : "Optional · "}${escapeHtml(quest.title)}</strong>
|
||
<span>${progress.status === "complete" ? "Complete" : `${progress.count} / ${quest.target}`}</span>
|
||
<p>${escapeHtml(quest.description)} · ${escapeHtml(quest.region)}</p>
|
||
</li>`;
|
||
}).join("");
|
||
show("quests", "Quest Log", `<p class="lima-panel-lead">${escapeHtml(Systems.currentObjective(state))}</p><ul class="lima-list">${quests || "<li>No quests yet.</li>"}</ul>`);
|
||
} else if (kind === "settings") {
|
||
show("settings", "Settings", `
|
||
<label class="lima-setting"><span>Master volume</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.master}" data-setting="master"></label>
|
||
<label class="lima-setting"><span>Music</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.music}" data-setting="music"></label>
|
||
<label class="lima-setting"><span>Effects</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.effects}" data-setting="effects"></label>
|
||
<label class="lima-setting"><span>Narration</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.voice}" data-setting="voice"></label>
|
||
<label class="lima-check"><input type="checkbox" data-setting="narrationEnabled" ${state.settings.narrationEnabled ? "checked" : ""}> Spoken narration</label>
|
||
<label class="lima-check"><input type="checkbox" data-setting="subtitles" ${state.settings.subtitles ? "checked" : ""}> Cinematic subtitles</label>
|
||
<label class="lima-check"><input type="checkbox" data-setting="reducedMotion" ${state.settings.reducedMotion ? "checked" : ""}> Reduced motion</label>
|
||
<label class="lima-check"><input type="checkbox" data-setting="screenShake" ${state.settings.screenShake ? "checked" : ""}> Screen shake</label>
|
||
<label class="lima-check"><input type="checkbox" data-setting="highContrast" ${state.settings.highContrast ? "checked" : ""}> High-contrast interface</label>
|
||
<label class="lima-setting"><span>Text speed</span><select data-setting="textSpeed">${["slow", "normal", "fast", "instant"].map((value) => `<option ${state.settings.textSpeed === value ? "selected" : ""}>${value}</option>`).join("")}</select></label>
|
||
`);
|
||
body.querySelectorAll("[data-setting]").forEach((control) => control.addEventListener("change", () => {
|
||
const value = control.type === "checkbox" ? control.checked : control.type === "range" ? Number(control.value) : control.value;
|
||
actions.onSetting(control.dataset.setting, value);
|
||
}));
|
||
} else if (kind === "pause") {
|
||
show("pause", "Paused", `
|
||
<p>${escapeHtml(Systems.currentObjective(state))}</p>
|
||
<div class="lima-menu-stack">
|
||
<button type="button" data-panel="inventory">Inventory</button>
|
||
<button type="button" data-panel="quests">Quest Log</button>
|
||
<button type="button" data-panel="settings">Settings & accessibility</button>
|
||
<button type="button" data-lima-replay-intro-panel>Replay introduction</button>
|
||
<button type="button" data-lima-fullscreen-panel>Toggle fullscreen</button>
|
||
<button type="button" data-lima-reset-request>Reset save</button>
|
||
<a href="/">Exit to Website</a>
|
||
</div>
|
||
`);
|
||
body.querySelectorAll("[data-panel]").forEach((button) => button.addEventListener("click", () => openPanel(button.dataset.panel, actions.getState())));
|
||
body.querySelector("[data-lima-replay-intro-panel]").addEventListener("click", actions.onReplayIntro);
|
||
body.querySelector("[data-lima-fullscreen-panel]").addEventListener("click", actions.onFullscreen);
|
||
body.querySelector("[data-lima-reset-request]").addEventListener("click", confirmReset);
|
||
}
|
||
}
|
||
|
||
function confirmReset() {
|
||
show("confirm", "Delete this adventure?", `
|
||
<p>This permanently removes the Princess Lima save on this device.</p>
|
||
<div class="lima-confirm"><button type="button" class="lima-danger" data-confirm-reset>Delete save</button><button type="button" data-cancel-reset>Keep progress</button></div>
|
||
`, false);
|
||
body.querySelector("[data-confirm-reset]").addEventListener("click", actions.onReset, { once: true });
|
||
body.querySelector("[data-cancel-reset]").addEventListener("click", () => openPanel("pause", actions.getState()), { once: true });
|
||
}
|
||
|
||
function gameOver(state) {
|
||
show("defeat", "The road is not finished", `
|
||
<p>You awaken at the latest safe checkpoint with your quests and important items intact.</p>
|
||
<button type="button" class="lima-primary" data-respawn>Return to checkpoint</button>
|
||
`, false);
|
||
body.querySelector("[data-respawn]").addEventListener("click", actions.onRespawn, { once: true });
|
||
}
|
||
|
||
function ending(state) {
|
||
show("ending", "Princess Lima Rescued", `
|
||
<p>At dawn, the roads reopen. The villages ring their bells, the forest paths quiet, and the mountain fires become beacons instead of warnings.</p>
|
||
<p><strong>${escapeHtml(state.player.name)}</strong> is offered a place at the royal table—and chooses first to walk the repaired road home with Lima.</p>
|
||
<p class="lima-ending-note">The kingdom remains explorable from your final save.</p>
|
||
<button type="button" class="lima-primary" data-ending-continue>Continue exploring</button>
|
||
<a class="lima-button-link" href="/">Exit to Website</a>
|
||
`, false);
|
||
body.querySelector("[data-ending-continue]").addEventListener("click", close, { once: true });
|
||
}
|
||
|
||
closeButton.addEventListener("click", close);
|
||
overlay.addEventListener("click", (event) => { if (event.target === overlay && !dialogue) close(); });
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && !overlay.hidden && !dialogue) {
|
||
event.preventDefault();
|
||
close();
|
||
}
|
||
if ((event.key === "Enter" || event.key === " ") && dialogue && !overlay.hidden) {
|
||
event.preventDefault();
|
||
advanceDialogue();
|
||
}
|
||
if (!overlay.hidden && !dialogue && ["ArrowDown", "ArrowUp"].includes(event.key)) {
|
||
const controls = Array.from(panel.querySelectorAll("button:not([hidden]), a[href], input, select"))
|
||
.filter((control) => !control.disabled && control.offsetParent !== null);
|
||
if (!controls.length) return;
|
||
event.preventDefault();
|
||
const current = controls.indexOf(document.activeElement);
|
||
const change = event.key === "ArrowDown" ? 1 : -1;
|
||
controls[(current + change + controls.length) % controls.length].focus();
|
||
}
|
||
});
|
||
|
||
return Object.freeze({ show, close, openDialogue, openPanel, gameOver, ending, confirmReset });
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value).replace(/[&<>"']/g, (character) => ({
|
||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||
}[character]));
|
||
}
|
||
|
||
root.PrincessLimaUI = Object.freeze({ create });
|
||
}(typeof globalThis !== "undefined" ? globalThis : this));
|