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

0
assets/audio/princess-lima/attack.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/boss-theme.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/damage.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/defeat.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/door.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/forest-theme.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/fortress-theme.wav Normal file → Executable file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

0
assets/audio/princess-lima/mountain-theme.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/pickup.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/puzzle.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/quest.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/step.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/victory-theme.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/victory.wav Normal file → Executable file
View File

0
assets/audio/princess-lima/village-theme.wav Normal file → Executable file
View File

0
assets/images/play/princess-lima/cast-atlas.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 975 KiB

After

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

0
assets/images/play/princess-lima/title-landscape.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

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

@@ -11,6 +11,7 @@
function create(getState) { function create(getState) {
let scene = null; let scene = null;
let ambience = null; let ambience = null;
let voice = null;
let unlocked = false; let unlocked = false;
function attach(nextScene, region) { function attach(nextScene, region) {
@@ -33,7 +34,7 @@
const enabled = Boolean(unlocked && state && state.settings.soundEnabled); const enabled = Boolean(unlocked && state && state.settings.soundEnabled);
scene.sound.mute = !enabled; scene.sound.mute = !enabled;
if (ambience) { 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.play();
if (!enabled && ambience.isPlaying) ambience.pause(); if (!enabled && ambience.isPlaying) ambience.pause();
} }
@@ -41,10 +42,29 @@
function play(key, volume) { function play(key, volume) {
const state = getState(); 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) }); 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() { function suspend() {
if (ambience && ambience.isPlaying) ambience.pause(); if (ambience && ambience.isPlaying) ambience.pause();
} }
@@ -54,12 +74,13 @@
} }
function stop() { function stop() {
stopVoice();
if (ambience) ambience.stop(); if (ambience) ambience.stop();
ambience = null; ambience = null;
scene = 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 }); 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 Scenes = window.PrincessLimaScenes;
const UI = window.PrincessLimaUI; const UI = window.PrincessLimaUI;
const Audio = window.PrincessLimaAudio; const Audio = window.PrincessLimaAudio;
const Intro = window.PrincessLimaIntro;
const controller = { const controller = {
root: null, root: null,
@@ -14,15 +15,16 @@
scene: null, scene: null,
state: null, state: null,
audio: null, audio: null,
intro: null,
ui: null, ui: null,
locked: true, locked: true,
transitioning: false, transitioning: false,
move: { up: false, down: false, left: false, right: false },
lastSaveAt: 0, lastSaveAt: 0,
systemReducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches, systemReducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
getState: () => controller.state, getState: () => controller.state,
reducedMotion: () => controller.state && controller.state.settings.reducedMotion !== null reducedMotion: () => controller.state && controller.state.settings.reducedMotion !== null
? controller.state.settings.reducedMotion : controller.systemReducedMotion, ? controller.state.settings.reducedMotion : controller.systemReducedMotion,
debugCollision: false,
devWarn: (message) => { devWarn: (message) => {
if (["localhost", "127.0.0.1"].includes(window.location.hostname)) console.warn(`[Princess Lima RPG] ${message}`); if (["localhost", "127.0.0.1"].includes(window.location.hostname)) console.warn(`[Princess Lima RPG] ${message}`);
} }
@@ -32,19 +34,37 @@
function init() { function init() {
const root = document.querySelector("[data-princess-lima-rpg]"); 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.root = root;
controller.state = loadState(); controller.state = loadState();
controller.audio = Audio.create(() => controller.state); controller.audio = Audio.create(() => controller.state);
controller.debugCollision = localDebug();
controller.ui = UI.create(root, { controller.ui = UI.create(root, {
getState: () => controller.state, getState: () => controller.state,
onLock: (value) => { controller.locked = value; }, onLock: (value) => { controller.locked = value; },
onUseTonic: useTonic, onUseTonic: useTonic,
onSetting: updateSetting, onSetting: updateSetting,
onFullscreen: toggleFullscreen, onFullscreen: toggleFullscreen,
onReplayIntro: () => {
controller.ui.close();
startIntro(true);
},
onReset: resetSave, onReset: resetSave,
onRespawn: respawn 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(); bindInterface();
startEngine(); startEngine();
window.addEventListener("error", (event) => { window.addEventListener("error", (event) => {
@@ -85,14 +105,14 @@
const classes = Scenes.createSceneClasses(controller); const classes = Scenes.createSceneClasses(controller);
controller.game = new Phaser.Game({ controller.game = new Phaser.Game({
type: Phaser.AUTO, type: Phaser.AUTO,
width: Data.WIDTH, width: 1000,
height: Data.HEIGHT, height: Data.HEIGHT,
parent: "princess-lima-game", parent: "princess-lima-game",
pixelArt: true, pixelArt: true,
roundPixels: true, roundPixels: true,
backgroundColor: "#0a0b12", backgroundColor: "#0a0b12",
physics: { default: "arcade", arcade: { debug: localDebug(), gravity: { x: 0, y: 0 } } }, 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, scene: classes,
render: { antialias: false, pixelArt: true } render: { antialias: false, pixelArt: true }
}); });
@@ -116,7 +136,16 @@
const root = controller.root; const root = controller.root;
root.querySelector("[data-lima-new]").addEventListener("click", () => openSetup()); root.querySelector("[data-lima-new]").addEventListener("click", () => openSetup());
root.querySelector("[data-lima-continue]").addEventListener("click", () => { 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( root.querySelector("[data-lima-credits]").addEventListener("click", () => controller.ui.show(
"credits", "Credits", "credits", "Credits",
@@ -124,18 +153,6 @@
)); ));
root.querySelector("[data-lima-setup-form]").addEventListener("submit", submitSetup); root.querySelector("[data-lima-setup-form]").addEventListener("submit", submitSetup);
root.querySelector("[data-lima-setup-cancel]").addEventListener("click", closeSetup); 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-pause]", () => openPanel("pause"));
bindButton("[data-lima-inventory]", () => openPanel("inventory")); bindButton("[data-lima-inventory]", () => openPanel("inventory"));
bindButton("[data-lima-quests]", () => openPanel("quests")); bindButton("[data-lima-quests]", () => openPanel("quests"));
@@ -172,20 +189,41 @@
controller.state = State.fresh(name, appearance); controller.state = State.fresh(name, appearance);
save(); save();
closeSetup(); closeSetup();
beginAdventure(); startIntro(false);
} }
function beginAdventure() { function beginAdventure() {
controller.audio.unlock(); controller.audio.unlock();
controller.root.querySelector("[data-lima-menu]").hidden = true; 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-hud]").hidden = false;
controller.root.querySelector("[data-lima-touch]").hidden = false;
controller.root.querySelector("[data-lima-status-stack]").hidden = false; controller.root.querySelector("[data-lima-status-stack]").hidden = false;
controller.locked = false; controller.locked = false;
controller.game.scene.start("LimaWorld", { region: controller.state.region }); controller.game.scene.start("LimaWorld", { region: controller.state.region });
focusGame(); 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) { function travel(region, spawn) {
if (controller.transitioning || !Data.MAPS[region]) return; if (controller.transitioning || !Data.MAPS[region]) return;
controller.transitioning = true; controller.transitioning = true;
@@ -311,12 +349,20 @@
text("[data-lima-chapter]", `Chapter ${controller.state.chapter}`); text("[data-lima-chapter]", `Chapter ${controller.state.chapter}`);
text("[data-lima-objective]", Systems.currentObjective(controller.state)); text("[data-lima-objective]", Systems.currentObjective(controller.state));
text("[data-lima-tonics]", `Tonic ×${Systems.quantity(controller.state, "healing_tonic")}`); 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"); text("[data-lima-sound]", controller.state.settings.soundEnabled ? "Sound On" : "Sound Muted");
const healthBar = controller.root.querySelector(".lima-hud__healthbar i"); const healthBar = controller.root.querySelector(".lima-hud__healthbar i");
if (healthBar) healthBar.style.width = `${controller.state.health / controller.state.maxHealth * 100}%`; if (healthBar) healthBar.style.width = `${controller.state.health / controller.state.maxHealth * 100}%`;
controller.root.classList.toggle("is-high-contrast", controller.state.settings.highContrast); 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) { function prompt(message) {
text("[data-lima-prompt]", message || "Explore the road ahead."); text("[data-lima-prompt]", message || "Explore the road ahead.");
} }
@@ -358,6 +404,6 @@
Object.assign(controller, { Object.assign(controller, {
ready, travel, reloadRegion, persistPosition, setState, updateHud, status, prompt, boss, 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 Data = root.PrincessLimaData;
const Systems = root.PrincessLimaSystems; const Systems = root.PrincessLimaSystems;
const State = root.PrincessLimaState; const State = root.PrincessLimaState;
const Maps = root.PrincessLimaMaps;
function createSceneClasses(controller) { function createSceneClasses(controller) {
class BootScene extends Phaser.Scene { class BootScene extends Phaser.Scene {
@@ -12,16 +13,20 @@
preload() { preload() {
this.load.image("lima-title", "/assets/images/play/princess-lima/title-landscape.png"); 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-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/"; const audio = "/assets/audio/princess-lima/";
[ [
"village-theme", "forest-theme", "mountain-theme", "fortress-theme", "boss-theme", "victory-theme", "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`)); ].forEach((key) => this.load.audio(key, `${audio}${key}.wav`));
this.load.on("loaderror", (file) => controller.devWarn(`Optional asset failed: ${file.key}`)); this.load.on("loaderror", (file) => controller.devWarn(`Optional asset failed: ${file.key}`));
} }
create() { 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(); controller.ready();
} }
} }
@@ -37,6 +42,9 @@
this.previous = null; this.previous = null;
this.puzzleInput = []; this.puzzleInput = [];
this.enemySerial = 0; this.enemySerial = 0;
this.attacking = false;
this.attackToken = 0;
this.attackHits = new Set();
} }
init(data) { init(data) {
@@ -84,37 +92,8 @@
} }
drawMap() { drawMap() {
const [ground, path, solid, accent] = this.map.palette; this.cameras.main.setBackgroundColor(this.map.palette[0]);
const groundColor = Phaser.Display.Color.HexStringToColor(ground).color; Maps.render(this, this.regionId, this.map.obstacles, this.map.dynamicObstacles, controller.getState().unlockedRoutes);
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);
}
} }
addSolid(shape) { addSolid(shape) {
@@ -223,8 +202,9 @@
this.cursors = this.input.keyboard.createCursorKeys(); this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({ this.keys = this.input.keyboard.addKeys({
up: "W", down: "S", left: "A", right: "D", interact: "E", enter: "ENTER", 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) { update(time, delta) {
@@ -237,10 +217,11 @@
this.previous = { x: this.player.x, y: this.player.y }; this.previous = { x: this.player.x, y: this.player.y };
let dx = 0; let dx = 0;
let dy = 0; let dy = 0;
if (this.cursors.left.isDown || this.keys.left.isDown || controller.move.left) dx -= 1; if (this.cursors.left.isDown || this.keys.left.isDown) dx -= 1;
if (this.cursors.right.isDown || this.keys.right.isDown || controller.move.right) dx += 1; if (this.cursors.right.isDown || this.keys.right.isDown) dx += 1;
if (this.cursors.up.isDown || this.keys.up.isDown || controller.move.up) dy -= 1; if (this.cursors.up.isDown || this.keys.up.isDown) dy -= 1;
if (this.cursors.down.isDown || this.keys.down.isDown || controller.move.down) dy += 1; if (this.cursors.down.isDown || this.keys.down.isDown) dy += 1;
if (this.attacking) dx = dy = 0;
const velocity = Systems.approachVelocity( const velocity = Systems.approachVelocity(
this.player.body.velocity.x, this.player.body.velocity.y, dx, dy, delta, this.player.body.velocity.x, this.player.body.velocity.y, dx, dy, delta,
Boolean(controller.getState().equipment.boots) 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.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.cursors.space)) this.attack(time);
if (Phaser.Input.Keyboard.JustDown(this.keys.item)) controller.useTonic(); 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.inventory)) controller.openPanel("inventory");
if (Phaser.Input.Keyboard.JustDown(this.keys.quests)) controller.openPanel("quests"); 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() { interact() {
@@ -403,29 +386,104 @@
} }
attack(time) { 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.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"); controller.audio.play("attack");
const direction = faceVector(this.facing); const direction = faceVector(facing);
const arc = this.add.arc( const blade = this.add.rectangle(0, -22, 6, 42, 0xeaf6ff).setStrokeStyle(2, 0x5e6b78);
this.player.x + direction.x * 46, this.player.y + direction.y * 38, const guard = this.add.rectangle(0, 1, 20, 6, 0xf2c467).setStrokeStyle(1, 0x5b3d25);
38, direction.angle - 58, direction.angle + 58, false, 0xffe29a, 0.52 const grip = this.add.rectangle(0, 11, 6, 19, 0x70432d);
).setDepth(500); const weapon = this.add.container(
this.physics.add.existing(arc); this.player.x + direction.x * 19,
this.physics.overlap(arc, this.enemies, (_hit, enemyObject) => this.hitEnemy(enemyObject, direction)); this.player.y + direction.y * 16,
this.time.delayedCall(controller.reducedMotion() ? 75 : 120, () => arc.destroy()); [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; 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) { 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."); controller.status("Malrec's final veil holds. Activate both Sun Crystal pedestals.");
return; return;
} }
const health = enemyObject.getData("health") - controller.getState().attack; const health = enemyObject.getData("health") - controller.getState().attack;
enemyObject.setData("health", health); enemyObject.setData("health", health);
enemyObject.setVelocity(direction.x * 180, direction.y * 180).setTint(0xffc5c5); const pushDistance = controller.reducedMotion() ? 20 : enemyObject.getData("spec").boss ? 24 : 42;
this.time.delayedCall(100, () => { if (enemyObject.active) enemyObject.clearTint(); }); 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); if (health <= 0) this.defeatEnemy(enemyObject);
} }
@@ -448,6 +506,7 @@
updateEnemies(time) { updateEnemies(time) {
this.enemies.getChildren().forEach((enemyObject) => { this.enemies.getChildren().forEach((enemyObject) => {
if (!enemyObject.active || !enemyObject.body.enable) return; if (!enemyObject.active || !enemyObject.body.enable) return;
if (time < (enemyObject.getData("stunnedUntil") || 0)) return;
const spec = enemyObject.getData("spec"); const spec = enemyObject.getData("spec");
const distance = Math.hypot(enemyObject.x - this.player.x, enemyObject.y - this.player.y); 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")); 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: [], openedChests: [],
unlockedRoutes: [], unlockedRoutes: [],
rescued: false, rescued: false,
introSeen: false,
playTimeSeconds: 0, playTimeSeconds: 0,
settings: { settings: {
soundEnabled: false, soundEnabled: false,
master: 0.8, master: 0.8,
music: 0.45, music: 0.45,
effects: 0.7, effects: 0.7,
voice: 0.85,
narrationEnabled: true,
subtitles: true,
reducedMotion: null, reducedMotion: null,
screenShake: true, screenShake: true,
highContrast: false, 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) : [], 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"]), 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, 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)), playTimeSeconds: Math.floor(finite(candidate.playTimeSeconds, 0, 0, 999999)),
settings: { settings: {
soundEnabled: candidate.settings && candidate.settings.soundEnabled === true, soundEnabled: candidate.settings && candidate.settings.soundEnabled === true,
master: finite(candidate.settings && candidate.settings.master, 0.8, 0, 1), master: finite(candidate.settings && candidate.settings.master, 0.8, 0, 1),
music: finite(candidate.settings && candidate.settings.music, 0.45, 0, 1), music: finite(candidate.settings && candidate.settings.music, 0.45, 0, 1),
effects: finite(candidate.settings && candidate.settings.effects, 0.7, 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, reducedMotion: candidate.settings && typeof candidate.settings.reducedMotion === "boolean" ? candidate.settings.reducedMotion : null,
screenShake: !(candidate.settings && candidate.settings.screenShake === false), screenShake: !(candidate.settings && candidate.settings.screenShake === false),
highContrast: candidate.settings && candidate.settings.highContrast === true, 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; 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) { function respawn(state) {
let next = copy(state); let next = copy(state);
if (!next) return next; if (!next) return next;
@@ -207,6 +239,7 @@
return Object.freeze({ return Object.freeze({
normalizedVector, approachVelocity, pointInShape, activeObstacles, isSafePosition, nearestSafeSpawn, normalizedVector, approachVelocity, pointInShape, activeObstacles, isSafePosition, nearestSafeSpawn,
quantity, addItem, removeItem, useItem, startQuest, progressQuest, completeQuest, solvePuzzle, 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>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>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>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="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="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-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="inventory">Inventory</button>
<button type="button" data-panel="quests">Quest Log</button> <button type="button" data-panel="quests">Quest Log</button>
<button type="button" data-panel="settings">Settings & accessibility</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-fullscreen-panel>Toggle fullscreen</button>
<button type="button" data-lima-reset-request>Reset save</button> <button type="button" data-lima-reset-request>Reset save</button>
<a href="/">Exit to Website</a> <a href="/">Exit to Website</a>
</div> </div>
`); `);
body.querySelectorAll("[data-panel]").forEach((button) => button.addEventListener("click", () => openPanel(button.dataset.panel, actions.getState()))); 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-fullscreen-panel]").addEventListener("click", actions.onFullscreen);
body.querySelector("[data-lima-reset-request]").addEventListener("click", confirmReset); body.querySelector("[data-lima-reset-request]").addEventListener("click", confirmReset);
} }
@@ -164,6 +169,15 @@
event.preventDefault(); event.preventDefault();
advanceDialogue(); 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 }); return Object.freeze({ show, close, openDialogue, openPanel, gameOver, ending, confirmReset });

317
assets/styles/pages/princess-lima-rpg.css Normal file → Executable file
View File

@@ -93,16 +93,17 @@ button:disabled {
.lima-rpg__game { .lima-rpg__game {
position: absolute; position: absolute;
inset: 0; inset: 0 0 0 272px;
display: grid; display: grid;
place-items: center; place-items: center;
overflow: hidden;
background: #080a12; background: #080a12;
} }
.lima-rpg__game canvas { .lima-rpg__game canvas {
display: block; display: block;
max-width: 100vw; max-width: none;
max-height: 100dvh; max-height: none;
image-rendering: pixelated; image-rendering: pixelated;
} }
@@ -177,33 +178,27 @@ button:disabled {
border-color: #e7888f; border-color: #e7888f;
} }
.lima-exit {
position: absolute;
z-index: 80;
top: 0.75rem;
right: 0.75rem;
min-height: 36px;
padding: 0.4rem 0.75rem;
background: rgb(9 13 23 / 0.9);
font-size: 0.86rem;
}
.lima-hud { .lima-hud {
position: absolute; position: absolute;
z-index: 30; z-index: 30;
inset: 0.75rem 10rem auto 0.75rem; inset: 0 auto 0 0;
display: grid; display: flex;
grid-template-columns: auto minmax(12rem, 22rem) minmax(10rem, 1fr) auto; width: 272px;
gap: 0.55rem; flex-direction: column;
pointer-events: none; gap: 0;
overflow: auto;
border-right: 3px solid #b78b4d;
background:
linear-gradient(180deg, rgb(20 29 47 / 0.98), rgb(7 12 22 / 0.99)),
url("/assets/images/play/princess-lima/world-tiles.png");
box-shadow: 8px 0 24px rgb(0 0 0 / 0.38);
pointer-events: auto;
} }
.lima-hud > div { .lima-hud > div {
min-height: 58px; border-bottom: 1px solid rgb(214 174 99 / 0.38);
border: 2px solid rgb(214 174 99 / 0.72); background: rgb(8 12 22 / 0.48);
border-radius: 5px; padding: 0.72rem 0.85rem;
background: rgb(8 12 22 / 0.93);
padding: 0.45rem 0.65rem;
} }
.lima-hud span { .lima-hud span {
@@ -222,6 +217,43 @@ button:disabled {
line-height: 1.2; line-height: 1.2;
} }
.lima-hud__identity {
display: grid;
grid-template-columns: 66px 1fr;
align-items: center;
gap: 0.7rem;
border-bottom: 2px solid #9d7440;
padding: 0.8rem;
}
.lima-hud__portrait {
width: 64px;
height: 64px;
border: 2px solid #d8b66d;
border-radius: 4px;
background-size: 256px 256px;
}
.lima-hud__equipment {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.2rem 0.55rem;
}
.lima-hud__equipment strong {
margin: 0 0 0.45rem;
font-size: 0.84rem;
text-align: right;
}
.lima-hud__objective strong {
display: -webkit-box;
overflow: hidden;
font-size: 0.9rem;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.lima-hud__healthbar, .lima-hud__healthbar,
.lima-boss__bar { .lima-boss__bar {
height: 8px; height: 8px;
@@ -240,22 +272,47 @@ button:disabled {
} }
.lima-hud__actions { .lima-hud__actions {
display: flex; display: grid;
gap: 0.4rem; grid-template-columns: 1fr 1fr;
gap: 0.42rem;
pointer-events: auto; pointer-events: auto;
} }
.lima-hud__actions button { .lima-hud__actions button {
min-width: 48px; min-width: 0;
min-height: 48px; min-height: 42px;
padding: 0.4rem; padding: 0.35rem;
font-size: 0.78rem; font-size: 0.72rem;
}
.lima-hud__actions .lima-button-link {
display: grid;
grid-column: 1 / -1;
place-items: center;
min-height: 38px;
font-size: 0.75rem;
}
.lima-hud kbd {
border: 1px solid #897553;
border-radius: 3px;
background: #090d16;
padding: 0.05rem 0.22rem;
}
.lima-hud__keys {
margin: auto 0 0;
border-top: 1px solid #654e32;
color: #bcb9b0;
padding: 0.65rem 0.8rem;
font-size: 0.68rem;
line-height: 1.45;
} }
.lima-status-stack { .lima-status-stack {
position: absolute; position: absolute;
z-index: 25; z-index: 25;
left: 50%; left: calc(50% + 136px);
bottom: 1rem; bottom: 1rem;
width: min(44rem, calc(100vw - 2rem)); width: min(44rem, calc(100vw - 2rem));
transform: translateX(-50%); transform: translateX(-50%);
@@ -281,12 +338,8 @@ button:disabled {
} }
.lima-boss { .lima-boss {
position: absolute; position: static;
z-index: 35; width: auto;
top: 5.2rem;
left: 50%;
width: min(34rem, calc(100vw - 2rem));
transform: translateX(-50%);
border: 2px solid #bd6d8b; border: 2px solid #bd6d8b;
background: rgb(18 8 25 / 0.95); background: rgb(18 8 25 / 0.95);
padding: 0.55rem 0.8rem; padding: 0.55rem 0.8rem;
@@ -297,42 +350,6 @@ button:disabled {
background: linear-gradient(90deg, #9d3961, #e28f98); background: linear-gradient(90deg, #9d3961, #e28f98);
} }
.lima-touch {
position: absolute;
z-index: 40;
inset: auto 0.8rem 0.8rem;
display: flex;
justify-content: space-between;
pointer-events: none;
}
.lima-dpad,
.lima-actions {
display: grid;
gap: 0.35rem;
pointer-events: auto;
}
.lima-dpad {
grid-template-columns: repeat(3, 54px);
grid-template-rows: repeat(2, 54px);
}
.lima-dpad button {
min-height: 54px;
padding: 0;
touch-action: none;
}
.lima-dpad [data-lima-move="up"] { grid-column: 2; }
.lima-dpad [data-lima-move="left"] { grid-column: 1; grid-row: 2; }
.lima-dpad [data-lima-move="down"] { grid-column: 2; grid-row: 2; }
.lima-dpad [data-lima-move="right"] { grid-column: 3; grid-row: 2; }
.lima-actions {
grid-template-columns: repeat(2, minmax(70px, 96px));
}
.lima-overlay, .lima-overlay,
.lima-setup { .lima-setup {
position: absolute; position: absolute;
@@ -520,6 +537,107 @@ button:disabled {
color: #ffb1b9; color: #ffb1b9;
} }
.lima-intro {
position: absolute;
z-index: 120;
inset: 0;
overflow: hidden;
background: #03050a;
}
.lima-intro__picture {
position: absolute;
inset: 0;
background-image: url("/assets/images/play/princess-lima/intro-storyboard.png");
background-repeat: no-repeat;
background-position: 0 0;
background-size: 200% 200%;
image-rendering: pixelated;
animation: lima-cinematic-pan 15s ease-in-out both;
}
.lima-intro__picture[data-panel="1"] { background-position: 100% 0; }
.lima-intro__picture[data-panel="2"] { background-position: 0 100%; }
.lima-intro__picture[data-panel="3"] { background-position: 100% 100%; }
.lima-intro__vignette {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgb(0 0 0 / 0.22), transparent 40%, rgb(0 0 0 / 0.75));
pointer-events: none;
}
.lima-intro__subtitle {
position: absolute;
left: 50%;
bottom: 5.2rem;
width: min(58rem, calc(100% - 3rem));
transform: translateX(-50%);
border: 2px solid #d7b76d;
border-radius: 4px;
background: rgb(4 7 14 / 0.94);
color: #fff9e9;
padding: 0.85rem 1.1rem;
font-family: Georgia, serif;
font-size: clamp(1rem, 2.1vw, 1.45rem);
line-height: 1.45;
text-align: center;
text-shadow: 0 2px #000;
}
.lima-intro__progress {
position: absolute;
right: 1rem;
bottom: 1rem;
left: 1rem;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #251d2a;
}
.lima-intro__progress i {
display: block;
width: 0;
height: 100%;
background: #f1c66e;
transition: width 0.4s linear;
}
.lima-intro__controls {
position: absolute;
z-index: 2;
top: 0.75rem;
right: 0.75rem;
display: flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #75633e;
background: rgb(3 5 10 / 0.86);
padding: 0.4rem;
}
.lima-intro__controls button {
min-height: 38px;
padding: 0.38rem 0.65rem;
font-size: 0.78rem;
}
.lima-intro__controls span {
color: #e5dcc5;
padding: 0 0.35rem;
font-size: 0.76rem;
}
.lima-intro.is-paused .lima-intro__picture {
animation-play-state: paused;
}
@keyframes lima-cinematic-pan {
from { transform: scale(1.02) translate3d(-0.5%, 0, 0); }
to { transform: scale(1.09) translate3d(0.5%, -0.5%, 0); }
}
.lima-rpg.is-high-contrast { .lima-rpg.is-high-contrast {
--lima-panel: #000; --lima-panel: #000;
--lima-panel-strong: #000; --lima-panel-strong: #000;
@@ -534,41 +652,18 @@ button:disabled {
height: 100vh; height: 100vh;
} }
.lima-rpg:fullscreen .lima-exit {
top: 0.5rem;
}
@media (pointer: fine) and (min-width: 900px) {
.lima-touch {
opacity: 0.18;
}
.lima-touch:hover,
.lima-touch:focus-within {
opacity: 1;
}
}
@media (max-width: 820px) { @media (max-width: 820px) {
.lima-hud { .lima-hud {
right: 0.65rem; width: 220px;
grid-template-columns: 1fr 1fr;
} }
.lima-hud__objective { .lima-rpg__game {
grid-column: 1 / -1; left: 220px;
} }
.lima-hud__actions { .lima-status-stack {
position: fixed; left: calc(50% + 110px);
right: 0.6rem; width: min(38rem, calc(100vw - 238px));
top: 0.6rem;
border: 0 !important;
background: transparent !important;
}
.lima-hud__actions button:not([data-lima-pause]) {
display: none;
} }
.lima-main-menu, .lima-main-menu,
@@ -578,27 +673,23 @@ button:disabled {
top: 50%; top: 50%;
} }
.lima-status-stack { .lima-intro__controls span {
bottom: 7.4rem; display: none;
} }
} }
@media (max-height: 560px) and (orientation: landscape) { @media (max-height: 560px) and (orientation: landscape) {
.lima-hud { .lima-hud {
inset: 0.35rem 5rem auto 0.35rem; width: 210px;
grid-template-columns: auto auto 1fr auto;
} }
.lima-hud > div { .lima-rpg__game {
min-height: 48px; left: 210px;
padding: 0.3rem 0.5rem;
}
.lima-touch {
inset: auto 0.35rem 0.35rem;
} }
.lima-status-stack { .lima-status-stack {
left: calc(50% + 105px);
bottom: 0.35rem; bottom: 0.35rem;
} }

View File

@@ -791,3 +791,9 @@ at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/build-logs/gite
2026-07-30T10:23:03.5358268+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python. 2026-07-30T10:23:03.5358268+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-30T10:23:03.6148289+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0 2026-07-30T10:23:03.6148289+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-30T10:23:03.9852050+01:00 [INFO] Sent authoring server test notification. 2026-07-30T10:23:03.9852050+01:00 [INFO] Sent authoring server test notification.
2026-07-30T11:30:05.0219398+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-30T11:30:05.0295461+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-30T11:30:05.9431243+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-30T11:30:05.9654967+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-30T11:30:06.0418011+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-30T11:30:06.4084140+01:00 [INFO] Sent authoring server test notification.

View File

@@ -134,7 +134,7 @@ Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
"<meta name=\"description\" content=\"A standalone fantasy RPG about rescuing Princess Lima.\" />\n" "<meta name=\"description\" content=\"A standalone fantasy RPG about rescuing Princess Lima.\" />\n"
"<title>Rescue Princess Lima</title>\n" "<title>Rescue Princess Lima</title>\n"
"<link rel=\"icon\" href=\"/assets/icons/icons8-film-tape-100.png\" />\n" "<link rel=\"icon\" href=\"/assets/icons/icons8-film-tape-100.png\" />\n"
"<link rel=\"stylesheet\" href=\"/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.0.1\" />\n" "<link rel=\"stylesheet\" href=\"/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.1.0\" />\n"
"</head>\n<body class=\"princess-lima-page\">\n" "</head>\n<body class=\"princess-lima-page\">\n"
body body
"\n</body>\n</html>\n")) "\n</body>\n</html>\n"))

90
play/rpg.org Normal file → Executable file
View File

@@ -6,10 +6,8 @@
#+BEGIN_EXPORT html #+BEGIN_EXPORT html
<!-- RPG-STANDALONE-START --> <!-- RPG-STANDALONE-START -->
<main class="lima-rpg" data-princess-lima-rpg aria-label="Rescue Princess Lima role-playing game"> <main class="lima-rpg" data-princess-lima-rpg aria-label="Rescue Princess Lima role-playing game">
<a class="lima-exit" href="/">Exit to Website</a>
<div id="princess-lima-game" class="lima-rpg__game" data-lima-game tabindex="0" role="application" <div id="princess-lima-game" class="lima-rpg__game" data-lima-game tabindex="0" role="application"
aria-label="Rescue Princess Lima. Move with WASD or arrow keys. Attack with Space. Interact with E or Enter. Use a healing tonic with Q. Pause with Escape."> aria-label="Rescue Princess Lima. Keyboard game. Move with WASD or arrow keys. Attack with Space. Interact with E or Enter. Use a healing tonic with Q. Inventory I. Quests J. Menu Escape or M. Fullscreen F.">
</div> </div>
<section class="lima-rpg__loading" data-lima-loading aria-live="polite"> <section class="lima-rpg__loading" data-lima-loading aria-live="polite">
@@ -24,6 +22,8 @@
<div class="lima-menu-stack"> <div class="lima-menu-stack">
<button type="button" class="lima-primary" data-lima-new>New Game</button> <button type="button" class="lima-primary" data-lima-new>New Game</button>
<button type="button" data-lima-continue disabled>Continue</button> <button type="button" data-lima-continue disabled>Continue</button>
<button type="button" data-lima-replay-intro>Replay Introduction</button>
<button type="button" data-lima-menu-settings>Settings</button>
<button type="button" data-lima-credits>Credits</button> <button type="button" data-lima-credits>Credits</button>
<a class="lima-button-link" href="/">Exit to Website</a> <a class="lima-button-link" href="/">Exit to Website</a>
</div> </div>
@@ -46,22 +46,45 @@
</form> </form>
</section> </section>
<section class="lima-hud" data-lima-hud hidden aria-label="Game status"> <section class="lima-intro" data-lima-intro hidden tabindex="-1" aria-label="Opening cinematic">
<div><span>Traveller</span><strong data-lima-player>Traveller</strong><span data-lima-chapter>Chapter 1</span></div> <div class="lima-intro__picture" data-lima-intro-picture data-panel="0" role="img" aria-label="Princess Lima and the fall of the northern kingdom"></div>
<div><span>Health</span><strong data-lima-health>100 / 100</strong><div class="lima-hud__healthbar" aria-hidden="true"><i></i></div></div> <div class="lima-intro__vignette" aria-hidden="true"></div>
<div class="lima-hud__objective"><span data-lima-region>Broken Village</span><strong data-lima-objective>Speak with Elder Corin.</strong></div> <div class="lima-intro__subtitle" data-lima-intro-subtitle aria-live="polite"></div>
<div class="lima-hud__actions"> <div class="lima-intro__progress" role="progressbar" aria-label="Introduction progress" aria-valuemin="1" aria-valuemax="5" aria-valuenow="1"><i data-lima-intro-progress></i></div>
<button type="button" data-lima-quests aria-label="Quest log">Quests</button> <div class="lima-intro__controls">
<button type="button" data-lima-inventory aria-label="Inventory" data-lima-tonics>Tonic ×2</button> <button type="button" data-lima-intro-pause aria-pressed="false">Pause</button>
<button type="button" data-lima-sound>Sound Muted</button> <button type="button" data-lima-intro-mute aria-pressed="true">Enable sound</button>
<button type="button" data-lima-fullscreen aria-pressed="false">Fullscreen</button> <button type="button" data-lima-intro-skip>Skip introduction</button>
<button type="button" data-lima-pause>Menu</button> <span>Hold Escape to skip · P to pause</span>
</div> </div>
</section> </section>
<section class="lima-boss" data-lima-boss hidden aria-live="polite"> <section class="lima-hud" data-lima-hud hidden aria-label="Game status">
<strong data-lima-boss-name>Guardian</strong> <header class="lima-hud__identity">
<div class="lima-boss__bar" aria-hidden="true"><i></i></div> <div class="lima-hud__portrait lima-sprite" aria-hidden="true"></div>
<div><span>Traveller</span><strong data-lima-player>Traveller</strong><span data-lima-chapter>Chapter 1</span></div>
</header>
<div><span>Health</span><strong data-lima-health>100 / 100</strong><div class="lima-hud__healthbar" aria-hidden="true"><i></i></div></div>
<div class="lima-hud__equipment">
<span>Weapon</span><strong data-lima-weapon>Unarmed</strong>
<span>Defence</span><strong data-lima-armour>None</strong>
<span>Selected item · Q</span><strong data-lima-selected-item>Healing Tonic ×2</strong>
<span>Moon coins</span><strong data-lima-currency>0</strong>
</div>
<div class="lima-hud__objective"><span data-lima-region>Broken Village</span><strong data-lima-objective>Speak with Elder Corin.</strong></div>
<div class="lima-boss" data-lima-boss hidden aria-live="polite">
<strong data-lima-boss-name>Guardian</strong>
<div class="lima-boss__bar" aria-hidden="true"><i></i></div>
</div>
<div class="lima-hud__actions">
<button type="button" data-lima-quests aria-label="Quest log">Quests <kbd>J</kbd></button>
<button type="button" data-lima-inventory aria-label="Inventory" data-lima-tonics>Inventory <kbd>I</kbd></button>
<button type="button" data-lima-sound>Sound Muted</button>
<button type="button" data-lima-fullscreen aria-pressed="false">Fullscreen</button>
<button type="button" data-lima-pause>Menu <kbd>M</kbd></button>
<a class="lima-button-link" href="/">Exit to Website</a>
</div>
<p class="lima-hud__keys">Move WASD / arrows · Attack Space · Interact E / Enter · Item Q · Cycle Tab · Fullscreen F</p>
</section> </section>
<div class="lima-status-stack" data-lima-status-stack hidden> <div class="lima-status-stack" data-lima-status-stack hidden>
@@ -69,21 +92,6 @@
<p class="lima-status" data-lima-status aria-live="polite">Audio begins muted. Press Sound to enable it.</p> <p class="lima-status" data-lima-status aria-live="polite">Audio begins muted. Press Sound to enable it.</p>
</div> </div>
<div class="lima-touch" data-lima-touch hidden aria-label="Touch controls">
<div class="lima-dpad">
<button type="button" data-lima-move="up" aria-label="Move up"></button>
<button type="button" data-lima-move="left" aria-label="Move left"></button>
<button type="button" data-lima-move="down" aria-label="Move down"></button>
<button type="button" data-lima-move="right" aria-label="Move right"></button>
</div>
<div class="lima-actions">
<button type="button" data-lima-attack>Attack</button>
<button type="button" data-lima-interact>Interact</button>
<button type="button" data-lima-item>Item</button>
<button type="button" data-lima-pause>Menu</button>
</div>
</div>
<div class="lima-overlay" data-lima-overlay hidden> <div class="lima-overlay" data-lima-overlay hidden>
<section class="lima-panel" data-lima-panel role="dialog" aria-modal="true" aria-labelledby="lima-panel-title"> <section class="lima-panel" data-lima-panel role="dialog" aria-modal="true" aria-labelledby="lima-panel-title">
<header> <header>
@@ -99,14 +107,16 @@
</noscript> </noscript>
</main> </main>
<link rel="stylesheet" href="/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.0.1" /> <link rel="stylesheet" href="/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.1.0" />
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=princess-lima-1.0.1" defer></script> <script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=princess-lima-1.1.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-data.js?v=princess-lima-1.0.1" defer></script> <script src="/assets/scripts/pages/princess-lima-data.js?v=princess-lima-1.1.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-state.js?v=princess-lima-1.0.1" defer></script> <script src="/assets/scripts/pages/princess-lima-state.js?v=princess-lima-1.1.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-systems.js?v=princess-lima-1.0.1" defer></script> <script src="/assets/scripts/pages/princess-lima-systems.js?v=princess-lima-1.1.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-audio.js?v=princess-lima-1.0.1" defer></script> <script src="/assets/scripts/pages/princess-lima-audio.js?v=princess-lima-1.1.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-ui.js?v=princess-lima-1.0.1" defer></script> <script src="/assets/scripts/pages/princess-lima-ui.js?v=princess-lima-1.1.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-scenes.js?v=princess-lima-1.0.1" defer></script> <script src="/assets/scripts/pages/princess-lima-maps.js?v=princess-lima-1.1.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-game.js?v=princess-lima-1.0.1" defer></script> <script src="/assets/scripts/pages/princess-lima-intro.js?v=princess-lima-1.1.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-scenes.js?v=princess-lima-1.1.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-game.js?v=princess-lima-1.1.0" defer></script>
<!-- RPG-STANDALONE-END --> <!-- RPG-STANDALONE-END -->
#+END_EXPORT #+END_EXPORT

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@ See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts: * Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">30-07-2026 10:18</span>@@ - [[file:career/career-list.org][Career List]] @@html:<span class="post-date">30-07-2026 10:22</span>@@
- [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@ - [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@ - [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@ - [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@

View File

@@ -2,7 +2,7 @@
#+OPTIONS: toc:nil num:nil #+OPTIONS: toc:nil num:nil
* Recently Updated (top 26 files) * Recently Updated (top 26 files)
- [[file:play/rpg.org][Rescue Princess Lima]] @@html:<span class="post-date">2026-07-29 00:00</span>@@ - [[file:play/rpg.org][Rescue Princess Lima]] @@html:<span class="post-date">2026-07-30 00:00</span>@@
- [[file:play/house.org][The House of Pages]] @@html:<span class="post-date">2026-07-15 00:00</span>@@ - [[file:play/house.org][The House of Pages]] @@html:<span class="post-date">2026-07-15 00:00</span>@@
- [[file:blogs/2026/07-july/12-07-week-review.org][[12-07-2026] - Weekly Review]] @@html:<span class="post-date">2026-07-12 12:00</span>@@ - [[file:blogs/2026/07-july/12-07-week-review.org][[12-07-2026] - Weekly Review]] @@html:<span class="post-date">2026-07-12 12:00</span>@@
- [[file:home/wird-tracker.org][Wird Tracker]] @@html:<span class="post-date">2026-07-08 22:36</span>@@ - [[file:home/wird-tracker.org][Wird Tracker]] @@html:<span class="post-date">2026-07-08 22:36</span>@@

View File

@@ -118,80 +118,80 @@ flowchart TD
n43 --> n56 n43 --> n56
n57["Tag: maths"] n57["Tag: maths"]
n43 --> n57 n43 --> n57
n58{{"play"}} n58{{"posts"}}
root --> n58 root --> n58
n59["Sigil Press"] n59["Posts Introduction"]
n58 --> n59 n58 --> n59
n60["Ink Pond"] n60["Posts List"]
n58 --> n60 n58 --> n60
n61["Bookshelf Sort"] n61{{"career"}}
n58 --> n61 n58 --> n61
n62["Constellation Desk"] n62["SOLID Principles"]
n58 --> n62 n61 --> n62
n63["Memory Cabinet"] n63["OWASP Top Ten"]
n58 --> n63 n61 --> n63
n64["Play"] n64["Retrospectives"]
n58 --> n64 n61 --> n64
n65["Archive Terminal"] n65["Lean"]
n58 --> n65 n61 --> n65
n66["Study Lamp"] n66["Invest Principles"]
n58 --> n66 n61 --> n66
n67["Marginalia Machine"] n67["Career Introduction"]
n58 --> n67 n61 --> n67
n68["The Rain Index"] n68["Management of self training"]
n58 --> n68 n61 --> n68
n69["The House of Pages"] n69["Wireframe Designs"]
n58 --> n69 n61 --> n69
n70["Rescue Princess Lima"] n70["Requirements, features, user stories, tasks, walking skeletons"]
n58 --> n70 n61 --> n70
n71{{"posts"}} n71["Benefits of Normalisation"]
root --> n71 n61 --> n71
n72["Posts Introduction"] n72["Datamarts, Airflow and DAG's"]
n71 --> n72 n61 --> n72
n73["Posts List"] n73["Probation Objectives:"]
n71 --> n73 n61 --> n73
n74{{"career"}} n74["Database Permissions, Roles, and Accounts"]
n71 --> n74 n61 --> n74
n75["SOLID Principles"] n75["Monitoring and Logging"]
n74 --> n75 n61 --> n75
n76["OWASP Top Ten"] n76["Pipelines and how they work (as well as CI/CD)"]
n74 --> n76 n61 --> n76
n77["Retrospectives"] n77["Restful API"]
n74 --> n77 n61 --> n77
n78["Lean"] n78["Understands the Javascript language"]
n74 --> n78 n61 --> n78
n79["Invest Principles"] n79["High Availability, Disaster Recovery and Business Continuity"]
n74 --> n79 n61 --> n79
n80["Career Introduction"] n80["Cross-Site Scripting (XSS)"]
n74 --> n80 n61 --> n80
n81["Management of self training"] n81["Career List"]
n74 --> n81 n61 --> n81
n82["Wireframe Designs"] n82{{"play"}}
n74 --> n82 root --> n82
n83["Requirements, features, user stories, tasks, walking skeletons"] n83["Sigil Press"]
n74 --> n83 n82 --> n83
n84["Benefits of Normalisation"] n84["Ink Pond"]
n74 --> n84 n82 --> n84
n85["Datamarts, Airflow and DAG's"] n85["Bookshelf Sort"]
n74 --> n85 n82 --> n85
n86["Probation Objectives:"] n86["Constellation Desk"]
n74 --> n86 n82 --> n86
n87["Database Permissions, Roles, and Accounts"] n87["Memory Cabinet"]
n74 --> n87 n82 --> n87
n88["Monitoring and Logging"] n88["Play"]
n74 --> n88 n82 --> n88
n89["Pipelines and how they work (as well as CI/CD)"] n89["Archive Terminal"]
n74 --> n89 n82 --> n89
n90["Restful API"] n90["Study Lamp"]
n74 --> n90 n82 --> n90
n91["Understands the Javascript language"] n91["Marginalia Machine"]
n74 --> n91 n82 --> n91
n92["High Availability, Disaster Recovery and Business Continuity"] n92["The Rain Index"]
n74 --> n92 n82 --> n92
n93["Cross-Site Scripting (XSS)"] n93["The House of Pages"]
n74 --> n93 n82 --> n93
n94["Career List"] n94["Rescue Princess Lima"]
n74 --> n94 n82 --> n94
click n1 "index.html" "Home" click n1 "index.html" "Home"
click n2 "recently-updated.html" "Recently Updated" click n2 "recently-updated.html" "Recently Updated"
click n4 "blogs/blogs-intro.html" "Blogs Introduction" click n4 "blogs/blogs-intro.html" "Blogs Introduction"
@@ -231,40 +231,40 @@ flowchart TD
click n55 "tags/education.html" "Tag: education" click n55 "tags/education.html" "Tag: education"
click n56 "tags/reading.html" "Tag: reading" click n56 "tags/reading.html" "Tag: reading"
click n57 "tags/maths.html" "Tag: maths" click n57 "tags/maths.html" "Tag: maths"
click n59 "play/sigil.html" "Sigil Press" click n59 "posts/posts-intro.html" "Posts Introduction"
click n60 "play/ink.html" "Ink Pond" click n60 "posts/posts-list.html" "Posts List"
click n61 "play/bookshelf.html" "Bookshelf Sort" click n62 "posts/career/solid-principles.html" "SOLID Principles"
click n62 "play/constellation.html" "Constellation Desk" click n63 "posts/career/owasp.html" "OWASP Top Ten"
click n63 "play/memory.html" "Memory Cabinet" click n64 "posts/career/retrospectives.html" "Retrospectives"
click n64 "play/play.html" "Play" click n65 "posts/career/lean.html" "Lean"
click n65 "play/terminal.html" "Archive Terminal" click n66 "posts/career/invest-principles.html" "Invest Principles"
click n66 "play/study.html" "Study Lamp" click n67 "posts/career/career-intro.html" "Career Introduction"
click n67 "play/poem.html" "Marginalia Machine" click n68 "posts/career/management-of-self.html" "Management of self training"
click n68 "play/the-rain-index.html" "The Rain Index" click n69 "posts/career/wireframe-designs.html" "Wireframe Designs"
click n69 "play/house.html" "The House of Pages" click n70 "posts/career/requirements-features.html" "Requirements, features, user stories, tasks, walking skeletons"
click n70 "play/rpg.html" "Rescue Princess Lima" click n71 "posts/career/normalisation.html" "Benefits of Normalisation"
click n72 "posts/posts-intro.html" "Posts Introduction" click n72 "posts/career/airflow.html" "Datamarts, Airflow and DAG's"
click n73 "posts/posts-list.html" "Posts List" click n73 "posts/career/probation-objectives.html" "Probation Objectives:"
click n75 "posts/career/solid-principles.html" "SOLID Principles" click n74 "posts/career/database-permissions.html" "Database Permissions, Roles, and Accounts"
click n76 "posts/career/owasp.html" "OWASP Top Ten" click n75 "posts/career/monitoring-and-logging.html" "Monitoring and Logging"
click n77 "posts/career/retrospectives.html" "Retrospectives" click n76 "posts/career/pipelines.html" "Pipelines and how they work (as well as CI/CD)"
click n78 "posts/career/lean.html" "Lean" click n77 "posts/career/restful-api.html" "Restful API"
click n79 "posts/career/invest-principles.html" "Invest Principles" click n78 "posts/career/javascript.html" "Understands the Javascript language"
click n80 "posts/career/career-intro.html" "Career Introduction" click n79 "posts/career/ha-dr.html" "High Availability, Disaster Recovery and Business Continuity"
click n81 "posts/career/management-of-self.html" "Management of self training" click n80 "posts/career/cross-site-scripting-xss.html" "Cross-Site Scripting (XSS)"
click n82 "posts/career/wireframe-designs.html" "Wireframe Designs" click n81 "posts/career/career-list.html" "Career List"
click n83 "posts/career/requirements-features.html" "Requirements, features, user stories, tasks, walking skeletons" click n83 "play/sigil.html" "Sigil Press"
click n84 "posts/career/normalisation.html" "Benefits of Normalisation" click n84 "play/ink.html" "Ink Pond"
click n85 "posts/career/airflow.html" "Datamarts, Airflow and DAG's" click n85 "play/bookshelf.html" "Bookshelf Sort"
click n86 "posts/career/probation-objectives.html" "Probation Objectives:" click n86 "play/constellation.html" "Constellation Desk"
click n87 "posts/career/database-permissions.html" "Database Permissions, Roles, and Accounts" click n87 "play/memory.html" "Memory Cabinet"
click n88 "posts/career/monitoring-and-logging.html" "Monitoring and Logging" click n88 "play/play.html" "Play"
click n89 "posts/career/pipelines.html" "Pipelines and how they work (as well as CI/CD)" click n89 "play/terminal.html" "Archive Terminal"
click n90 "posts/career/restful-api.html" "Restful API" click n90 "play/study.html" "Study Lamp"
click n91 "posts/career/javascript.html" "Understands the Javascript language" click n91 "play/poem.html" "Marginalia Machine"
click n92 "posts/career/ha-dr.html" "High Availability, Disaster Recovery and Business Continuity" click n92 "play/the-rain-index.html" "The Rain Index"
click n93 "posts/career/cross-site-scripting-xss.html" "Cross-Site Scripting (XSS)" click n93 "play/house.html" "The House of Pages"
click n94 "posts/career/career-list.html" "Career List" click n94 "play/rpg.html" "Rescue Princess Lima"
#+end_src #+end_src
* Pages * Pages
@@ -325,19 +325,6 @@ flowchart TD
- [[file:tags/education.org][Tag: education]] - [[file:tags/education.org][Tag: education]]
- [[file:tags/reading.org][Tag: reading]] - [[file:tags/reading.org][Tag: reading]]
- [[file:tags/maths.org][Tag: maths]] - [[file:tags/maths.org][Tag: maths]]
- play
- [[file:play/sigil.org][Sigil Press]]
- [[file:play/ink.org][Ink Pond]]
- [[file:play/bookshelf.org][Bookshelf Sort]]
- [[file:play/constellation.org][Constellation Desk]]
- [[file:play/memory.org][Memory Cabinet]]
- [[file:play/play.org][Play]]
- [[file:play/terminal.org][Archive Terminal]]
- [[file:play/study.org][Study Lamp]]
- [[file:play/poem.org][Marginalia Machine]]
- [[file:play/the-rain-index.org][The Rain Index]]
- [[file:play/house.org][The House of Pages]]
- [[file:play/rpg.org][Rescue Princess Lima]]
- posts - posts
- [[file:posts/posts-intro.org][Posts Introduction]] - [[file:posts/posts-intro.org][Posts Introduction]]
- [[file:posts/posts-list.org][Posts List]] - [[file:posts/posts-list.org][Posts List]]
@@ -362,3 +349,16 @@ flowchart TD
- [[file:posts/career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] - [[file:posts/career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]]
- [[file:posts/career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] - [[file:posts/career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]]
- [[file:posts/career/career-list.org][Career List]] - [[file:posts/career/career-list.org][Career List]]
- play
- [[file:play/sigil.org][Sigil Press]]
- [[file:play/ink.org][Ink Pond]]
- [[file:play/bookshelf.org][Bookshelf Sort]]
- [[file:play/constellation.org][Constellation Desk]]
- [[file:play/memory.org][Memory Cabinet]]
- [[file:play/play.org][Play]]
- [[file:play/terminal.org][Archive Terminal]]
- [[file:play/study.org][Study Lamp]]
- [[file:play/poem.org][Marginalia Machine]]
- [[file:play/the-rain-index.org][The Rain Index]]
- [[file:play/house.org][The House of Pages]]
- [[file:play/rpg.org][Rescue Princess Lima]]

View File

@@ -0,0 +1,40 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const data = require("../assets/scripts/pages/princess-lima-data.js");
const intro = require("../assets/scripts/pages/princess-lima-intro.js");
const root = path.resolve(__dirname, "..");
const rpgSource = fs.readFileSync(path.join(root, "play/rpg.org"), "utf8");
test("all nine regions have authored art data and valid collision-safe transitions", () => {
const mapsSource = fs.readFileSync(path.join(root, "assets/scripts/pages/princess-lima-maps.js"), "utf8");
data.MAP_IDS.forEach((region) => {
assert.match(mapsSource, new RegExp(`${region}:\\s*\\{`));
Object.values(data.MAPS[region].spawns).forEach((spawn) => {
assert.ok(spawn.x > 0 && spawn.x < data.WIDTH);
assert.ok(spawn.y > 0 && spawn.y < data.HEIGHT);
});
data.MAPS[region].exits.forEach((exit) => {
assert.ok(data.MAPS[exit.target]);
assert.ok(data.MAPS[exit.target].spawns[exit.spawn]);
});
});
});
test("keyboard-only page contains one left HUD and no touch controls", () => {
assert.match(rpgSource, /class="lima-hud"/);
assert.match(rpgSource, /Inventory <kbd>I<\/kbd>/);
assert.match(rpgSource, /Menu <kbd>M<\/kbd>/);
assert.doesNotMatch(rpgSource, /data-lima-touch|data-lima-move|Touch controls|data-lima-attack/);
});
test("opening cinematic is complete, replayable and accessible", () => {
assert.equal(intro.BEATS.length, 5);
assert.ok(intro.BEATS.reduce((total, beat) => total + beat.duration, 0) >= 60000);
assert.ok(intro.BEATS.every((beat) => beat.subtitle.length > 20));
assert.match(rpgSource, /data-lima-replay-intro/);
assert.match(rpgSource, /data-lima-intro-pause/);
assert.match(rpgSource, /Hold Escape to skip/);
});

16
tests/princess-lima-state.test.cjs Normal file → Executable file
View File

@@ -12,6 +12,22 @@ test("fresh save uses the Princess Lima schema and safe village start", () => {
assert.equal(fresh.region, "village"); assert.equal(fresh.region, "village");
assert.deepEqual({ x: fresh.position.x, y: fresh.position.y }, data.MAPS.village.spawns.start); assert.deepEqual({ x: fresh.position.x, y: fresh.position.y }, data.MAPS.village.spawns.start);
assert.equal(fresh.settings.soundEnabled, false); assert.equal(fresh.settings.soundEnabled, false);
assert.equal(fresh.introSeen, false);
assert.equal(fresh.settings.subtitles, true);
assert.equal(fresh.settings.narrationEnabled, true);
});
test("old v1 saves continue without unexpectedly replaying the new introduction", () => {
const candidate = state.fresh("Legacy", "azure");
delete candidate.introSeen;
delete candidate.settings.voice;
delete candidate.settings.subtitles;
delete candidate.settings.narrationEnabled;
const restored = state.normalize(candidate);
assert.equal(restored.introSeen, true);
assert.equal(restored.settings.voice, 0.85);
assert.equal(restored.settings.subtitles, true);
assert.equal(restored.settings.narrationEnabled, true);
}); });
test("names and appearances validate", () => { test("names and appearances validate", () => {

24
tests/princess-lima-systems.test.cjs Normal file → Executable file
View File

@@ -16,6 +16,30 @@ test("movement is responsive and diagonal speed is normalized", () => {
assert.deepEqual(velocity, { x: 0, y: 0 }); assert.deepEqual(velocity, { x: 0, y: 0 });
}); });
test("directional attacks separate wind-up, active hit and recovery timing", () => {
assert.equal(systems.attackPhase(0), "windup");
assert.equal(systems.attackPhase(systems.ATTACK_TIMING.windup), "active");
assert.equal(systems.attackPhase(systems.ATTACK_TIMING.windup + systems.ATTACK_TIMING.active), "recovery");
assert.equal(systems.attackPhase(999), "complete");
const origin = { x: 500, y: 400 };
const north = systems.attackHitbox("north", origin.x, origin.y);
const south = systems.attackHitbox("south", origin.x, origin.y);
const east = systems.attackHitbox("east", origin.x, origin.y);
const west = systems.attackHitbox("west", origin.x, origin.y);
assert.ok(north.y + north.height <= origin.y);
assert.ok(south.y >= origin.y);
assert.ok(east.x >= origin.x);
assert.ok(west.x + west.width <= origin.x);
});
test("enemy knockback stops before walls and map boundaries", () => {
const current = state.fresh("Ada", "azure");
const safe = systems.wallSafeKnockback(current, "village", 430, 400, 1, 0, 60);
assert.equal(systems.isSafePosition(current, "village", safe.x, safe.y), true);
const edge = systems.wallSafeKnockback(current, "village", 50, 400, -1, 0, 90);
assert.equal(systems.isSafePosition(current, "village", edge.x, edge.y), true);
});
test("all named spawns are collision-safe and invalid positions recover", () => { test("all named spawns are collision-safe and invalid positions recover", () => {
const current = state.fresh("Ada", "azure"); const current = state.fresh("Ada", "azure");
Object.entries(data.MAPS).forEach(([regionId, map]) => { Object.entries(data.MAPS).forEach(([regionId, map]) => {

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Generate deterministic Princess Lima tiles and local narration assets."""
from __future__ import annotations
import ctypes
import ctypes.util
import math
import random
import wave
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
IMAGE_DIR = ROOT / "assets/images/play/princess-lima"
AUDIO_DIR = ROOT / "assets/audio/princess-lima"
TILE = 32
THEMES = [
("village", "#51713f", "#41556b", "#b79861", "#6d4333"),
("forest", "#173f31", "#2f6845", "#789b52", "#234f62"),
("ruins", "#263f47", "#486468", "#8a8872", "#1b7280"),
("mountain", "#6c8094", "#b9c9d6", "#e8f0f2", "#516476"),
("camp", "#53442e", "#76603a", "#aa8051", "#683f2b"),
("fortressExterior", "#221c2c", "#40344b", "#64566f", "#2b182e"),
("fortressInterior", "#201d28", "#393342", "#74636b", "#7e312a"),
("bossArena", "#17121f", "#2d2138", "#674371", "#8e365f"),
("chamber", "#73869a", "#c9b985", "#e9dfc2", "#31558b"),
]
NARRATION = [
"Before shadow crossed the northern road, Princess Lima walked among her people, listening before she ruled and helping before she asked.",
"Then Lord Malrec descended from the Fortress of Shadows. His riders carried fear through the valleys, searching for the royal oath.",
"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.",
"At dawn, a lone traveller reached the broken village. The road was dangerous, but every rescued life would become another light leading to Lima.",
]
def shade(hex_color: str, factor: float) -> tuple[int, int, int, int]:
value = hex_color.lstrip("#")
channels = [int(value[index:index + 2], 16) for index in (0, 2, 4)]
return tuple(max(0, min(255, round(channel * factor))) for channel in channels) + (255,)
def draw_tile(draw: ImageDraw.ImageDraw, x: int, y: int, colors: tuple[str, str, str, str], kind: int, seed: int) -> None:
ground, detail, path, hazard = colors
randomizer = random.Random(seed)
base = [ground, detail, path, hazard][kind]
draw.rectangle((x, y, x + TILE - 1, y + TILE - 1), fill=base)
dark = shade(base, 0.72)
light = shade(base, 1.22)
if kind == 0:
for _ in range(13):
px = x + randomizer.randrange(2, TILE - 2)
py = y + randomizer.randrange(2, TILE - 2)
draw.point((px, py), fill=light if randomizer.random() > 0.45 else dark)
draw.line((x, y + TILE - 2, x + TILE, y + TILE - 2), fill=dark, width=2)
elif kind == 1:
for row in range(0, TILE, 8):
offset = 4 if row % 16 else 0
draw.line((x, y + row, x + TILE, y + row), fill=dark)
for col in range(-offset, TILE, 12):
draw.line((x + col, y + row, x + col + 5, y + row + 4), fill=light, width=2)
elif kind == 2:
draw.line((x + 3, y + 2, x + 3, y + TILE - 3), fill=dark, width=2)
draw.line((x + TILE - 4, y + 2, x + TILE - 4, y + TILE - 3), fill=light, width=2)
for row in range(5, TILE, 8):
draw.line((x + 6, y + row, x + TILE - 7, y + row + randomizer.choice((-1, 0, 1))), fill=dark)
else:
for row in range(4, TILE, 7):
draw.arc((x - 5, y + row - 4, x + 16, y + row + 4), 195, 345, fill=light, width=2)
draw.arc((x + 13, y + row - 4, x + 34, y + row + 4), 195, 345, fill=dark, width=2)
def make_tiles() -> None:
image = Image.new("RGBA", (TILE * 4, TILE * len(THEMES)), "#000000")
draw = ImageDraw.Draw(image)
for row, (_, *colors) in enumerate(THEMES):
for kind in range(4):
draw_tile(draw, kind * TILE, row * TILE, tuple(colors), kind, row * 101 + kind)
image.save(IMAGE_DIR / "world-tiles.png", optimize=True)
def synthesize(text: str, destination: Path) -> bool:
library = ctypes.util.find_library("espeak-ng")
if not library:
return False
espeak = ctypes.CDLL(library)
samples: list[int] = []
callback_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(ctypes.c_short), ctypes.c_int, ctypes.c_void_p)
@callback_type
def callback(wav, count, _events):
if wav and count > 0:
samples.extend(wav[index] for index in range(count))
return 0
espeak.espeak_Initialize.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_char_p, ctypes.c_int]
espeak.espeak_Initialize.restype = ctypes.c_int
espeak.espeak_SetSynthCallback.argtypes = [callback_type]
espeak.espeak_SetVoiceByName.argtypes = [ctypes.c_char_p]
espeak.espeak_Synth.argtypes = [
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint, ctypes.c_int,
ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint), ctypes.c_void_p,
]
rate = espeak.espeak_Initialize(1, 0, None, 0)
if rate <= 0:
return False
espeak.espeak_SetSynthCallback(callback)
espeak.espeak_SetVoiceByName(b"en-gb")
encoded = text.encode("utf-8") + b"\0"
identifier = ctypes.c_uint(0)
espeak.espeak_Synth(encoded, len(encoded), 0, 0, 0, 1, ctypes.byref(identifier), None)
espeak.espeak_Synchronize()
if not samples:
return False
peak = max(1, max(abs(sample) for sample in samples))
scaled = [round(sample * min(1.0, 24000 / peak)) for sample in samples]
with wave.open(str(destination), "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(rate)
buffer = (ctypes.c_short * len(scaled))(*scaled)
output.writeframes(bytes(buffer))
espeak.espeak_Terminate()
return True
def make_narration() -> None:
for index, line in enumerate(NARRATION, 1):
if not synthesize(line, AUDIO_DIR / f"intro-narration-{index}.wav"):
raise RuntimeError("Local espeak-ng narration synthesis was unavailable")
if __name__ == "__main__":
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
make_tiles()
make_narration()