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

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

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

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

View File

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

View File

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

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

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

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

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

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

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

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

@@ -98,6 +98,9 @@
<label class="lima-setting"><span>Master volume</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.master}" data-setting="master"></label>
<label class="lima-setting"><span>Music</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.music}" data-setting="music"></label>
<label class="lima-setting"><span>Effects</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.effects}" data-setting="effects"></label>
<label class="lima-setting"><span>Narration</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.voice}" data-setting="voice"></label>
<label class="lima-check"><input type="checkbox" data-setting="narrationEnabled" ${state.settings.narrationEnabled ? "checked" : ""}> Spoken narration</label>
<label class="lima-check"><input type="checkbox" data-setting="subtitles" ${state.settings.subtitles ? "checked" : ""}> Cinematic subtitles</label>
<label class="lima-check"><input type="checkbox" data-setting="reducedMotion" ${state.settings.reducedMotion ? "checked" : ""}> Reduced motion</label>
<label class="lima-check"><input type="checkbox" data-setting="screenShake" ${state.settings.screenShake ? "checked" : ""}> Screen shake</label>
<label class="lima-check"><input type="checkbox" data-setting="highContrast" ${state.settings.highContrast ? "checked" : ""}> High-contrast interface</label>
@@ -114,12 +117,14 @@
<button type="button" data-panel="inventory">Inventory</button>
<button type="button" data-panel="quests">Quest Log</button>
<button type="button" data-panel="settings">Settings & accessibility</button>
<button type="button" data-lima-replay-intro-panel>Replay introduction</button>
<button type="button" data-lima-fullscreen-panel>Toggle fullscreen</button>
<button type="button" data-lima-reset-request>Reset save</button>
<a href="/">Exit to Website</a>
</div>
`);
body.querySelectorAll("[data-panel]").forEach((button) => button.addEventListener("click", () => openPanel(button.dataset.panel, actions.getState())));
body.querySelector("[data-lima-replay-intro-panel]").addEventListener("click", actions.onReplayIntro);
body.querySelector("[data-lima-fullscreen-panel]").addEventListener("click", actions.onFullscreen);
body.querySelector("[data-lima-reset-request]").addEventListener("click", confirmReset);
}
@@ -164,6 +169,15 @@
event.preventDefault();
advanceDialogue();
}
if (!overlay.hidden && !dialogue && ["ArrowDown", "ArrowUp"].includes(event.key)) {
const controls = Array.from(panel.querySelectorAll("button:not([hidden]), a[href], input, select"))
.filter((control) => !control.disabled && control.offsetParent !== null);
if (!controls.length) return;
event.preventDefault();
const current = controls.indexOf(document.activeElement);
const change = event.key === "ArrowDown" ? 1 : -1;
controls[(current + change + controls.length) % controls.length].focus();
}
});
return Object.freeze({ show, close, openDialogue, openPanel, gameOver, ending, confirmReset });

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

@@ -93,16 +93,17 @@ button:disabled {
.lima-rpg__game {
position: absolute;
inset: 0;
inset: 0 0 0 272px;
display: grid;
place-items: center;
overflow: hidden;
background: #080a12;
}
.lima-rpg__game canvas {
display: block;
max-width: 100vw;
max-height: 100dvh;
max-width: none;
max-height: none;
image-rendering: pixelated;
}
@@ -177,33 +178,27 @@ button:disabled {
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 {
position: absolute;
z-index: 30;
inset: 0.75rem 10rem auto 0.75rem;
display: grid;
grid-template-columns: auto minmax(12rem, 22rem) minmax(10rem, 1fr) auto;
gap: 0.55rem;
pointer-events: none;
inset: 0 auto 0 0;
display: flex;
width: 272px;
flex-direction: column;
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 {
min-height: 58px;
border: 2px solid rgb(214 174 99 / 0.72);
border-radius: 5px;
background: rgb(8 12 22 / 0.93);
padding: 0.45rem 0.65rem;
border-bottom: 1px solid rgb(214 174 99 / 0.38);
background: rgb(8 12 22 / 0.48);
padding: 0.72rem 0.85rem;
}
.lima-hud span {
@@ -222,6 +217,43 @@ button:disabled {
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-boss__bar {
height: 8px;
@@ -240,22 +272,47 @@ button:disabled {
}
.lima-hud__actions {
display: flex;
gap: 0.4rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.42rem;
pointer-events: auto;
}
.lima-hud__actions button {
min-width: 48px;
min-height: 48px;
padding: 0.4rem;
font-size: 0.78rem;
min-width: 0;
min-height: 42px;
padding: 0.35rem;
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 {
position: absolute;
z-index: 25;
left: 50%;
left: calc(50% + 136px);
bottom: 1rem;
width: min(44rem, calc(100vw - 2rem));
transform: translateX(-50%);
@@ -281,12 +338,8 @@ button:disabled {
}
.lima-boss {
position: absolute;
z-index: 35;
top: 5.2rem;
left: 50%;
width: min(34rem, calc(100vw - 2rem));
transform: translateX(-50%);
position: static;
width: auto;
border: 2px solid #bd6d8b;
background: rgb(18 8 25 / 0.95);
padding: 0.55rem 0.8rem;
@@ -297,42 +350,6 @@ button:disabled {
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-setup {
position: absolute;
@@ -520,6 +537,107 @@ button:disabled {
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-panel: #000;
--lima-panel-strong: #000;
@@ -534,41 +652,18 @@ button:disabled {
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) {
.lima-hud {
right: 0.65rem;
grid-template-columns: 1fr 1fr;
width: 220px;
}
.lima-hud__objective {
grid-column: 1 / -1;
.lima-rpg__game {
left: 220px;
}
.lima-hud__actions {
position: fixed;
right: 0.6rem;
top: 0.6rem;
border: 0 !important;
background: transparent !important;
}
.lima-hud__actions button:not([data-lima-pause]) {
display: none;
.lima-status-stack {
left: calc(50% + 110px);
width: min(38rem, calc(100vw - 238px));
}
.lima-main-menu,
@@ -578,27 +673,23 @@ button:disabled {
top: 50%;
}
.lima-status-stack {
bottom: 7.4rem;
.lima-intro__controls span {
display: none;
}
}
@media (max-height: 560px) and (orientation: landscape) {
.lima-hud {
inset: 0.35rem 5rem auto 0.35rem;
grid-template-columns: auto auto 1fr auto;
width: 210px;
}
.lima-hud > div {
min-height: 48px;
padding: 0.3rem 0.5rem;
}
.lima-touch {
inset: auto 0.35rem 0.35rem;
.lima-rpg__game {
left: 210px;
}
.lima-status-stack {
left: calc(50% + 105px);
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.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-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"
"<title>Rescue Princess Lima</title>\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"
body
"\n</body>\n</html>\n"))

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

@@ -6,10 +6,8 @@
#+BEGIN_EXPORT html
<!-- RPG-STANDALONE-START -->
<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"
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>
<section class="lima-rpg__loading" data-lima-loading aria-live="polite">
@@ -24,6 +22,8 @@
<div class="lima-menu-stack">
<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-replay-intro>Replay Introduction</button>
<button type="button" data-lima-menu-settings>Settings</button>
<button type="button" data-lima-credits>Credits</button>
<a class="lima-button-link" href="/">Exit to Website</a>
</div>
@@ -46,22 +46,45 @@
</form>
</section>
<section class="lima-hud" data-lima-hud hidden aria-label="Game status">
<div><span>Traveller</span><strong data-lima-player>Traveller</strong><span data-lima-chapter>Chapter 1</span></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-hud__objective"><span data-lima-region>Broken Village</span><strong data-lima-objective>Speak with Elder Corin.</strong></div>
<div class="lima-hud__actions">
<button type="button" data-lima-quests aria-label="Quest log">Quests</button>
<button type="button" data-lima-inventory aria-label="Inventory" data-lima-tonics>Tonic ×2</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</button>
<section class="lima-intro" data-lima-intro hidden tabindex="-1" aria-label="Opening cinematic">
<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 class="lima-intro__vignette" aria-hidden="true"></div>
<div class="lima-intro__subtitle" data-lima-intro-subtitle aria-live="polite"></div>
<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>
<div class="lima-intro__controls">
<button type="button" data-lima-intro-pause aria-pressed="false">Pause</button>
<button type="button" data-lima-intro-mute aria-pressed="true">Enable sound</button>
<button type="button" data-lima-intro-skip>Skip introduction</button>
<span>Hold Escape to skip · P to pause</span>
</div>
</section>
<section 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>
<section class="lima-hud" data-lima-hud hidden aria-label="Game status">
<header class="lima-hud__identity">
<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>
<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>
</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>
<section class="lima-panel" data-lima-panel role="dialog" aria-modal="true" aria-labelledby="lima-panel-title">
<header>
@@ -99,14 +107,16 @@
</noscript>
</main>
<link rel="stylesheet" href="/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.0.1" />
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=princess-lima-1.0.1" 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-state.js?v=princess-lima-1.0.1" 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-audio.js?v=princess-lima-1.0.1" 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-scenes.js?v=princess-lima-1.0.1" defer></script>
<script src="/assets/scripts/pages/princess-lima-game.js?v=princess-lima-1.0.1" defer></script>
<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.1.0" 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.1.0" 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.1.0" 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-maps.js?v=princess-lima-1.1.0" 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 -->
#+END_EXPORT

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* 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/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>@@

View File

@@ -2,7 +2,7 @@
#+OPTIONS: toc:nil num:nil
* 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: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>@@

View File

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

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 });
});
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", () => {
const current = state.fresh("Ada", "azure");
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()