Files
org_web/assets/scripts/pages/princess-lima-v2-ui.js
gitea-actions 563d4b63cc
All checks were successful
Build Org Website / build (push) Successful in 37s
Tune Princess Lima v2 boss balance and Sun Crystal aid
2026-07-30 15:36:59 +01:00

240 lines
14 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.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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[character]));
}
root.PrincessLimaV2UI = Object.freeze({ create });
}(typeof globalThis !== "undefined" ? globalThis : this));