diff --git a/assets/audio/princess-lima/attack.wav b/assets/audio/princess-lima/attack.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/boss-theme.wav b/assets/audio/princess-lima/boss-theme.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/damage.wav b/assets/audio/princess-lima/damage.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/defeat.wav b/assets/audio/princess-lima/defeat.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/door.wav b/assets/audio/princess-lima/door.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/forest-theme.wav b/assets/audio/princess-lima/forest-theme.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/fortress-theme.wav b/assets/audio/princess-lima/fortress-theme.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/hit.wav b/assets/audio/princess-lima/hit.wav new file mode 100644 index 0000000..61ecf6d Binary files /dev/null and b/assets/audio/princess-lima/hit.wav differ diff --git a/assets/audio/princess-lima/intro-narration-1.wav b/assets/audio/princess-lima/intro-narration-1.wav new file mode 100644 index 0000000..9ec89b8 Binary files /dev/null and b/assets/audio/princess-lima/intro-narration-1.wav differ diff --git a/assets/audio/princess-lima/intro-narration-2.wav b/assets/audio/princess-lima/intro-narration-2.wav new file mode 100644 index 0000000..b0dd25c Binary files /dev/null and b/assets/audio/princess-lima/intro-narration-2.wav differ diff --git a/assets/audio/princess-lima/intro-narration-3.wav b/assets/audio/princess-lima/intro-narration-3.wav new file mode 100644 index 0000000..87ab778 Binary files /dev/null and b/assets/audio/princess-lima/intro-narration-3.wav differ diff --git a/assets/audio/princess-lima/intro-narration-4.wav b/assets/audio/princess-lima/intro-narration-4.wav new file mode 100644 index 0000000..8a12242 Binary files /dev/null and b/assets/audio/princess-lima/intro-narration-4.wav differ diff --git a/assets/audio/princess-lima/mountain-theme.wav b/assets/audio/princess-lima/mountain-theme.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/pickup.wav b/assets/audio/princess-lima/pickup.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/puzzle.wav b/assets/audio/princess-lima/puzzle.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/quest.wav b/assets/audio/princess-lima/quest.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/step.wav b/assets/audio/princess-lima/step.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/victory-theme.wav b/assets/audio/princess-lima/victory-theme.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/victory.wav b/assets/audio/princess-lima/victory.wav old mode 100644 new mode 100755 diff --git a/assets/audio/princess-lima/village-theme.wav b/assets/audio/princess-lima/village-theme.wav old mode 100644 new mode 100755 diff --git a/assets/images/play/princess-lima/cast-atlas.png b/assets/images/play/princess-lima/cast-atlas.png old mode 100644 new mode 100755 diff --git a/assets/images/play/princess-lima/intro-storyboard.png b/assets/images/play/princess-lima/intro-storyboard.png new file mode 100644 index 0000000..6ce4f40 Binary files /dev/null and b/assets/images/play/princess-lima/intro-storyboard.png differ diff --git a/assets/images/play/princess-lima/regional-style-atlas.png b/assets/images/play/princess-lima/regional-style-atlas.png new file mode 100644 index 0000000..1529b36 Binary files /dev/null and b/assets/images/play/princess-lima/regional-style-atlas.png differ diff --git a/assets/images/play/princess-lima/title-landscape.png b/assets/images/play/princess-lima/title-landscape.png old mode 100644 new mode 100755 diff --git a/assets/images/play/princess-lima/world-tiles.png b/assets/images/play/princess-lima/world-tiles.png new file mode 100644 index 0000000..46a0b37 Binary files /dev/null and b/assets/images/play/princess-lima/world-tiles.png differ diff --git a/assets/scripts/pages/princess-lima-audio.js b/assets/scripts/pages/princess-lima-audio.js old mode 100644 new mode 100755 index ed6a74a..25e0078 --- a/assets/scripts/pages/princess-lima-audio.js +++ b/assets/scripts/pages/princess-lima-audio.js @@ -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 }); diff --git a/assets/scripts/pages/princess-lima-data.js b/assets/scripts/pages/princess-lima-data.js old mode 100644 new mode 100755 diff --git a/assets/scripts/pages/princess-lima-game.js b/assets/scripts/pages/princess-lima-game.js old mode 100644 new mode 100755 index 4c2337f..0b6f317 --- a/assets/scripts/pages/princess-lima-game.js +++ b/assets/scripts/pages/princess-lima-game.js @@ -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", "

Create a traveller to save audio and accessibility preferences. The introduction always includes subtitles and can be muted or skipped.

"); }); 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 }); }()); diff --git a/assets/scripts/pages/princess-lima-intro.js b/assets/scripts/pages/princess-lima-intro.js new file mode 100644 index 0000000..b501b35 --- /dev/null +++ b/assets/scripts/pages/princess-lima-intro.js @@ -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)); diff --git a/assets/scripts/pages/princess-lima-maps.js b/assets/scripts/pages/princess-lima-maps.js new file mode 100644 index 0000000..fc997c6 --- /dev/null +++ b/assets/scripts/pages/princess-lima-maps.js @@ -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)); diff --git a/assets/scripts/pages/princess-lima-scenes.js b/assets/scripts/pages/princess-lima-scenes.js old mode 100644 new mode 100755 index 4e8d50c..7a0ac00 --- a/assets/scripts/pages/princess-lima-scenes.js +++ b/assets/scripts/pages/princess-lima-scenes.js @@ -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")); diff --git a/assets/scripts/pages/princess-lima-state.js b/assets/scripts/pages/princess-lima-state.js old mode 100644 new mode 100755 index c5a6113..50fd43c --- a/assets/scripts/pages/princess-lima-state.js +++ b/assets/scripts/pages/princess-lima-state.js @@ -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, diff --git a/assets/scripts/pages/princess-lima-systems.js b/assets/scripts/pages/princess-lima-systems.js old mode 100644 new mode 100755 index 4d9685b..63c0305 --- a/assets/scripts/pages/princess-lima-systems.js +++ b/assets/scripts/pages/princess-lima-systems.js @@ -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 }); })); diff --git a/assets/scripts/pages/princess-lima-ui.js b/assets/scripts/pages/princess-lima-ui.js old mode 100644 new mode 100755 index ef2abfb..56394ad --- a/assets/scripts/pages/princess-lima-ui.js +++ b/assets/scripts/pages/princess-lima-ui.js @@ -98,6 +98,9 @@ + + + @@ -114,12 +117,14 @@ + Exit to Website `); 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 }); diff --git a/assets/styles/pages/princess-lima-rpg.css b/assets/styles/pages/princess-lima-rpg.css old mode 100644 new mode 100755 index 3e15db1..086f111 --- a/assets/styles/pages/princess-lima-rpg.css +++ b/assets/styles/pages/princess-lima-rpg.css @@ -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; } diff --git a/build-logs/gitea-build-monitor.log b/build-logs/gitea-build-monitor.log index 89e6894..b3d163d 100755 --- a/build-logs/gitea-build-monitor.log +++ b/build-logs/gitea-build-monitor.log @@ -791,3 +791,9 @@ at , /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. diff --git a/lisp/build-html.el b/lisp/build-html.el index 842fffd..c2dd838 100755 --- a/lisp/build-html.el +++ b/lisp/build-html.el @@ -134,7 +134,7 @@ Created with %c on Arch \ "\n" "Rescue Princess Lima\n" "\n" - "\n" + "\n" "\n\n" body "\n\n\n")) diff --git a/play/rpg.org b/play/rpg.org old mode 100644 new mode 100755 index 2fc91fc..2a5e7e3 --- a/play/rpg.org +++ b/play/rpg.org @@ -6,10 +6,8 @@ #+BEGIN_EXPORT html
- Exit to Website -
+ 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.">
@@ -24,6 +22,8 @@
+ + Exit to Website
@@ -46,22 +46,45 @@
-
- - - - - - - - - + + + + + + + + + + + #+END_EXPORT diff --git a/posts/posts-list.org b/posts/posts-list.org index 8aa8feb..5586f71 100755 --- a/posts/posts-list.org +++ b/posts/posts-list.org @@ -4,7 +4,7 @@ See the categories: @@html:Categories@@ * Posts: -- [[file:career/career-list.org][Career List]] @@html:30-07-2026 10:18@@ +- [[file:career/career-list.org][Career List]] @@html:30-07-2026 10:22@@ - [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:01-06-2026 10:21@@ @@html:@@ @@html:@@ - [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:11-03-2026 17:18@@ @@html:@@ @@html:@@ - [[file:career/javascript.org][Understands the Javascript language]] @@html:11-03-2026 16:52@@ @@html:@@ @@html:@@ diff --git a/recently-updated.org b/recently-updated.org index 8475dac..293e1eb 100755 --- a/recently-updated.org +++ b/recently-updated.org @@ -2,7 +2,7 @@ #+OPTIONS: toc:nil num:nil * Recently Updated (top 26 files) -- [[file:play/rpg.org][Rescue Princess Lima]] @@html:2026-07-29 00:00@@ +- [[file:play/rpg.org][Rescue Princess Lima]] @@html:2026-07-30 00:00@@ - [[file:play/house.org][The House of Pages]] @@html:2026-07-15 00:00@@ - [[file:blogs/2026/07-july/12-07-week-review.org][[12-07-2026] - Weekly Review]] @@html:2026-07-12 12:00@@ - [[file:home/wird-tracker.org][Wird Tracker]] @@html:2026-07-08 22:36@@ diff --git a/sitemap.org b/sitemap.org index ebc9b3d..7a65447 100755 --- a/sitemap.org +++ b/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]] \ No newline at end of file diff --git a/tests/princess-lima-polish.test.cjs b/tests/princess-lima-polish.test.cjs new file mode 100644 index 0000000..52a09ae --- /dev/null +++ b/tests/princess-lima-polish.test.cjs @@ -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 I<\/kbd>/); + assert.match(rpgSource, /Menu 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/); +}); diff --git a/tests/princess-lima-state.test.cjs b/tests/princess-lima-state.test.cjs old mode 100644 new mode 100755 index 5999010..5d99874 --- a/tests/princess-lima-state.test.cjs +++ b/tests/princess-lima-state.test.cjs @@ -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", () => { diff --git a/tests/princess-lima-systems.test.cjs b/tests/princess-lima-systems.test.cjs old mode 100644 new mode 100755 index 054b8d2..db1e40e --- a/tests/princess-lima-systems.test.cjs +++ b/tests/princess-lima-systems.test.cjs @@ -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]) => { diff --git a/tools/generate-princess-lima-assets.py b/tools/generate-princess-lima-assets.py new file mode 100644 index 0000000..ea7bac9 --- /dev/null +++ b/tools/generate-princess-lima-assets.py @@ -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()