Refactor platform UI and data flow across core pages
All checks were successful
Build Org Website / build (push) Successful in 50s
0
assets/audio/princess-lima/attack.wav
Normal file → Executable file
0
assets/audio/princess-lima/boss-theme.wav
Normal file → Executable file
0
assets/audio/princess-lima/damage.wav
Normal file → Executable file
0
assets/audio/princess-lima/defeat.wav
Normal file → Executable file
0
assets/audio/princess-lima/door.wav
Normal file → Executable file
0
assets/audio/princess-lima/forest-theme.wav
Normal file → Executable file
0
assets/audio/princess-lima/fortress-theme.wav
Normal file → Executable file
BIN
assets/audio/princess-lima/hit.wav
Normal file
BIN
assets/audio/princess-lima/intro-narration-1.wav
Normal file
BIN
assets/audio/princess-lima/intro-narration-2.wav
Normal file
BIN
assets/audio/princess-lima/intro-narration-3.wav
Normal file
BIN
assets/audio/princess-lima/intro-narration-4.wav
Normal file
0
assets/audio/princess-lima/mountain-theme.wav
Normal file → Executable file
0
assets/audio/princess-lima/pickup.wav
Normal file → Executable file
0
assets/audio/princess-lima/puzzle.wav
Normal file → Executable file
0
assets/audio/princess-lima/quest.wav
Normal file → Executable file
0
assets/audio/princess-lima/step.wav
Normal file → Executable file
0
assets/audio/princess-lima/victory-theme.wav
Normal file → Executable file
0
assets/audio/princess-lima/victory.wav
Normal file → Executable file
0
assets/audio/princess-lima/village-theme.wav
Normal file → Executable file
0
assets/images/play/princess-lima/cast-atlas.png
Normal file → Executable file
|
Before Width: | Height: | Size: 975 KiB After Width: | Height: | Size: 975 KiB |
BIN
assets/images/play/princess-lima/intro-storyboard.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
assets/images/play/princess-lima/regional-style-atlas.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
0
assets/images/play/princess-lima/title-landscape.png
Normal file → Executable file
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
BIN
assets/images/play/princess-lima/world-tiles.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
27
assets/scripts/pages/princess-lima-audio.js
Normal file → Executable 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
86
assets/scripts/pages/princess-lima-game.js
Normal file → Executable 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
|
||||
});
|
||||
}());
|
||||
|
||||
154
assets/scripts/pages/princess-lima-intro.js
Normal 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));
|
||||
156
assets/scripts/pages/princess-lima-maps.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>@@
|
||||
|
||||
@@ -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>@@
|
||||
|
||||
234
sitemap.org
@@ -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]]
|
||||
40
tests/princess-lima-polish.test.cjs
Normal 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
@@ -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
@@ -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]) => {
|
||||
|
||||
141
tools/generate-princess-lima-assets.py
Normal 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()
|
||||