Refactor platform UI and data flow across core pages
All checks were successful
Build Org Website / build (push) Successful in 50s

This commit is contained in:
gitea-actions
2026-07-30 12:22:44 +01:00
parent fe8acac707
commit a0c78929b3
45 changed files with 1168 additions and 348 deletions

27
assets/scripts/pages/princess-lima-audio.js Normal file → Executable file
View File

@@ -11,6 +11,7 @@
function create(getState) {
let scene = null;
let ambience = null;
let voice = null;
let unlocked = false;
function attach(nextScene, region) {
@@ -33,7 +34,7 @@
const enabled = Boolean(unlocked && state && state.settings.soundEnabled);
scene.sound.mute = !enabled;
if (ambience) {
ambience.setVolume((state.settings.master || 0) * (state.settings.music || 0));
ambience.setVolume(state ? (state.settings.master || 0) * (state.settings.music || 0) : 0);
if (enabled && !ambience.isPlaying) ambience.play();
if (!enabled && ambience.isPlaying) ambience.pause();
}
@@ -41,10 +42,29 @@
function play(key, volume) {
const state = getState();
if (!scene || !unlocked || !state.settings.soundEnabled || !scene.cache.audio.exists(key)) return;
if (!scene || !unlocked || !state || !state.settings.soundEnabled || !scene.cache.audio.exists(key)) return;
scene.sound.play(key, { volume: state.settings.master * state.settings.effects * (volume || 1) });
}
function playVoice(key) {
const state = getState();
stopVoice();
if (!scene || !unlocked || !state || !state.settings.soundEnabled || !state.settings.narrationEnabled
|| !scene.cache.audio.exists(key)) return null;
voice = scene.sound.add(key, { volume: state.settings.master * state.settings.voice });
voice.once("complete", () => { voice = null; });
voice.play();
return voice;
}
function stopVoice() {
if (voice) {
voice.stop();
voice.destroy();
}
voice = null;
}
function suspend() {
if (ambience && ambience.isPlaying) ambience.pause();
}
@@ -54,12 +74,13 @@
}
function stop() {
stopVoice();
if (ambience) ambience.stop();
ambience = null;
scene = null;
}
return Object.freeze({ attach, unlock, apply, play, suspend, resume, stop, isUnlocked: () => unlocked });
return Object.freeze({ attach, unlock, apply, play, playVoice, stopVoice, suspend, resume, stop, isUnlocked: () => unlocked });
}
root.PrincessLimaAudio = Object.freeze({ create, TRACKS });

0
assets/scripts/pages/princess-lima-data.js Normal file → Executable file
View File

86
assets/scripts/pages/princess-lima-game.js Normal file → Executable file
View File

@@ -7,6 +7,7 @@
const Scenes = window.PrincessLimaScenes;
const UI = window.PrincessLimaUI;
const Audio = window.PrincessLimaAudio;
const Intro = window.PrincessLimaIntro;
const controller = {
root: null,
@@ -14,15 +15,16 @@
scene: null,
state: null,
audio: null,
intro: null,
ui: null,
locked: true,
transitioning: false,
move: { up: false, down: false, left: false, right: false },
lastSaveAt: 0,
systemReducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
getState: () => controller.state,
reducedMotion: () => controller.state && controller.state.settings.reducedMotion !== null
? controller.state.settings.reducedMotion : controller.systemReducedMotion,
debugCollision: false,
devWarn: (message) => {
if (["localhost", "127.0.0.1"].includes(window.location.hostname)) console.warn(`[Princess Lima RPG] ${message}`);
}
@@ -32,19 +34,37 @@
function init() {
const root = document.querySelector("[data-princess-lima-rpg]");
if (!root || !Data || !State || !Systems || !Scenes || !UI || !Audio || !window.Phaser) return gracefulFailure();
if (!root || !Data || !State || !Systems || !Scenes || !UI || !Audio || !Intro || !window.PrincessLimaMaps || !window.Phaser) return gracefulFailure();
controller.root = root;
controller.state = loadState();
controller.audio = Audio.create(() => controller.state);
controller.debugCollision = localDebug();
controller.ui = UI.create(root, {
getState: () => controller.state,
onLock: (value) => { controller.locked = value; },
onUseTonic: useTonic,
onSetting: updateSetting,
onFullscreen: toggleFullscreen,
onReplayIntro: () => {
controller.ui.close();
startIntro(true);
},
onReset: resetSave,
onRespawn: respawn
});
controller.intro = Intro.create(root, {
onLock: (value) => { controller.locked = value; },
onAudioUnlock: () => controller.audio.unlock(),
onComplete: completeIntro,
playVoice: (key) => controller.audio.playVoice(key),
stopVoice: () => controller.audio.stopVoice(),
playEffect: (key, volume) => controller.audio.play(key, volume),
narrationEnabled: () => !controller.state || controller.state.settings.narrationEnabled,
subtitlesEnabled: () => !controller.state || controller.state.settings.subtitles,
soundEnabled: () => Boolean(controller.state && controller.state.settings.soundEnabled),
toggleSound,
status
});
bindInterface();
startEngine();
window.addEventListener("error", (event) => {
@@ -85,14 +105,14 @@
const classes = Scenes.createSceneClasses(controller);
controller.game = new Phaser.Game({
type: Phaser.AUTO,
width: Data.WIDTH,
width: 1000,
height: Data.HEIGHT,
parent: "princess-lima-game",
pixelArt: true,
roundPixels: true,
backgroundColor: "#0a0b12",
physics: { default: "arcade", arcade: { debug: localDebug(), gravity: { x: 0, y: 0 } } },
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, width: Data.WIDTH, height: Data.HEIGHT },
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, width: 1000, height: Data.HEIGHT },
scene: classes,
render: { antialias: false, pixelArt: true }
});
@@ -116,7 +136,16 @@
const root = controller.root;
root.querySelector("[data-lima-new]").addEventListener("click", () => openSetup());
root.querySelector("[data-lima-continue]").addEventListener("click", () => {
if (controller.state) beginAdventure();
if (controller.state) {
controller.audio.unlock();
if (controller.state.introSeen) beginAdventure();
else startIntro(false);
}
});
root.querySelector("[data-lima-replay-intro]").addEventListener("click", () => startIntro(true));
root.querySelector("[data-lima-menu-settings]").addEventListener("click", () => {
if (controller.state) openPanel("settings");
else controller.ui.show("settings-help", "Settings", "<p>Create a traveller to save audio and accessibility preferences. The introduction always includes subtitles and can be muted or skipped.</p>");
});
root.querySelector("[data-lima-credits]").addEventListener("click", () => controller.ui.show(
"credits", "Credits",
@@ -124,18 +153,6 @@
));
root.querySelector("[data-lima-setup-form]").addEventListener("submit", submitSetup);
root.querySelector("[data-lima-setup-cancel]").addEventListener("click", closeSetup);
root.querySelectorAll("[data-lima-move]").forEach((button) => {
const direction = button.dataset.limaMove;
const on = (event) => { event.preventDefault(); controller.move[direction] = true; focusGame(); };
const off = (event) => { event.preventDefault(); controller.move[direction] = false; };
button.addEventListener("pointerdown", on);
button.addEventListener("pointerup", off);
button.addEventListener("pointercancel", off);
button.addEventListener("pointerleave", off);
});
bindButton("[data-lima-attack]", () => controller.scene && controller.scene.attack(controller.scene.time.now));
bindButton("[data-lima-interact]", () => controller.scene && controller.scene.interact());
bindButton("[data-lima-item]", useTonic);
bindButton("[data-lima-pause]", () => openPanel("pause"));
bindButton("[data-lima-inventory]", () => openPanel("inventory"));
bindButton("[data-lima-quests]", () => openPanel("quests"));
@@ -172,20 +189,41 @@
controller.state = State.fresh(name, appearance);
save();
closeSetup();
beginAdventure();
startIntro(false);
}
function beginAdventure() {
controller.audio.unlock();
controller.root.querySelector("[data-lima-menu]").hidden = true;
controller.root.querySelector("[data-lima-intro]").hidden = true;
controller.root.querySelector("[data-lima-hud]").hidden = false;
controller.root.querySelector("[data-lima-touch]").hidden = false;
controller.root.querySelector("[data-lima-status-stack]").hidden = false;
controller.locked = false;
controller.game.scene.start("LimaWorld", { region: controller.state.region });
focusGame();
}
function startIntro(replay) {
controller.audio.unlock();
controller.root.querySelector("[data-lima-menu]").hidden = true;
controller.root.querySelector("[data-lima-hud]").hidden = true;
controller.root.querySelector("[data-lima-status-stack]").hidden = true;
controller.intro.start({ replay });
}
function completeIntro(result) {
if (controller.state && !result.replay) {
controller.state.introSeen = true;
controller.state = State.normalize(controller.state);
save();
beginAdventure();
return;
}
controller.locked = true;
controller.root.querySelector("[data-lima-menu]").hidden = false;
controller.root.querySelector("[data-lima-menu] button").focus();
}
function travel(region, spawn) {
if (controller.transitioning || !Data.MAPS[region]) return;
controller.transitioning = true;
@@ -311,12 +349,20 @@
text("[data-lima-chapter]", `Chapter ${controller.state.chapter}`);
text("[data-lima-objective]", Systems.currentObjective(controller.state));
text("[data-lima-tonics]", `Tonic ×${Systems.quantity(controller.state, "healing_tonic")}`);
text("[data-lima-weapon]", controller.state.equipment.weapon ? Data.ITEMS[controller.state.equipment.weapon].name : "Unarmed");
text("[data-lima-armour]", controller.state.equipment.armour ? Data.ITEMS[controller.state.equipment.armour].name : "None");
text("[data-lima-selected-item]", `Healing Tonic ×${Systems.quantity(controller.state, "healing_tonic")}`);
text("[data-lima-currency]", String(Systems.quantity(controller.state, "moon_coin")));
text("[data-lima-sound]", controller.state.settings.soundEnabled ? "Sound On" : "Sound Muted");
const healthBar = controller.root.querySelector(".lima-hud__healthbar i");
if (healthBar) healthBar.style.width = `${controller.state.health / controller.state.maxHealth * 100}%`;
controller.root.classList.toggle("is-high-contrast", controller.state.settings.highContrast);
}
function cycleItem() {
status("Healing Tonic selected. Press Q to use it.");
}
function prompt(message) {
text("[data-lima-prompt]", message || "Explore the road ahead.");
}
@@ -358,6 +404,6 @@
Object.assign(controller, {
ready, travel, reloadRegion, persistPosition, setState, updateHud, status, prompt, boss,
openDialogue, openPanel, gameOver, openEnding, useTonic
openDialogue, openPanel, gameOver, openEnding, useTonic, toggleFullscreen, toggleSound, cycleItem
});
}());

View File

@@ -0,0 +1,154 @@
(function (root) {
"use strict";
const BEATS = Object.freeze([
Object.freeze({
panel: 0, duration: 15000, voice: "intro-narration-1",
subtitle: "Before shadow crossed the northern road, Princess Lima walked among her people—listening before she ruled, and helping before she asked."
}),
Object.freeze({
panel: 1, duration: 14000, voice: "intro-narration-2", effect: "door",
subtitle: "Then Lord Malrec descended from the Fortress of Shadows. His riders carried fear through the valleys, searching for the royal oath."
}),
Object.freeze({
panel: 2, duration: 15000, voice: "intro-narration-3", effect: "attack",
subtitle: "Lima stood between the riders and the village. Malrec could not bend her will, so he bound her in shadow and carried her beyond the mountains."
}),
Object.freeze({
panel: 3, duration: 15000, voice: "intro-narration-4", effect: "quest",
subtitle: "At dawn, a lone traveller reached the broken village. The road was dangerous, but every rescued life would become another light leading to Lima."
}),
Object.freeze({
panel: 3, duration: 8000, voice: null, effect: "victory",
subtitle: "RESCUE PRINCESS LIMA — Chapter One: The Broken Village"
})
]);
function create(container, actions) {
const element = container.querySelector("[data-lima-intro]");
const picture = element.querySelector("[data-lima-intro-picture]");
const subtitle = element.querySelector("[data-lima-intro-subtitle]");
const progress = element.querySelector("[data-lima-intro-progress]");
const pauseButton = element.querySelector("[data-lima-intro-pause]");
const muteButton = element.querySelector("[data-lima-intro-mute]");
let beatIndex = 0;
let elapsed = 0;
let startedAt = 0;
let timer = 0;
let paused = false;
let replay = false;
let escapeStarted = 0;
let running = false;
function start(options) {
stopTimer();
beatIndex = 0;
elapsed = 0;
paused = false;
replay = Boolean(options && options.replay);
running = true;
element.hidden = false;
container.classList.add("is-intro-running");
actions.onLock(true);
actions.onAudioUnlock();
renderBeat();
element.focus();
}
function renderBeat() {
const beat = BEATS[beatIndex];
picture.dataset.panel = String(beat.panel);
subtitle.textContent = beat.subtitle;
subtitle.hidden = !actions.subtitlesEnabled();
progress.style.width = `${beatIndex / BEATS.length * 100}%`;
progress.parentElement.setAttribute("aria-valuenow", String(beatIndex + 1));
if (beat.voice && actions.narrationEnabled()) actions.playVoice(beat.voice);
if (beat.effect) actions.playEffect(beat.effect, 0.55);
startedAt = Date.now();
timer = window.setTimeout(next, beat.duration);
pauseButton.textContent = "Pause";
pauseButton.setAttribute("aria-pressed", "false");
}
function next() {
stopTimer();
actions.stopVoice();
beatIndex += 1;
elapsed = 0;
if (beatIndex >= BEATS.length) return finish(false);
renderBeat();
}
function togglePause() {
if (!running) return;
paused = !paused;
if (paused) {
elapsed += Date.now() - startedAt;
stopTimer();
actions.stopVoice();
} else {
const beat = BEATS[beatIndex];
startedAt = Date.now();
timer = window.setTimeout(next, Math.max(500, beat.duration - elapsed));
if (beat.voice && actions.narrationEnabled()) actions.playVoice(beat.voice);
}
pauseButton.textContent = paused ? "Resume" : "Pause";
pauseButton.setAttribute("aria-pressed", String(paused));
element.classList.toggle("is-paused", paused);
}
function finish(skipped) {
if (!running) return;
running = false;
stopTimer();
actions.stopVoice();
progress.style.width = "100%";
element.hidden = true;
element.classList.remove("is-paused");
container.classList.remove("is-intro-running");
actions.onComplete({ replay, skipped });
}
function stopTimer() {
if (timer) window.clearTimeout(timer);
timer = 0;
}
function updateMute() {
const enabled = actions.soundEnabled();
muteButton.textContent = enabled ? "Mute" : "Enable sound";
muteButton.setAttribute("aria-pressed", String(!enabled));
}
pauseButton.addEventListener("click", togglePause);
muteButton.addEventListener("click", () => {
actions.toggleSound();
updateMute();
});
element.querySelector("[data-lima-intro-skip]").addEventListener("click", () => finish(true));
document.addEventListener("keydown", (event) => {
if (!running) return;
if (event.key === "Escape" && !escapeStarted) {
event.preventDefault();
escapeStarted = Date.now();
} else if (event.key.toLowerCase() === "p") {
event.preventDefault();
togglePause();
}
});
document.addEventListener("keyup", (event) => {
if (!running || event.key !== "Escape") return;
event.preventDefault();
if (escapeStarted && Date.now() - escapeStarted >= 900) finish(true);
else actions.status("Hold Escape for one second to skip the introduction.");
escapeStarted = 0;
});
return Object.freeze({ start, finish, togglePause, isRunning: () => running, updateMute, BEATS });
}
const api = Object.freeze({ create, BEATS });
if (typeof module === "object" && module.exports) module.exports = api;
root.PrincessLimaIntro = api;
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -0,0 +1,156 @@
(function (root) {
"use strict";
const Data = root.PrincessLimaData;
const TILE = 32;
const REGION_ROW = Object.freeze({
village: 0, forest: 1, ruins: 2, mountain: 3, camp: 4,
fortressExterior: 5, fortressInterior: 6, bossArena: 7, chamber: 8
});
const ART = Object.freeze({
village: {
paths: [[1, 10, 38, 4], [17, 7, 6, 15], [3, 16, 34, 3]],
decor: [["garden", 1120, 575], ["cart", 955, 350], ["smoke", 180, 62], ["forge", 520, 250], ["flowers", 785, 610]]
},
forest: {
paths: [[1, 9, 37, 4], [8, 4, 4, 16], [16, 8, 4, 14], [24, 3, 4, 16], [31, 2, 5, 19]],
decor: [["stream", 1040, 300], ["bridge", 1030, 375], ["log", 760, 500], ["flowers", 520, 110], ["mushrooms", 1010, 610]]
},
ruins: {
paths: [[3, 16, 33, 4], [15, 4, 5, 13], [25, 4, 5, 13]],
decor: [["mosaic", 575, 350], ["statue", 230, 190], ["statue", 930, 190], ["arch", 570, 115], ["rubble", 1080, 520]]
},
mountain: {
paths: [[1, 16, 38, 4], [11, 5, 5, 13], [23, 5, 5, 13], [31, 10, 6, 9]],
decor: [["ice", 560, 580], ["pine", 110, 160], ["pine", 1140, 150], ["icicles", 630, 70], ["cave", 1080, 335]]
},
camp: {
paths: [[1, 16, 38, 4], [17, 5, 6, 14], [5, 10, 30, 4]],
decor: [["fire", 640, 345], ["crates", 1120, 570], ["training", 210, 385], ["lookout", 1120, 150], ["banner", 640, 55]]
},
fortressExterior: {
paths: [[1, 17, 38, 4], [18, 4, 5, 17], [10, 9, 20, 5]],
decor: [["moat", 640, 625], ["stairs", 640, 465], ["banner", 640, 120], ["siege", 220, 620], ["siege", 1060, 620]]
},
fortressInterior: {
paths: [[18, 2, 5, 20], [5, 14, 30, 5], [7, 5, 27, 5]],
decor: [["carpet", 640, 360], ["torch", 225, 230], ["torch", 1055, 230], ["crates", 1000, 610], ["banner", 640, 80]]
},
bossArena: {
paths: [[5, 3, 30, 18]],
decor: [["throne", 640, 90], ["rune", 640, 360], ["brazier", 330, 155], ["brazier", 950, 155], ["chains", 640, 45]]
},
chamber: {
paths: [[4, 3, 32, 18]],
decor: [["carpet", 640, 410], ["bed", 1000, 230], ["flowers", 220, 210], ["fountain", 300, 560], ["table", 985, 515]]
}
});
function tileIndex(regionId, kind) {
return REGION_ROW[regionId] * 4 + kind;
}
function createTilemap(scene, regionId) {
const config = ART[regionId];
const rows = Math.ceil(Data.HEIGHT / TILE);
const columns = Math.ceil(Data.WIDTH / TILE);
const base = tileIndex(regionId, 0);
const detail = tileIndex(regionId, 1);
const path = tileIndex(regionId, 2);
const data = Array.from({ length: rows }, (_, y) =>
Array.from({ length: columns }, (_, x) => ((x * 7 + y * 11) % 13 === 0 ? detail : base)));
config.paths.forEach(([left, top, width, height]) => {
for (let y = top; y < Math.min(rows, top + height); y += 1) {
for (let x = left; x < Math.min(columns, left + width); x += 1) data[y][x] = path;
}
});
const map = scene.make.tilemap({ data, tileWidth: TILE, tileHeight: TILE });
const tiles = map.addTilesetImage("lima-world-tiles", "lima-world-tiles", TILE, TILE, 0, 0);
const layer = map.createLayer(0, tiles, 0, 0).setDepth(0);
return { map, layer };
}
function drawObstacle(scene, regionId, shape) {
const frame = tileIndex(regionId, shape.kind === "water" || shape.kind === "chasm" ? 3 : 1);
if (shape.shape === "circle") {
const color = Phaser.Display.Color.HexStringToColor(Data.MAPS[regionId].palette[2]).color;
const edge = Phaser.Display.Color.HexStringToColor(Data.MAPS[regionId].palette[3]).color;
const object = scene.add.circle(shape.x, shape.y, shape.radius, color, 0.98).setStrokeStyle(5, edge).setDepth(8);
scene.add.circle(shape.x - shape.radius * 0.22, shape.y - shape.radius * 0.28, Math.max(4, shape.radius * 0.18), 0xffffff, 0.18).setDepth(9);
return object;
}
const object = scene.add.tileSprite(
shape.x + shape.width / 2, shape.y + shape.height / 2,
shape.width, shape.height, "lima-world-tiles", frame
).setDepth(8);
const graphics = scene.add.graphics().setDepth(9);
const outline = Phaser.Display.Color.HexStringToColor(Data.MAPS[regionId].palette[3]).color;
graphics.lineStyle(3, outline, 0.95).strokeRect(shape.x, shape.y, shape.width, shape.height);
addSilhouette(scene, shape);
return object;
}
function addSilhouette(scene, shape) {
const x = shape.x;
const y = shape.y;
const width = shape.width;
const height = shape.height;
const ink = 0x0b0d13;
if (shape.kind === "house" || shape.kind === "tent") {
scene.add.triangle(x + width / 2, y + 6, 4, Math.min(50, height * 0.35), width / 2, 0, width - 4, Math.min(50, height * 0.35), 0x3e2430, 0.92).setDepth(10);
scene.add.rectangle(x + width / 2, y + height - 14, Math.min(34, width * 0.2), 28, ink, 0.85).setDepth(10);
} else if (shape.kind === "trees") {
for (let py = y + 18; py < y + height; py += 44) {
scene.add.circle(x + width / 2 + ((py / 44) % 2 ? -12 : 12), py, 22, 0x173f2b, 0.96).setStrokeStyle(2, 0x82a957).setDepth(10);
}
} else if (/wall|cell|fence|barricade|cage/.test(shape.kind || "")) {
for (let px = x + 8; px < x + width; px += 18) scene.add.rectangle(px, y + height / 2, 5, height - 5, ink, 0.32).setDepth(10);
} else if (shape.kind === "cliff") {
for (let py = y + 14; py < y + height; py += 34) scene.add.triangle(x + width - 12, py, 0, 0, 22, 8, 4, 22, 0xdce6ee, 0.5).setDepth(10);
}
}
function drawDecor(scene, regionId) {
ART[regionId].decor.forEach(([type, x, y], index) => {
const depth = y + 5;
if (["flowers", "mushrooms"].includes(type)) {
const colors = type === "flowers" ? [0xffd26b, 0xd6759e, 0xf6f1cf] : [0xd95c69, 0xf0c66c];
colors.forEach((color, offset) => scene.add.circle(x + offset * 10, y + (offset % 2) * 5, 4, color).setDepth(depth));
} else if (["fire", "torch", "brazier"].includes(type)) {
scene.add.circle(x, y, type === "fire" ? 18 : 10, 0xff9d3d, 0.92).setStrokeStyle(3, 0xffe18b).setDepth(depth);
scene.add.rectangle(x, y + 15, type === "fire" ? 34 : 8, 7, 0x5b3324).setDepth(depth - 1);
} else if (["stream", "ice", "moat"].includes(type)) {
scene.add.ellipse(x, y, type === "moat" ? 420 : 150, type === "moat" ? 38 : 64, type === "ice" ? 0x9dd9e8 : 0x28758b, 0.72).setStrokeStyle(3, 0xb7eff1).setDepth(3);
} else if (["bridge", "stairs", "carpet"].includes(type)) {
scene.add.rectangle(x, y, type === "carpet" ? 100 : 130, type === "stairs" ? 48 : 40, type === "carpet" ? 0x7f2940 : 0x9a7548, 0.95).setStrokeStyle(3, 0xe0bd75).setDepth(depth);
} else if (["pine", "smoke", "statue", "arch", "lookout", "throne", "fountain"].includes(type)) {
const color = { pine: 0x275437, smoke: 0x70737c, statue: 0x8f9990, arch: 0x767d72, lookout: 0x68452d, throne: 0x613152, fountain: 0x4c99b5 }[type];
scene.add.triangle(x, y, 0, 42, 26, 0, 52, 42, color, 0.94).setStrokeStyle(2, 0xe4c779).setDepth(depth);
} else {
const color = ["rune", "chains"].includes(type) ? 0x8c4aa0 : 0x73513a;
scene.add.rectangle(x, y, 48 + index % 2 * 16, 30, color, 0.88).setStrokeStyle(2, 0xd1ac66).setDepth(depth);
}
});
}
function render(scene, regionId, obstacles, dynamicObstacles, unlockedRoutes) {
createTilemap(scene, regionId);
scene.add.image(Data.WIDTH / 2, Data.HEIGHT / 2, "lima-region-atlas", REGION_ROW[regionId])
.setDisplaySize(Data.WIDTH, Data.HEIGHT)
.setAlpha(regionId === "chamber" ? 0.52 : 0.42)
.setDepth(1);
obstacles.forEach((shape) => drawObstacle(scene, regionId, shape));
(dynamicObstacles || []).forEach((shape) => {
if (!unlockedRoutes.includes(shape.opensWith)) drawObstacle(scene, regionId, shape);
else if (shape.id === "bridge") {
scene.add.tileSprite(shape.x + shape.width / 2, shape.y + shape.height / 2, shape.width, 70, "lima-world-tiles", tileIndex(regionId, 2)).setDepth(7);
}
});
drawDecor(scene, regionId);
}
const api = Object.freeze({ ART, REGION_ROW, createTilemap, drawObstacle, render, tileIndex });
if (typeof module === "object" && module.exports) module.exports = api;
root.PrincessLimaMaps = api;
}(typeof globalThis !== "undefined" ? globalThis : this));

161
assets/scripts/pages/princess-lima-scenes.js Normal file → Executable file
View File

@@ -4,6 +4,7 @@
const Data = root.PrincessLimaData;
const Systems = root.PrincessLimaSystems;
const State = root.PrincessLimaState;
const Maps = root.PrincessLimaMaps;
function createSceneClasses(controller) {
class BootScene extends Phaser.Scene {
@@ -12,16 +13,20 @@
preload() {
this.load.image("lima-title", "/assets/images/play/princess-lima/title-landscape.png");
this.load.spritesheet("lima-cast", "/assets/images/play/princess-lima/cast-atlas.png", { frameWidth: 314, frameHeight: 314 });
this.load.spritesheet("lima-world-tiles", "/assets/images/play/princess-lima/world-tiles.png", { frameWidth: 32, frameHeight: 32 });
this.load.spritesheet("lima-region-atlas", "/assets/images/play/princess-lima/regional-style-atlas.png", { frameWidth: 418, frameHeight: 418 });
const audio = "/assets/audio/princess-lima/";
[
"village-theme", "forest-theme", "mountain-theme", "fortress-theme", "boss-theme", "victory-theme",
"step", "attack", "damage", "defeat", "pickup", "quest", "puzzle", "door", "victory"
"step", "attack", "hit", "damage", "defeat", "pickup", "quest", "puzzle", "door", "victory",
"intro-narration-1", "intro-narration-2", "intro-narration-3", "intro-narration-4"
].forEach((key) => this.load.audio(key, `${audio}${key}.wav`));
this.load.on("loaderror", (file) => controller.devWarn(`Optional asset failed: ${file.key}`));
}
create() {
this.add.image(Data.WIDTH / 2, Data.HEIGHT / 2, "lima-title").setDisplaySize(Data.WIDTH, Data.HEIGHT);
this.add.image(500, Data.HEIGHT / 2, "lima-title").setDisplaySize(1000, Data.HEIGHT);
controller.audio.attach(this, "village");
controller.ready();
}
}
@@ -37,6 +42,9 @@
this.previous = null;
this.puzzleInput = [];
this.enemySerial = 0;
this.attacking = false;
this.attackToken = 0;
this.attackHits = new Set();
}
init(data) {
@@ -84,37 +92,8 @@
}
drawMap() {
const [ground, path, solid, accent] = this.map.palette;
const groundColor = Phaser.Display.Color.HexStringToColor(ground).color;
const pathColor = Phaser.Display.Color.HexStringToColor(path).color;
this.cameras.main.setBackgroundColor(ground);
this.add.rectangle(Data.WIDTH / 2, Data.HEIGHT / 2, Data.WIDTH, Data.HEIGHT, groundColor).setDepth(0);
const grid = this.add.graphics().setDepth(1);
grid.lineStyle(1, pathColor, 0.18);
for (let x = 0; x <= Data.WIDTH; x += 40) grid.lineBetween(x, 0, x, Data.HEIGHT);
for (let y = 0; y <= Data.HEIGHT; y += 40) grid.lineBetween(0, y, Data.WIDTH, y);
this.add.rectangle(Data.WIDTH / 2, Data.HEIGHT / 2, Data.WIDTH - 110, 116, pathColor, 0.55).setDepth(1);
this.add.rectangle(Data.WIDTH / 2, Data.HEIGHT / 2, 110, Data.HEIGHT - 90, pathColor, 0.38).setDepth(1);
this.map.obstacles.forEach((shape) => this.drawShape(shape, solid, accent));
(this.map.dynamicObstacles || []).forEach((shape) => {
if (!controller.getState().unlockedRoutes.includes(shape.opensWith)) this.drawShape(shape, solid, accent);
else if (shape.id === "bridge") this.add.rectangle(shape.x + shape.width / 2, shape.y + shape.height / 2, shape.width, 74, 0x8d6d46).setDepth(2);
});
this.add.text(24, 20, `${this.map.name} · Chapter ${this.map.chapter}`, {
fontFamily: "monospace", fontSize: "18px", color: "#fff5d6",
backgroundColor: "#090b12cc", padding: { x: 10, y: 6 }
}).setScrollFactor(0).setDepth(900);
}
drawShape(shape, solid, accent) {
const main = Phaser.Display.Color.HexStringToColor(solid).color;
const edge = Phaser.Display.Color.HexStringToColor(accent).color;
if (shape.shape === "circle") {
this.add.circle(shape.x, shape.y, shape.radius, main).setStrokeStyle(4, edge, 0.85).setDepth(3);
} else {
this.add.rectangle(shape.x + shape.width / 2, shape.y + shape.height / 2, shape.width, shape.height, main)
.setStrokeStyle(4, edge, 0.75).setDepth(3);
}
this.cameras.main.setBackgroundColor(this.map.palette[0]);
Maps.render(this, this.regionId, this.map.obstacles, this.map.dynamicObstacles, controller.getState().unlockedRoutes);
}
addSolid(shape) {
@@ -223,8 +202,9 @@
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({
up: "W", down: "S", left: "A", right: "D", interact: "E", enter: "ENTER",
item: "Q", pause: "ESC", inventory: "I", quests: "J"
item: "Q", pause: "ESC", menu: "M", inventory: "I", quests: "J", fullscreen: "F", cycle: "TAB"
});
this.input.keyboard.addCapture(["SPACE", "TAB", "UP", "DOWN", "LEFT", "RIGHT"]);
}
update(time, delta) {
@@ -237,10 +217,11 @@
this.previous = { x: this.player.x, y: this.player.y };
let dx = 0;
let dy = 0;
if (this.cursors.left.isDown || this.keys.left.isDown || controller.move.left) dx -= 1;
if (this.cursors.right.isDown || this.keys.right.isDown || controller.move.right) dx += 1;
if (this.cursors.up.isDown || this.keys.up.isDown || controller.move.up) dy -= 1;
if (this.cursors.down.isDown || this.keys.down.isDown || controller.move.down) dy += 1;
if (this.cursors.left.isDown || this.keys.left.isDown) dx -= 1;
if (this.cursors.right.isDown || this.keys.right.isDown) dx += 1;
if (this.cursors.up.isDown || this.keys.up.isDown) dy -= 1;
if (this.cursors.down.isDown || this.keys.down.isDown) dy += 1;
if (this.attacking) dx = dy = 0;
const velocity = Systems.approachVelocity(
this.player.body.velocity.x, this.player.body.velocity.y, dx, dy, delta,
Boolean(controller.getState().equipment.boots)
@@ -289,9 +270,11 @@
if (Phaser.Input.Keyboard.JustDown(this.keys.interact) || Phaser.Input.Keyboard.JustDown(this.keys.enter)) this.interact();
if (Phaser.Input.Keyboard.JustDown(this.cursors.space)) this.attack(time);
if (Phaser.Input.Keyboard.JustDown(this.keys.item)) controller.useTonic();
if (Phaser.Input.Keyboard.JustDown(this.keys.pause)) controller.openPanel("pause");
if (Phaser.Input.Keyboard.JustDown(this.keys.pause) || Phaser.Input.Keyboard.JustDown(this.keys.menu)) controller.openPanel("pause");
if (Phaser.Input.Keyboard.JustDown(this.keys.inventory)) controller.openPanel("inventory");
if (Phaser.Input.Keyboard.JustDown(this.keys.quests)) controller.openPanel("quests");
if (Phaser.Input.Keyboard.JustDown(this.keys.fullscreen)) controller.toggleFullscreen();
if (Phaser.Input.Keyboard.JustDown(this.keys.cycle)) controller.cycleItem();
}
interact() {
@@ -403,29 +386,104 @@
}
attack(time) {
if (time - this.lastAttack < 320 || controller.locked) return;
if (time - this.lastAttack < Systems.ATTACK_TIMING.cooldown || controller.locked || this.attacking) return;
this.lastAttack = time;
this.attacking = true;
const token = ++this.attackToken;
const facing = this.facing;
this.attackHits = new Set();
this.player.setVelocity(0);
controller.audio.play("attack");
const direction = faceVector(this.facing);
const arc = this.add.arc(
this.player.x + direction.x * 46, this.player.y + direction.y * 38,
38, direction.angle - 58, direction.angle + 58, false, 0xffe29a, 0.52
).setDepth(500);
this.physics.add.existing(arc);
this.physics.overlap(arc, this.enemies, (_hit, enemyObject) => this.hitEnemy(enemyObject, direction));
this.time.delayedCall(controller.reducedMotion() ? 75 : 120, () => arc.destroy());
const direction = faceVector(facing);
const blade = this.add.rectangle(0, -22, 6, 42, 0xeaf6ff).setStrokeStyle(2, 0x5e6b78);
const guard = this.add.rectangle(0, 1, 20, 6, 0xf2c467).setStrokeStyle(1, 0x5b3d25);
const grip = this.add.rectangle(0, 11, 6, 19, 0x70432d);
const weapon = this.add.container(
this.player.x + direction.x * 19,
this.player.y + direction.y * 16,
[blade, guard, grip]
).setDepth(690).setAngle(direction.angle + 5);
const windupAngle = facing === "west" || facing === "north" ? -12 : 12;
this.tweens.add({
targets: this.player, angle: windupAngle, scaleX: 0.29, scaleY: 0.31,
duration: Systems.ATTACK_TIMING.windup, ease: "Stepped"
});
this.tweens.add({
targets: weapon,
angle: direction.angle + 90,
duration: Systems.ATTACK_TIMING.windup + Systems.ATTACK_TIMING.active,
ease: "Cubic.Out"
});
this.time.delayedCall(Systems.ATTACK_TIMING.windup, () => {
if (!this.player.active || token !== this.attackToken) return;
this.player.setAngle(-windupAngle).setScale(0.32, 0.28);
this.performAttackHit(facing, token);
});
this.time.delayedCall(Systems.ATTACK_TIMING.windup + Systems.ATTACK_TIMING.active, () => {
if (!this.player.active || token !== this.attackToken) return;
this.player.setAngle(windupAngle / 2).setScale(0.3);
});
this.time.delayedCall(
Systems.ATTACK_TIMING.windup + Systems.ATTACK_TIMING.active + Systems.ATTACK_TIMING.recovery,
() => {
if (!this.player.active || token !== this.attackToken) return;
this.player.setAngle(0).setScale(0.3).setFrame(playerFrame(this.facing));
if (weapon.active) weapon.destroy();
this.attacking = false;
}
);
}
hitEnemy(enemyObject, direction) {
performAttackHit(facing, token) {
const box = Systems.attackHitbox(facing, this.player.x, this.player.y);
const direction = faceVector(facing);
const hitbox = this.add.rectangle(box.x + box.width / 2, box.y + box.height / 2, box.width, box.height, 0xff4df3, controller.debugCollision ? 0.28 : 0);
this.physics.add.existing(hitbox, true);
const slash = this.add.graphics().setDepth(700);
slash.lineStyle(9, 0xffefb0, 0.95).beginPath();
slash.arc(
this.player.x + direction.x * 30, this.player.y + direction.y * 24, 50,
Phaser.Math.DegToRad(direction.angle - 58), Phaser.Math.DegToRad(direction.angle + 58), false
).strokePath();
slash.lineStyle(3, 0xd3f7ff, 0.9).strokePath();
this.physics.overlap(hitbox, this.enemies, (_hit, enemyObject) => this.hitEnemy(enemyObject, direction, token));
this.time.delayedCall(Systems.ATTACK_TIMING.active, () => {
if (hitbox.active) hitbox.destroy();
if (slash.active) slash.destroy();
});
}
hitEnemy(enemyObject, direction, token) {
if (!enemyObject.active || !enemyObject.visible) return;
const enemyId = enemyObject.getData("id");
if (this.attackHits.has(enemyId) || token !== this.attackToken) return;
this.attackHits.add(enemyId);
if (enemyObject.getData("type") === "malrec" && enemyObject.getData("phase") >= 3 && !controller.getState().flags.sun_veil_broken) {
controller.status("Malrec's final veil holds. Activate both Sun Crystal pedestals.");
return;
}
const health = enemyObject.getData("health") - controller.getState().attack;
enemyObject.setData("health", health);
enemyObject.setVelocity(direction.x * 180, direction.y * 180).setTint(0xffc5c5);
this.time.delayedCall(100, () => { if (enemyObject.active) enemyObject.clearTint(); });
const pushDistance = controller.reducedMotion() ? 20 : enemyObject.getData("spec").boss ? 24 : 42;
const target = Systems.wallSafeKnockback(controller.getState(), this.regionId, enemyObject.x, enemyObject.y, direction.x, direction.y, pushDistance);
const push = Systems.normalizedVector(target.x - enemyObject.x, target.y - enemyObject.y, controller.reducedMotion() ? 90 : 190);
enemyObject.setData("stunnedUntil", this.time.now + 210);
enemyObject.setVelocity(push.x, push.y).setTintFill(0xffe1b0);
controller.audio.play("hit", enemyObject.getData("spec").boss ? 1 : 0.78);
const impact = this.add.star(enemyObject.x, enemyObject.y - 15, 6, 5, 15, 0xfff1a8, 0.95).setDepth(750);
this.time.delayedCall(70, () => { if (impact.active) impact.destroy(); });
this.time.delayedCall(110, () => {
if (enemyObject.active) {
enemyObject.clearTint();
enemyObject.setVelocity(0);
}
});
const pause = enemyObject.getData("spec").boss ? 58 : 38;
this.physics.world.pause();
this.time.delayedCall(pause, () => { if (this.physics.world) this.physics.world.resume(); });
if (enemyObject.getData("spec").boss && !controller.reducedMotion() && controller.getState().settings.screenShake) {
this.cameras.main.shake(70, 0.0025);
}
if (health <= 0) this.defeatEnemy(enemyObject);
}
@@ -448,6 +506,7 @@
updateEnemies(time) {
this.enemies.getChildren().forEach((enemyObject) => {
if (!enemyObject.active || !enemyObject.body.enable) return;
if (time < (enemyObject.getData("stunnedUntil") || 0)) return;
const spec = enemyObject.getData("spec");
const distance = Math.hypot(enemyObject.x - this.player.x, enemyObject.y - this.player.y);
const homeDistance = Math.hypot(enemyObject.x - enemyObject.getData("homeX"), enemyObject.y - enemyObject.getData("homeY"));

9
assets/scripts/pages/princess-lima-state.js Normal file → Executable file
View File

@@ -53,12 +53,16 @@
openedChests: [],
unlockedRoutes: [],
rescued: false,
introSeen: false,
playTimeSeconds: 0,
settings: {
soundEnabled: false,
master: 0.8,
music: 0.45,
effects: 0.7,
voice: 0.85,
narrationEnabled: true,
subtitles: true,
reducedMotion: null,
screenShake: true,
highContrast: false,
@@ -147,12 +151,17 @@
openedChests: Array.isArray(candidate.openedChests) ? Array.from(new Set(candidate.openedChests.filter((id) => typeof id === "string"))).slice(0, 64) : [],
unlockedRoutes: unique(candidate.unlockedRoutes, ["village_defended", "guide_found", "ruins_complete", "briar_defeated", "bridge_repaired", "guardian_defeated", "emblem_found", "wards_broken", "malrec_defeated"]),
rescued: candidate.rescued === true,
// Existing v1 saves predate the cinematic and must continue directly.
introSeen: candidate.introSeen !== false,
playTimeSeconds: Math.floor(finite(candidate.playTimeSeconds, 0, 0, 999999)),
settings: {
soundEnabled: candidate.settings && candidate.settings.soundEnabled === true,
master: finite(candidate.settings && candidate.settings.master, 0.8, 0, 1),
music: finite(candidate.settings && candidate.settings.music, 0.45, 0, 1),
effects: finite(candidate.settings && candidate.settings.effects, 0.7, 0, 1),
voice: finite(candidate.settings && candidate.settings.voice, 0.85, 0, 1),
narrationEnabled: !(candidate.settings && candidate.settings.narrationEnabled === false),
subtitles: !(candidate.settings && candidate.settings.subtitles === false),
reducedMotion: candidate.settings && typeof candidate.settings.reducedMotion === "boolean" ? candidate.settings.reducedMotion : null,
screenShake: !(candidate.settings && candidate.settings.screenShake === false),
highContrast: candidate.settings && candidate.settings.highContrast === true,

35
assets/scripts/pages/princess-lima-systems.js Normal file → Executable file
View File

@@ -166,6 +166,38 @@
return ratio > 0.66 ? 1 : ratio > 0.33 ? 2 : 3;
}
const ATTACK_TIMING = Object.freeze({ windup: 85, active: 105, recovery: 135, cooldown: 360 });
function attackPhase(elapsed, timing) {
const value = Math.max(0, Number(elapsed) || 0);
const config = timing || ATTACK_TIMING;
if (value < config.windup) return "windup";
if (value < config.windup + config.active) return "active";
if (value < config.windup + config.active + config.recovery) return "recovery";
return "complete";
}
function attackHitbox(facing, x, y) {
const boxes = {
north: { x: x - 25, y: y - 76, width: 50, height: 66, angle: 270 },
south: { x: x - 25, y: y + 8, width: 50, height: 66, angle: 90 },
west: { x: x - 76, y: y - 27, width: 66, height: 54, angle: 180 },
east: { x: x + 10, y: y - 27, width: 66, height: 54, angle: 0 }
};
return boxes[facing] || boxes.south;
}
function wallSafeKnockback(state, regionId, x, y, directionX, directionY, distance) {
const push = normalizedVector(directionX, directionY, Math.max(0, Number(distance) || 0));
const target = { x: x + push.x, y: y + push.y };
if (isSafePosition(state, regionId, target.x, target.y)) return target;
for (let scale = 0.75; scale >= 0; scale -= 0.25) {
const candidate = { x: x + push.x * scale, y: y + push.y * scale };
if (isSafePosition(state, regionId, candidate.x, candidate.y)) return candidate;
}
return { x, y };
}
function respawn(state) {
let next = copy(state);
if (!next) return next;
@@ -207,6 +239,7 @@
return Object.freeze({
normalizedVector, approachVelocity, pointInShape, activeObstacles, isSafePosition, nearestSafeSpawn,
quantity, addItem, removeItem, useItem, startQuest, progressQuest, completeQuest, solvePuzzle,
damage, bossPhase, respawn, recordBoss, currentObjective
damage, bossPhase, ATTACK_TIMING, attackPhase, attackHitbox, wallSafeKnockback,
respawn, recordBoss, currentObjective
});
}));

14
assets/scripts/pages/princess-lima-ui.js Normal file → Executable file
View File

@@ -98,6 +98,9 @@
<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>
@@ -114,12 +117,14 @@
<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);
}
@@ -164,6 +169,15 @@
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 });