Refactor platform UI and data flow across core pages
All checks were successful
Build Org Website / build (push) Successful in 50s
All checks were successful
Build Org Website / build (push) Successful in 50s
This commit is contained in:
27
assets/scripts/pages/princess-lima-audio.js
Normal file → Executable file
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
0
assets/scripts/pages/princess-lima-data.js
Normal file → Executable file
86
assets/scripts/pages/princess-lima-game.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
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
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
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
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
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
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 });
|
||||
|
||||
Reference in New Issue
Block a user