240 lines
14 KiB
JavaScript
240 lines
14 KiB
JavaScript
(function (root) {
|
||
"use strict";
|
||
const Data = root.PrincessLimaV2Data;
|
||
const State = root.PrincessLimaV2State;
|
||
const Systems = root.PrincessLimaV2Systems;
|
||
|
||
function create(container, actions) {
|
||
const overlay = container.querySelector("[data-lima-overlay]");
|
||
const panel = container.querySelector("[data-lima-panel]");
|
||
const heading = container.querySelector("[data-lima-panel-title]");
|
||
const body = container.querySelector("[data-lima-panel-body]");
|
||
const closeButton = container.querySelector("[data-lima-panel-close]");
|
||
const live = container.querySelector("[data-lima-screenreader]");
|
||
let previousFocus = null;
|
||
let dialogue = null;
|
||
let typingTimer = null;
|
||
let displayed = "";
|
||
let fullText = "";
|
||
|
||
function portraitPosition(speaker, expression) {
|
||
const index = Data.PORTRAITS[speaker] && Data.PORTRAITS[speaker][expression] !== undefined
|
||
? Data.PORTRAITS[speaker][expression] : 14;
|
||
return `${(index % 4) * 33.333}% ${Math.floor(index / 4) * 33.333}%`;
|
||
}
|
||
function open(kind, title, html, closable = true) {
|
||
previousFocus = document.activeElement;
|
||
overlay.hidden = false;
|
||
panel.dataset.kind = kind;
|
||
heading.textContent = title;
|
||
body.innerHTML = html;
|
||
closeButton.hidden = !closable;
|
||
requestAnimationFrame(() => (body.querySelector("button, input, [tabindex]") || closeButton).focus());
|
||
}
|
||
function close() {
|
||
if (dialogue) return advance();
|
||
overlay.hidden = true;
|
||
panel.dataset.kind = "";
|
||
body.innerHTML = "";
|
||
actions.lock(false, "menu");
|
||
if (previousFocus && previousFocus.focus) previousFocus.focus();
|
||
else actions.focusGame();
|
||
}
|
||
function stopTyping() {
|
||
if (typingTimer) clearInterval(typingTimer);
|
||
typingTimer = null;
|
||
}
|
||
function typeLine(target, text) {
|
||
stopTyping();
|
||
fullText = text;
|
||
displayed = "";
|
||
const settings = actions.getState().settings;
|
||
const speed = { slow: 42, normal: 27, fast: 12, instant: 0 }[settings.textSpeed];
|
||
if (!speed || settings.reducedMotion) {
|
||
target.textContent = text;
|
||
displayed = text;
|
||
return;
|
||
}
|
||
let index = 0;
|
||
typingTimer = setInterval(() => {
|
||
index += 1;
|
||
displayed = text.slice(0, index);
|
||
target.textContent = displayed;
|
||
if (index >= text.length) stopTyping();
|
||
}, speed);
|
||
}
|
||
function startDialogue(id, options = {}) {
|
||
const definition = Data.DIALOGUES[id];
|
||
if (!definition) return;
|
||
actions.lock(true, "dialogue");
|
||
actions.beginDialogue(id, options.target);
|
||
dialogue = { id, node: definition.start, history: [], done: options.done || null, essential: definition.essential };
|
||
renderDialogue();
|
||
}
|
||
function renderDialogue() {
|
||
const definition = Data.DIALOGUES[dialogue.id];
|
||
const node = Systems.dialogueNode(dialogue.id, dialogue.node, actions.getState());
|
||
if (!node) return finishDialogue();
|
||
const speaker = Data.ACTORS[node.speaker] ? Data.ACTORS[node.speaker].name : node.speaker;
|
||
dialogue.history.push({ speaker, text: node.text });
|
||
live.textContent = `${speaker}: ${node.text}`;
|
||
open("dialogue", speaker, `
|
||
<div class="lima-cinematic-dialogue" data-expression="${escapeHtml(node.expression || "neutral")}">
|
||
<div class="lima-cinematic-dialogue__portrait" role="img" aria-label="${escapeHtml(speaker)}, ${escapeHtml(node.expression || "neutral")}"
|
||
style="background-position:${portraitPosition(node.speaker, node.expression || "neutral")}"></div>
|
||
<div class="lima-cinematic-dialogue__copy">
|
||
<p class="lima-cinematic-dialogue__speaker">${escapeHtml(speaker)} <span>${escapeHtml(node.expression || "")}</span></p>
|
||
<p class="lima-cinematic-dialogue__text" data-dialogue-line></p>
|
||
<div class="lima-cinematic-dialogue__choices" data-dialogue-choices></div>
|
||
<div class="lima-cinematic-dialogue__controls">
|
||
<button type="button" class="lima-primary" data-dialogue-advance>${node.choices ? "Choose" : node.next ? "Continue" : "Finish"}</button>
|
||
<button type="button" data-dialogue-history>History</button>
|
||
${!dialogue.essential && actions.getState().dialogueHistory.includes(dialogue.id) ? '<button type="button" data-dialogue-skip>Skip viewed scene</button>' : ""}
|
||
</div>
|
||
</div>
|
||
</div>`, false);
|
||
const line = body.querySelector("[data-dialogue-line]");
|
||
typeLine(line, node.text);
|
||
const choices = body.querySelector("[data-dialogue-choices]");
|
||
if (node.choices) {
|
||
body.querySelector("[data-dialogue-advance]").hidden = true;
|
||
choices.innerHTML = node.choices.map((choice, index) =>
|
||
`<button type="button" data-choice="${index}"><span>${index + 1}</span>${escapeHtml(choice.text)}</button>`).join("");
|
||
choices.querySelectorAll("[data-choice]").forEach((button) => button.addEventListener("click", () => choose(Number(button.dataset.choice))));
|
||
}
|
||
body.querySelector("[data-dialogue-advance]").addEventListener("click", advance);
|
||
body.querySelector("[data-dialogue-history]").addEventListener("click", showHistory);
|
||
const skip = body.querySelector("[data-dialogue-skip]");
|
||
if (skip) skip.addEventListener("click", finishDialogue);
|
||
actions.frameDialogue(node);
|
||
}
|
||
function advance() {
|
||
if (!dialogue) return;
|
||
if (displayed !== fullText) {
|
||
stopTyping();
|
||
const line = body.querySelector("[data-dialogue-line]");
|
||
if (line) line.textContent = fullText;
|
||
displayed = fullText;
|
||
return;
|
||
}
|
||
const node = Systems.dialogueNode(dialogue.id, dialogue.node, actions.getState());
|
||
if (!node || node.choices) return;
|
||
actions.applyEffects(node.effects);
|
||
if (node.next) {
|
||
dialogue.node = node.next;
|
||
renderDialogue();
|
||
} else finishDialogue();
|
||
}
|
||
function choose(index) {
|
||
const result = Systems.dialogueChoice(dialogue.id, dialogue.node, index, actions.getState());
|
||
if (!result) return;
|
||
actions.setState(result.state);
|
||
dialogue.node = result.next;
|
||
renderDialogue();
|
||
}
|
||
function finishDialogue() {
|
||
stopTyping();
|
||
const done = dialogue && dialogue.done;
|
||
if (dialogue) actions.recordDialogue(dialogue.id);
|
||
dialogue = null;
|
||
overlay.hidden = true;
|
||
body.innerHTML = "";
|
||
live.textContent = "";
|
||
actions.endDialogue();
|
||
actions.lock(false, "dialogue");
|
||
if (done) done();
|
||
actions.focusGame();
|
||
}
|
||
function showHistory() {
|
||
const history = dialogue.history.map((entry) => `<li><strong>${escapeHtml(entry.speaker)}</strong><p>${escapeHtml(entry.text)}</p></li>`).join("");
|
||
const dialog = document.createElement("dialog");
|
||
dialog.className = "lima-history";
|
||
dialog.innerHTML = `<h3>Conversation history</h3><ol>${history}</ol><button type="button">Return</button>`;
|
||
container.appendChild(dialog);
|
||
dialog.querySelector("button").addEventListener("click", () => { dialog.close(); dialog.remove(); });
|
||
dialog.showModal();
|
||
}
|
||
function openPanel(kind) {
|
||
const state = actions.getState();
|
||
actions.lock(true, "menu");
|
||
if (kind === "credits") {
|
||
open("credits", "Credits", `
|
||
<p class="lima-panel-lead">A locally hosted storybook RPG built for this site.</p>
|
||
<ul class="lima-list">
|
||
<li><strong>Story & world</strong><span>Princess Lima</span></li>
|
||
<li><strong>Engine</strong><span>Phaser 4.1.0</span></li>
|
||
<li><strong>Pathfinding</strong><span>EasyStar.js 0.4.4</span></li>
|
||
<li><strong>Portrait source art</strong><span>OpenAI ImageGen, edited and atlas-packed locally</span></li>
|
||
</ul>
|
||
<p>All game data, maps, images, code, and audio are served from this website.</p>`);
|
||
} else if (kind === "quests") {
|
||
const quests = Object.entries(Data.QUESTS).filter(([id]) => state.quests[id].status !== "locked").map(([id, quest]) => {
|
||
const progress = state.quests[id];
|
||
return `<li class="${progress.status}"><strong>${quest.main ? "Main · " : "Optional · "}${escapeHtml(quest.title)}</strong><span>${progress.status === "complete" ? "Complete" : `${progress.count}/${quest.target}`}</span><small>${escapeHtml(Data.MAP_NAMES[quest.region])}</small></li>`;
|
||
}).join("");
|
||
open("quests", "Quest Chronicle", `<p class="lima-panel-lead">${escapeHtml(Systems.currentObjective(state))}</p><ul class="lima-list">${quests || "<li>No quests have begun.</li>"}</ul>`);
|
||
} else if (kind === "inventory") {
|
||
const items = state.inventory.map((entry) => `<li><strong>${escapeHtml(Data.ITEMS[entry.id].name)}</strong><span>×${entry.quantity}</span></li>`).join("");
|
||
open("inventory", "Traveller's Satchel", `<ul class="lima-list">${items || "<li>The satchel is empty.</li>"}</ul><button type="button" class="lima-primary" data-use-tonic>Use Healing Tonic</button>`);
|
||
body.querySelector("[data-use-tonic]").addEventListener("click", () => { actions.useTonic(); openPanel("inventory"); });
|
||
} else if (kind === "settings") {
|
||
const checked = (key) => state.settings[key] ? "checked" : "";
|
||
open("settings", "Accessibility & Effects", `
|
||
<div class="lima-settings-grid">
|
||
<label><input type="checkbox" data-setting="soundEnabled" ${checked("soundEnabled")}> Sound</label>
|
||
<label><input type="checkbox" data-setting="subtitles" ${checked("subtitles")}> Subtitles</label>
|
||
<label><input type="checkbox" data-setting="narrationEnabled" ${checked("narrationEnabled")}> Narration</label>
|
||
<label><input type="checkbox" data-setting="reducedMotion" ${checked("reducedMotion")}> Reduced motion</label>
|
||
<label><input type="checkbox" data-setting="highContrast" ${checked("highContrast")}> High contrast</label>
|
||
<label><input type="checkbox" data-setting="blur" ${checked("blur")}> Dialogue blur</label>
|
||
<label><input type="checkbox" data-setting="particles" ${checked("particles")}> Particles</label>
|
||
<label><input type="checkbox" data-setting="screenShake" ${checked("screenShake")}> Screen shake</label>
|
||
<label><input type="checkbox" data-setting="lighting" ${checked("lighting")}> Lighting</label>
|
||
<label>Effects quality <select data-setting="effectsQuality"><option value="full">Full</option><option value="reduced">Reduced</option><option value="minimal">Minimal</option></select></label>
|
||
<label>Text speed <select data-setting="textSpeed"><option value="slow">Slow</option><option value="normal">Normal</option><option value="fast">Fast</option><option value="instant">Instant</option></select></label>
|
||
</div>`);
|
||
body.querySelector('[data-setting="effectsQuality"]').value = state.settings.effectsQuality;
|
||
body.querySelector('[data-setting="textSpeed"]').value = state.settings.textSpeed;
|
||
body.querySelectorAll("[data-setting]").forEach((control) => control.addEventListener("change", () =>
|
||
actions.setting(control.dataset.setting, control.type === "checkbox" ? control.checked : control.value)));
|
||
} else {
|
||
open("pause", "Roadside Menu", `
|
||
<p class="lima-panel-lead">${escapeHtml(Data.MAP_NAMES[state.region])}</p>
|
||
<div class="lima-menu-stack">
|
||
<button type="button" class="lima-primary" data-close-panel>Resume</button>
|
||
<button type="button" data-open-panel="quests">Quest Chronicle</button>
|
||
<button type="button" data-open-panel="inventory">Inventory</button>
|
||
<button type="button" data-open-panel="settings">Settings</button>
|
||
<button type="button" data-replay-intro>Replay Introduction</button>
|
||
</div>`);
|
||
body.querySelector("[data-close-panel]").addEventListener("click", close);
|
||
body.querySelectorAll("[data-open-panel]").forEach((button) => button.addEventListener("click", () => openPanel(button.dataset.openPanel)));
|
||
body.querySelector("[data-replay-intro]").addEventListener("click", actions.replayIntro);
|
||
}
|
||
}
|
||
function gameOver() {
|
||
actions.lock(true, "defeat");
|
||
open("gameover", "The Road Grows Quiet", `<p>You wake at the last safe fire. Your story progress and important items remain.</p><button type="button" class="lima-primary" data-respawn>Rise Again</button>`, false);
|
||
body.querySelector("[data-respawn]").addEventListener("click", () => { overlay.hidden = true; actions.lock(false, "defeat"); actions.respawn(); });
|
||
}
|
||
function ending() {
|
||
actions.lock(true, "ending");
|
||
const allies = ["promised_village", "defences_disabled", "prisoners_freed"].filter((flag) => actions.getState().flags[flag]).length;
|
||
open("ending", "Dawn Over Lima", `<p>At dawn the roads reopen. Village bells answer the resistance fires, and the fortress windows shine with ordinary sunlight.</p><p>${allies >= 2 ? "The people you helped arrive together; no one returns home alone." : "The kingdom begins the slower work of finding one another again."}</p><p class="lima-ending-mark">Princess Lima is free.</p><button type="button" class="lima-primary" data-ending-menu>Return to title</button>`, false);
|
||
body.querySelector("[data-ending-menu]").addEventListener("click", actions.returnToMenu);
|
||
}
|
||
|
||
closeButton.addEventListener("click", close);
|
||
overlay.addEventListener("click", (event) => { if (event.target === overlay && !dialogue) close(); });
|
||
document.addEventListener("keydown", (event) => {
|
||
if (dialogue && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); advance(); }
|
||
if (!dialogue && !overlay.hidden && event.key === "Escape") { event.preventDefault(); close(); }
|
||
});
|
||
return Object.freeze({ openPanel, startDialogue, gameOver, ending, close, finishDialogue });
|
||
}
|
||
function escapeHtml(value) {
|
||
return String(value || "").replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character]));
|
||
}
|
||
root.PrincessLimaV2UI = Object.freeze({ create });
|
||
}(typeof globalThis !== "undefined" ? globalThis : this));
|