Refine Princess Lima for v2 presentation
All checks were successful
Build Org Website / build (push) Successful in 1m2s

This commit is contained in:
gitea-actions
2026-07-30 15:00:45 +01:00
parent a6502b168d
commit 6066f486de
35 changed files with 74848 additions and 141 deletions

View File

@@ -0,0 +1,239 @@
(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));