Refine Princess Lima for v2 presentation
All checks were successful
Build Org Website / build (push) Successful in 1m2s

This commit is contained in:
gitea-actions
2026-07-30 15:00:45 +01:00
parent a6502b168d
commit 6066f486de
35 changed files with 74848 additions and 141 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"columns": 4,
"image": "../../images/play/princess-lima/world-tiles-v2.png",
"imageheight": 288,
"imagewidth": 128,
"margin": 0,
"name": "Princess Lima World",
"spacing": 0,
"tilecount": 36,
"tileheight": 32,
"tilewidth": 32,
"tiledversion": "1.11.2",
"type": "tileset",
"version": "1.10"
}

View File

@@ -13,6 +13,7 @@
let ambience = null;
let voice = null;
let unlocked = false;
let ducked = false;
function attach(nextScene, region) {
scene = nextScene;
@@ -34,7 +35,7 @@
const enabled = Boolean(unlocked && state && state.settings.soundEnabled);
scene.sound.mute = !enabled;
if (ambience) {
ambience.setVolume(state ? (state.settings.master || 0) * (state.settings.music || 0) : 0);
ambience.setVolume(state ? (state.settings.master || 0) * (state.settings.music || 0) * (ducked ? 0.38 : 1) : 0);
if (enabled && !ambience.isPlaying) ambience.play();
if (!enabled && ambience.isPlaying) ambience.pause();
}
@@ -73,6 +74,11 @@
apply();
}
function duck(value) {
ducked = Boolean(value);
apply();
}
function stop() {
stopVoice();
if (ambience) ambience.stop();
@@ -80,7 +86,7 @@
scene = null;
}
return Object.freeze({ attach, unlock, apply, play, playVoice, stopVoice, suspend, resume, stop, isUnlocked: () => unlocked });
return Object.freeze({ attach, unlock, apply, play, playVoice, stopVoice, suspend, resume, duck, stop, isUnlocked: () => unlocked });
}
root.PrincessLimaAudio = Object.freeze({ create, TRACKS });

View File

@@ -0,0 +1,234 @@
(function (root, factory) {
"use strict";
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
root.PrincessLimaV2Data = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
const SCHEMA_VERSION = 2;
const WIDTH = 960;
const HEIGHT = 540;
const TILE = 32;
const MAP_IDS = Object.freeze([
"village", "forest", "ruins", "mountain", "camp",
"fortressExterior", "fortressInterior", "bossArena", "chamber"
]);
const MAP_NAMES = Object.freeze({
village: "Broken Village", forest: "Whispering Woods", ruins: "Ancient Ruins",
mountain: "Frostpeak Mountain", camp: "Resistance Camp",
fortressExterior: "Fortress Exterior", fortressInterior: "Fortress Interior",
bossArena: "Throne of Night", chamber: "Lima's Chamber"
});
const MAP_URLS = Object.freeze(Object.fromEntries(MAP_IDS.map((id) => [
id, `/assets/maps/princess-lima/${id}.json`
])));
const ASSETS = Object.freeze({
schemaVersion: SCHEMA_VERSION,
shared: Object.freeze([
{ type: "image", key: "world-tiles", url: "/assets/images/play/princess-lima/world-tiles-v2.png", required: true },
{ type: "spritesheet", key: "actors-v2", url: "/assets/images/play/princess-lima/actor-atlas-v2.png", frameWidth: 48, frameHeight: 64, required: true },
{ type: "spritesheet", key: "effects-v2", url: "/assets/images/play/princess-lima/effects-atlas-v2.png", frameWidth: 32, frameHeight: 32, required: false },
{ type: "image", key: "lima-title", url: "/assets/images/play/princess-lima/title-landscape.png", required: true }
]),
portraits: "/assets/images/play/princess-lima/portrait-atlas-v2.png",
maps: MAP_URLS
});
const ACTORS = Object.freeze({
player: { row: 0, name: "Traveller" }, lima: { row: 1, name: "Princess Lima" },
elder: { row: 2, name: "Elder Corin" }, bram: { row: 3, name: "Blacksmith Bram" },
nia: { row: 4, name: "Healer Nia" }, tovin: { row: 5, name: "Guide Tovin" },
malrec: { row: 6, name: "Lord Malrec" }, slime: { row: 7, name: "Marsh Slime" },
wolf: { row: 8, name: "Grey Wolf" }, raider: { row: 9, name: "Blackroad Raider" },
flying: { row: 10, name: "Cave Bat" }, shield: { row: 11, name: "Shadow Guard" },
stone_guardian: { row: 12, name: "Stone Guardian" }, archer: { row: 13, name: "Shadow Archer" },
elowen: { row: 14, name: "Commander Elowen" }, prisoner: { row: 15, name: "Resistance Prisoner" },
ambusher: { row: 8, name: "Briar Stalker" }, caster: { row: 6, name: "Night Caster" },
support: { row: 4, name: "Shadow Mender" }, elite: { row: 11, name: "Blackguard Elite" },
captain: { row: 11, name: "Captain Veyr" }, briar_wolf: { row: 8, name: "Briar Wolf" }
});
const ENEMIES = Object.freeze({
raider: { role: "melee", health: 5, damage: 9, speed: 86, awareness: 210, attackRange: 42, cooldown: 900, telegraph: 230 },
shield: { role: "shield", health: 9, damage: 11, speed: 58, awareness: 190, attackRange: 42, cooldown: 1250, telegraph: 360 },
archer: { role: "ranged", health: 5, damage: 10, speed: 64, awareness: 310, attackRange: 230, cooldown: 1550, telegraph: 450 },
wolf: { role: "fast", health: 4, damage: 8, speed: 122, awareness: 250, attackRange: 38, cooldown: 760, telegraph: 180 },
ambusher: { role: "ambush", health: 5, damage: 12, speed: 104, awareness: 135, attackRange: 38, cooldown: 1100, telegraph: 280 },
caster: { role: "caster", health: 6, damage: 11, speed: 50, awareness: 280, attackRange: 185, cooldown: 1800, telegraph: 600 },
flying: { role: "flying", health: 3, damage: 7, speed: 112, awareness: 240, attackRange: 34, cooldown: 800, telegraph: 160 },
support: { role: "support", health: 5, damage: 5, speed: 58, awareness: 240, attackRange: 150, cooldown: 1900, telegraph: 420 },
elite: { role: "elite", health: 15, damage: 15, speed: 72, awareness: 260, attackRange: 48, cooldown: 1100, telegraph: 300 },
briar_wolf: { role: "boss", boss: true, phases: 2, health: 30, damage: 14, speed: 118, awareness: 430, attackRange: 56, cooldown: 950, telegraph: 380 },
stone_guardian: { role: "boss", boss: true, phases: 3, health: 42, damage: 17, speed: 54, awareness: 430, attackRange: 74, cooldown: 1350, telegraph: 620 },
captain: { role: "boss", boss: true, phases: 2, health: 36, damage: 15, speed: 78, awareness: 430, attackRange: 54, cooldown: 1050, telegraph: 360 },
malrec: { role: "boss", boss: true, phases: 3, health: 64, damage: 18, speed: 70, awareness: 520, attackRange: 190, cooldown: 1200, telegraph: 520 }
});
const ITEMS = Object.freeze({
village_sword: { name: "Wayfarer Sword", type: "weapon", attack: 2, unique: true },
tempered_sword: { name: "Sun-tempered Sword", type: "weapon", attack: 4, unique: true },
buckler: { name: "Oak Buckler", type: "armour", defence: 2, unique: true },
reinforced_buckler: { name: "Resistance Buckler", type: "armour", defence: 4, unique: true },
trail_boots: { name: "Trail Boots", type: "equipment", unique: true },
forest_charm: { name: "Forest Charm", type: "charm", unique: true },
sun_crystal: { name: "Sun Crystal", type: "quest", unique: true },
fortress_emblem: { name: "Fortress Emblem", type: "quest", unique: true },
prison_key: { name: "Prison Key", type: "quest", unique: true },
smoke_bomb: { name: "Smoke Bomb", type: "consumable", stack: 5 },
healing_tonic: { name: "Healing Tonic", type: "consumable", heal: 40, stack: 9 },
royal_draught: { name: "Royal Draught", type: "consumable", heal: 999, stack: 3 },
silver_leaf: { name: "Silver Leaf", type: "collectable", stack: 12 },
moon_coin: { name: "Moon Coin", type: "currency", stack: 99 }
});
const QUESTS = Object.freeze({
aftermath: { title: "After the Black Riders", chapter: 1, region: "village", main: true, target: 1, reward: [["village_sword", 1]] },
village_defence: { title: "The Second Raid", chapter: 1, region: "village", main: true, target: 3, reward: [["buckler", 1], ["healing_tonic", 2]] },
healer_herbs: { title: "Silver for the Wounded", chapter: 1, region: "village", main: false, target: 2, reward: [["healing_tonic", 2]] },
find_guide: { title: "The Missing Guide", chapter: 2, region: "forest", main: true, target: 2, reward: [["trail_boots", 1], ["forest_charm", 1]] },
ruins_light: { title: "Light Beneath the Roots", chapter: 2, region: "ruins", main: true, target: 4, reward: [["sun_crystal", 1]] },
wolf_miniboss: { title: "The Briar Wolf", chapter: 2, region: "forest", main: true, target: 1, reward: [] },
repair_bridge: { title: "A Road Across the Sky", chapter: 3, region: "mountain", main: true, target: 2, reward: [["tempered_sword", 1]] },
stone_guardian: { title: "Guardian of Frostpeak", chapter: 3, region: "mountain", main: true, target: 1, reward: [["royal_draught", 1]] },
rally_resistance: { title: "A Camp Rekindled", chapter: 3, region: "camp", main: true, target: 2, reward: [["fortress_emblem", 1], ["reinforced_buckler", 1]] },
disable_defences: { title: "Blind the Fortress", chapter: 4, region: "fortressExterior", main: false, target: 2, reward: [["smoke_bomb", 2]] },
free_prisoners: { title: "No One Left in Shadow", chapter: 4, region: "fortressInterior", main: true, target: 2, reward: [["prison_key", 1]] },
break_wards: { title: "The Three Shadow Wards", chapter: 4, region: "fortressInterior", main: true, target: 3, reward: [] },
defeat_malrec: { title: "The Last Shadow", chapter: 4, region: "bossArena", main: true, target: 1, reward: [] }
});
const PORTRAITS = Object.freeze({
lima: { determined: 0, relieved: 1 }, malrec: { enraged: 2, cold: 3 },
elder: { worried: 4, thoughtful: 5 }, bram: { neutral: 6, determined: 7 },
nia: { gentle: 8, worried: 9 }, tovin: { alert: 10, surprised: 11 },
elowen: { neutral: 12, determined: 13 }, player: { neutral: 14, injured: 15 },
prisoner: { worried: 4, relieved: 1 }
});
const DIALOGUES = Object.freeze({
elder: {
id: "elder", start: "arrival", essential: true,
nodes: {
arrival: { speaker: "elder", expression: "worried", text: "The black riders took Princess Lima north. She stood between Malrec and every soul in this square.", camera: "two-shot", next: "choice" },
choice: { speaker: "elder", expression: "thoughtful", text: "We need more than a sword. Will you help us stand before you follow?", choices: [
{ text: "No one is left behind.", next: "accept", effects: [{ type: "startQuest", id: "aftermath" }, { type: "flag", id: "promised_village", value: true }] },
{ text: "Tell me where Malrec went.", next: "direct", effects: [{ type: "startQuest", id: "aftermath" }] }
] },
accept: { speaker: "player", expression: "neutral", text: "I will help the village—and then I will bring Lima home.", next: "end" },
direct: { speaker: "elder", expression: "worried", text: "Through the Whispering Woods, over Frostpeak, into the Fortress of Shadows. But Bram's blade must go with you.", next: "end" },
end: { speaker: "elder", expression: "thoughtful", text: "Then hope has arrived before dawn after all.", effects: [{ type: "completeQuest", id: "aftermath" }] }
}
},
bram: {
id: "bram", start: "start", essential: false,
nodes: {
start: { speaker: "bram", expression: "neutral", text: "This blade was forged for a royal guard. Today it chooses the road, not the rank.", next: "end" },
end: { speaker: "bram", expression: "determined", text: "Bring it to Frostpeak's old forge and I will teach sunlight to live in steel." }
}
},
nia: {
id: "nia", start: "start", essential: false,
nodes: {
start: { speaker: "nia", expression: "gentle", text: "Silver Leaf grows where moonlight reaches the forest floor. Two sprigs would save a fevered child.", choices: [
{ text: "I will look for it.", next: "thanks", effects: [{ type: "startQuest", id: "healer_herbs" }] },
{ text: "I cannot promise, but I will remember.", next: "thanks" }
] },
thanks: { speaker: "nia", expression: "worried", text: "Courage is easier when it remembers tenderness." }
}
},
tovin: {
id: "tovin", start: "start", essential: true,
nodes: {
start: { speaker: "tovin", expression: "surprised", text: "You found me. The woods hid the true path after Malrec poisoned the old stones.", next: "clue" },
clue: { speaker: "tovin", expression: "alert", text: "Wake dawn, noon, dusk, then night beneath the ruins. The forest will open when its memory is whole.", effects: [{ type: "startQuest", id: "find_guide" }] }
}
},
elowen: {
id: "elowen", start: "start", essential: true,
nodes: {
start: { speaker: "elowen", expression: "determined", text: "We can hit the gate, slip through the drain, or blind the detection wards first.", choices: [
{ text: "Direct assault.", next: "route", effects: [{ type: "flag", id: "route_assault", value: true }] },
{ text: "Use the hidden drain.", next: "route", effects: [{ type: "flag", id: "route_infiltration", value: true }] },
{ text: "Disable every defence.", next: "route", effects: [{ type: "startQuest", id: "disable_defences" }, { type: "flag", id: "route_sabotage", value: true }] }
] },
route: { speaker: "elowen", expression: "neutral", text: "Then that is our road. Lima held the kingdom together alone long enough." }
}
},
prisoner: {
id: "prisoner", start: "start", essential: false,
nodes: {
start: { speaker: "prisoner", expression: "worried", text: "The three wards feed the throne. Break moon, crown, and flame before Malrec can draw on them.", next: "end" },
end: { speaker: "prisoner", expression: "relieved", text: "Open the cells and we will make sure no guard reaches your back.", effects: [{ type: "startQuest", id: "free_prisoners" }, { type: "startQuest", id: "break_wards" }] }
}
},
malrec: {
id: "malrec", start: "start", essential: true,
nodes: {
start: { speaker: "malrec", expression: "cold", text: "Lima's oath could end a century of quarrels. You call it freedom when every lord may choose another war.", next: "reply" },
reply: { speaker: "player", expression: "neutral", text: "Peace forced by shadow is only silence.", next: "end" },
end: { speaker: "malrec", expression: "enraged", text: "Then listen closely as hope learns to break.", effects: [{ type: "startQuest", id: "defeat_malrec" }, { type: "flag", id: "boss_started", value: true }] }
}
},
lima: {
id: "lima", start: "start", essential: true,
nodes: {
start: { speaker: "lima", expression: "determined", text: "You crossed a wounded kingdom for someone you had never met.", choices: [
{ text: "Every person I helped led me here.", next: "hope", effects: [{ type: "flag", id: "ending_community", value: true }] },
{ text: "No throne belongs to shadow.", next: "hope", effects: [{ type: "flag", id: "ending_defiance", value: true }] }
] },
hope: { speaker: "lima", expression: "relieved", text: "Then let us go home—not as legend and princess, but as two people who chose to help.", effects: [{ type: "flag", id: "rescued", value: true }] }
}
}
});
const CUTSCENES = Object.freeze({
intro: [
{ command: "fade", direction: "in", duration: 800 },
{ command: "caption", text: "Before shadow crossed the northern road, Princess Lima listened before she ruled." },
{ command: "music", key: "village-theme" },
{ command: "caption", text: "Lord Malrec came for the royal oath. Lima refused him, and the kingdom paid for her courage." },
{ command: "shake", duration: 180, intensity: 0.003 },
{ command: "caption", text: "At dawn, one traveller reached the broken village." },
{ command: "title", text: "RESCUE PRINCESS LIMA" },
{ command: "transition", scene: "LimaWorld", region: "village" }
],
malrecEntrance: [
{ command: "lock", reason: "cutscene" }, { command: "camera", target: "malrec", zoom: 1.18, duration: 700 },
{ command: "lighting", preset: "shadow" }, { command: "dialogue", id: "malrec" },
{ command: "cameraRestore", duration: 500 }, { command: "unlock", reason: "cutscene" }
]
});
function validateDialogue(dialogue) {
if (!dialogue || !dialogue.id || !dialogue.start || !dialogue.nodes || !dialogue.nodes[dialogue.start]) return false;
return Object.values(dialogue.nodes).every((node) => {
if (!node.speaker || typeof node.text !== "string") return false;
if (node.next && !dialogue.nodes[node.next]) return false;
return !node.choices || node.choices.every((choice) => choice.text && dialogue.nodes[choice.next]);
});
}
function validateAssetManifest(manifest) {
return Boolean(manifest && manifest.schemaVersion === SCHEMA_VERSION &&
Array.isArray(manifest.shared) && manifest.shared.every((item) => item.key && item.url && item.type));
}
function validateAll() {
const errors = [];
if (!validateAssetManifest(ASSETS)) errors.push("asset-manifest");
Object.entries(DIALOGUES).forEach(([id, dialogue]) => { if (!validateDialogue(dialogue)) errors.push(`dialogue:${id}`); });
Object.entries(ENEMIES).forEach(([id, enemy]) => {
if (!ACTORS[id] || enemy.health <= 0 || enemy.speed <= 0) errors.push(`enemy:${id}`);
});
MAP_IDS.forEach((id) => { if (!MAP_URLS[id] || !MAP_NAMES[id]) errors.push(`map:${id}`); });
return errors;
}
return Object.freeze({
SCHEMA_VERSION, WIDTH, HEIGHT, TILE, MAP_IDS, MAP_NAMES, MAP_URLS, ASSETS,
ACTORS, ENEMIES, ITEMS, QUESTS, PORTRAITS, DIALOGUES, CUTSCENES,
validateDialogue, validateAssetManifest, validateAll
});
}));

View File

@@ -0,0 +1,278 @@
(function () {
"use strict";
const Data = window.PrincessLimaV2Data;
const State = window.PrincessLimaV2State;
const Scenes = window.PrincessLimaV2Scenes;
const UI = window.PrincessLimaV2UI;
const Audio = window.PrincessLimaAudio;
const controller = {
root: null, game: null, scene: null, state: null, audio: null, ui: null,
inputLock: null, uiTokens: new Map(), lastSaveAt: 0,
systemReducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
reducedMotion() {
return this.state && typeof this.state.settings.reducedMotion === "boolean"
? this.state.settings.reducedMotion : this.systemReducedMotion;
}
};
document.addEventListener("DOMContentLoaded", init, { once: true });
function init() {
const root = document.querySelector("[data-princess-lima-rpg]");
if (!root || !Data || !State || !Scenes || !UI || !Audio || !window.Phaser || !window.PrincessLimaV2Maps) return;
controller.root = root;
const loaded = State.load(localStorage);
controller.state = loaded.state;
controller.audio = Audio.create(() => controller.state);
controller.inputLock = window.PrincessLimaV2Systems.createInputLock((locked) => root.classList.toggle("is-input-locked", locked));
controller.ui = UI.create(root, {
getState: () => controller.state,
setState,
applyEffects: (effects) => setState(State.applyEffects(controller.state, effects)),
recordDialogue,
lock,
beginDialogue: (_id, target) => controller.scene && controller.scene.beginDialogue(target),
frameDialogue: (node) => controller.scene && controller.scene.frameDialogue(node),
endDialogue: () => controller.scene && controller.scene.endDialogue(),
focusGame,
useTonic,
setting: updateSetting,
replayIntro,
respawn,
returnToMenu
});
bindPage();
startEngine();
if (loaded.migrated) queueMicrotask(() => status("Legacy progress imported safely. Your original v1 save remains untouched."));
window.addEventListener("error", (event) => status(`The game recovered from a problem: ${event.message}`));
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
controller.inputLock.acquire("hidden-tab");
controller.audio.suspend();
} else {
controller.inputLock.releaseReason("hidden-tab");
controller.audio.resume();
}
});
window.PrincessLimaV2Controller = controller;
}
function startEngine() {
try {
controller.game = new Phaser.Game({
type: Phaser.AUTO,
width: Data.WIDTH,
height: Data.HEIGHT,
parent: "princess-lima-game",
pixelArt: true,
roundPixels: true,
backgroundColor: "#080a12",
physics: { default: "arcade", arcade: { gravity: { x: 0, y: 0 }, debug: location.search.includes("collisionDebug=1") } },
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, width: Data.WIDTH, height: Data.HEIGHT },
render: { antialias: false, pixelArt: true, powerPreference: "high-performance" },
scene: Scenes.createSceneClasses(controller)
});
} catch (error) { fatal(error.message); }
}
function bindPage() {
const root = controller.root;
root.querySelector("[data-lima-new]").addEventListener("click", openSetup);
root.querySelector("[data-lima-continue]").addEventListener("click", () => {
if (!controller.state) return;
controller.audio.unlock();
controller.state.introSeen ? beginAdventure() : startIntro(false);
});
root.querySelector("[data-lima-replay-intro]").addEventListener("click", replayIntro);
root.querySelector("[data-lima-menu-settings]").addEventListener("click", () => controller.state ? controller.ui.openPanel("settings") : status("Create a traveller first; all accessibility settings remain available during play."));
root.querySelector("[data-lima-credits]").addEventListener("click", () => controller.ui.openPanel("credits"));
root.querySelector("[data-lima-setup-form]").addEventListener("submit", submitSetup);
root.querySelector("[data-lima-setup-cancel]").addEventListener("click", closeSetup);
root.querySelector("[data-lima-pause]").addEventListener("click", () => controller.ui.openPanel("pause"));
root.querySelector("[data-lima-inventory]").addEventListener("click", () => controller.ui.openPanel("inventory"));
root.querySelector("[data-lima-quests]").addEventListener("click", () => controller.ui.openPanel("quests"));
root.querySelector("[data-lima-sound]").addEventListener("click", toggleSound);
root.querySelector("[data-lima-fullscreen]").addEventListener("click", toggleFullscreen);
document.addEventListener("fullscreenchange", updateFullscreenButton);
}
function ready() {
controller.root.dataset.ready = "true";
controller.root.querySelector("[data-lima-loading]").hidden = true;
const button = controller.root.querySelector("[data-lima-continue]");
button.disabled = !controller.state;
button.textContent = controller.state ? `Continue · Chapter ${controller.state.chapter}` : "Continue";
}
function showMenu() {
controller.inputLock.clear();
controller.root.querySelector("[data-lima-menu]").hidden = false;
controller.root.querySelector("[data-lima-hud]").hidden = true;
controller.root.querySelector("[data-lima-status-stack]").hidden = true;
requestAnimationFrame(() => controller.root.querySelector("[data-lima-menu] button").focus());
}
function hideMenu() {
controller.root.querySelector("[data-lima-menu]").hidden = true;
}
function openSetup() {
hideMenu();
controller.root.querySelector("[data-lima-setup]").hidden = false;
controller.root.querySelector("[data-lima-setup] input").focus();
}
function closeSetup() {
controller.root.querySelector("[data-lima-setup]").hidden = true;
showMenu();
}
function submitSetup(event) {
event.preventDefault();
const form = event.currentTarget;
const name = State.validName(form.elements.name.value);
const appearance = form.elements.appearance.value;
if (!name || !State.APPEARANCES.includes(appearance)) {
controller.root.querySelector("[data-lima-setup-error]").textContent = "Enter a name from 1 to 20 characters and choose a cloak.";
return;
}
controller.state = State.fresh(name, appearance);
controller.audio.unlock();
save();
controller.root.querySelector("[data-lima-setup]").hidden = true;
startIntro(false);
}
function startIntro(replay) {
hideMenu();
controller.root.querySelector("[data-lima-hud]").hidden = true;
controller.game.scene.start("LimaIntro", { replay });
}
function replayIntro() {
controller.ui.close();
startIntro(true);
}
function beginAdventure(sourceScene) {
hideMenu();
controller.audio.unlock();
const region = controller.state.region || "village";
if (sourceScene && sourceScene.scene) sourceScene.scene.start("LimaWorld", { region });
else controller.game.scene.start("LimaWorld", { region });
focusGame();
}
function showHud() {
controller.root.querySelector("[data-lima-hud]").hidden = false;
controller.root.querySelector("[data-lima-status-stack]").hidden = false;
}
function updateHud() {
if (!controller.state) return;
const state = controller.state;
controller.root.querySelector("[data-lima-player]").textContent = state.player.name;
controller.root.querySelector("[data-lima-chapter]").textContent = `Chapter ${state.chapter}`;
controller.root.querySelector("[data-lima-health]").textContent = `${Math.ceil(state.health)} / ${state.maxHealth}`;
controller.root.querySelector(".lima-hud__healthbar i").style.width = `${state.health / state.maxHealth * 100}%`;
controller.root.querySelector("[data-lima-weapon]").textContent = state.equipment.weapon ? Data.ITEMS[state.equipment.weapon].name : "Unarmed";
controller.root.querySelector("[data-lima-armour]").textContent = state.equipment.armour ? Data.ITEMS[state.equipment.armour].name : "None";
controller.root.querySelector("[data-lima-selected-item]").textContent = `Healing Tonic ×${State.quantity(state, "healing_tonic")}`;
controller.root.querySelector("[data-lima-currency]").textContent = State.quantity(state, "moon_coin");
controller.root.querySelector("[data-lima-region]").textContent = Data.MAP_NAMES[state.region];
controller.root.querySelector("[data-lima-objective]").textContent = window.PrincessLimaV2Systems.currentObjective(state);
controller.root.classList.toggle("is-high-contrast", state.settings.highContrast);
}
function setState(next, message) {
const normalized = State.normalize(next);
if (!normalized) return;
controller.state = normalized;
save();
updateHud();
if (message) status(message);
}
function recordDialogue(id) {
if (!controller.state.dialogueHistory.includes(id)) controller.state.dialogueHistory.push(id);
save();
}
function save() {
if (!controller.state) return;
try {
localStorage.setItem(State.STORAGE_KEY, JSON.stringify(State.normalize(controller.state)));
controller.lastSaveAt = Date.now();
controller.root.dataset.saved = "true";
setTimeout(() => { if (controller.root) controller.root.dataset.saved = "false"; }, 900);
} catch (_error) { status("Saving is unavailable in this browser session."); }
}
function travel(region, spawn) {
const safe = State.safeSpawn(region, spawn);
controller.state.region = region;
controller.state.position = Object.assign({}, safe, { facing: "south" });
controller.state.checkpoint = safe;
save();
controller.audio.play("door");
controller.game.scene.start("LimaWorld", { region });
}
function respawn() {
const checkpoint = controller.state.checkpoint;
controller.state.region = checkpoint.region;
controller.state.position = Object.assign({}, checkpoint, { facing: "south" });
controller.state.health = Math.max(Math.ceil(controller.state.maxHealth * 0.6), 1);
save();
controller.game.scene.start("LimaWorld", { region: checkpoint.region });
}
function useTonic() {
const result = State.useTonic(controller.state);
if (!result.used) return status(controller.state.health >= controller.state.maxHealth ? "Health is already full." : "No Healing Tonics remain.");
setState(result.state, "Healing Tonic used.");
controller.audio.play("pickup");
}
function updateSetting(key, value) {
if (!controller.state || !(key in controller.state.settings)) return;
controller.state.settings[key] = value;
controller.state = State.normalize(controller.state);
save();
controller.audio.apply();
updateHud();
if (["effectsQuality", "particles", "lighting"].includes(key) && controller.scene) status("This visual setting applies fully after the next region transition.");
}
function toggleSound() {
if (!controller.state) return;
controller.state.settings.soundEnabled = !controller.state.settings.soundEnabled;
controller.audio.unlock();
controller.audio.apply();
save();
const button = controller.root.querySelector("[data-lima-sound]");
button.textContent = controller.state.settings.soundEnabled ? "Sound On" : "Sound Muted";
button.setAttribute("aria-pressed", String(controller.state.settings.soundEnabled));
}
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen();
else controller.root.requestFullscreen().catch(() => status("Fullscreen is unavailable in this browser."));
}
function updateFullscreenButton() {
const button = controller.root.querySelector("[data-lima-fullscreen]");
button.setAttribute("aria-pressed", String(Boolean(document.fullscreenElement)));
button.textContent = document.fullscreenElement ? "Exit Fullscreen" : "Fullscreen";
}
function lock(value, reason) {
if (value) {
if (!controller.uiTokens.has(reason)) controller.uiTokens.set(reason, controller.inputLock.acquire(reason));
} else if (controller.uiTokens.has(reason)) {
controller.inputLock.release(controller.uiTokens.get(reason));
controller.uiTokens.delete(reason);
} else controller.inputLock.releaseReason(reason);
}
function frameDialogue(node) { if (controller.scene) controller.scene.frameDialogue(node); }
function focusGame() { const game = controller.root.querySelector("[data-lima-game]"); if (game) game.focus({ preventScroll: true }); }
function prompt(text) { controller.root.querySelector("[data-lima-prompt]").textContent = text || "Explore · speak · protect · discover"; }
function status(message) { const element = controller.root && controller.root.querySelector("[data-lima-status]"); if (element) element.textContent = message; }
function boss(name, health, maxHealth, phase) {
const container = controller.root.querySelector("[data-lima-boss]");
container.hidden = !name;
if (!name) return;
container.querySelector("[data-lima-boss-name]").textContent = `${name} · Phase ${phase}`;
container.querySelector("i").style.width = `${Math.max(0, health) / maxHealth * 100}%`;
}
function returnToMenu() {
controller.ui.close();
controller.game.scene.start("LimaTitle");
}
function fatal(message) {
if (!controller.root) return;
controller.root.querySelector("[data-lima-loading]").innerHTML = `<strong>The road could not open.</strong><span>${escapeHtml(message)}</span><a href="/play/play.html">Return to Play</a>`;
}
function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char])); }
Object.assign(controller, {
ready, showMenu, hideMenu, beginAdventure, showHud, updateHud, setState, save, travel,
prompt, status, boss, toggleFullscreen, fatal, frameDialogue, root: null
});
}());

View File

@@ -0,0 +1,82 @@
(function (root) {
"use strict";
const Data = root.PrincessLimaV2Data;
const Systems = root.PrincessLimaV2Systems;
function properties(object) {
return Systems.propertyMap(object);
}
function objects(map, layerName) {
const layer = map.getObjectLayer(layerName);
return layer ? layer.objects : [];
}
function build(scene, regionId, state) {
const map = scene.make.tilemap({ key: `map-${regionId}` });
const tileset = map.addTilesetImage("Princess Lima World", "world-tiles", 32, 32, 0, 0);
if (!tileset) throw new Error(`Tileset could not be attached for ${regionId}`);
const tileLayers = [
["Base terrain", 0, 1], ["Terrain variation", 2, 0.62], ["Paths", 3, 1],
["Water", 4, 0.94], ["Cliffs and buildings", 8, 1], ["Props", 12, 1],
["Objects behind actors", 20, 1], ["Actor layer", 100, 1],
["Objects above actors", 9000, 1], ["Shadows", 8800, 0.42], ["Lighting", 8900, 0.34]
].map(([name, depth, alpha]) => {
const layer = map.createLayer(name, tileset, 0, 0);
if (layer) layer.setDepth(depth).setAlpha(alpha);
return layer;
}).filter(Boolean);
const collisions = scene.physics.add.staticGroup();
objects(map, "Collision").forEach((object) => {
const blocker = scene.add.rectangle(object.x + object.width / 2, object.y + object.height / 2, object.width, object.height, 0x000000, 0);
scene.physics.add.existing(blocker, true);
collisions.add(blocker);
});
const bounds = { width: map.widthInPixels, height: map.heightInPixels };
const resolved = Boolean(state.flags[`${regionId}_resolved`]);
const tint = resolved ? 0xffffff : {
village: 0xd9c1a8, forest: 0xb7cfba, ruins: 0xaabdc4, mountain: 0xc7d8e6,
camp: 0xd8c09c, fortressExterior: 0xb7a7c2, fortressInterior: 0xa996af,
bossArena: 0xa17ca8, chamber: 0xfff1cc
}[regionId] || 0xffffff;
tileLayers.forEach((layer) => layer.setTint(tint));
const ambience = createAmbience(scene, regionId, bounds, state);
return {
map, tileset, tileLayers, collisions, bounds, ambience,
spawns: Object.fromEntries(objects(map, "Named safe spawns").map((object) => [object.name, { x: object.x, y: object.y }])),
npcs: objects(map, "Dialogue triggers"),
enemies: objects(map, "Enemy zones"),
interactions: objects(map, "Interaction zones").concat(objects(map, "Optional secrets")),
transitions: objects(map, "Scene transitions"),
cameraZones: objects(map, "Camera zones")
};
}
function createAmbience(scene, regionId, bounds, state) {
const container = scene.add.container(0, 0).setDepth(8700);
if (!state.settings.particles || state.settings.effectsQuality === "minimal") return container;
const colors = {
village: 0xffa75e, forest: 0xb9f39a, ruins: 0x6dd7df, mountain: 0xe8f5ff,
camp: 0xffc16b, fortressExterior: 0x9d6fb4, fortressInterior: 0x9d6fb4,
bossArena: 0xd968c2, chamber: 0xffe3a3
};
const count = state.settings.effectsQuality === "reduced" ? 10 : 24;
for (let index = 0; index < count; index += 1) {
const mote = scene.add.circle(
(index * 149 + 71) % bounds.width,
(index * 83 + 37) % bounds.height,
index % 3 + 1,
colors[regionId] || 0xffffff,
0.12 + (index % 4) * 0.07
);
container.add(mote);
if (!state.settings.reducedMotion) scene.tweens.add({
targets: mote, y: mote.y - 32 - (index % 5) * 8, x: mote.x + ((index % 3) - 1) * 18,
alpha: { from: mote.alpha, to: 0.03 }, duration: 2600 + index * 97, yoyo: true, repeat: -1,
ease: "Sine.InOut"
});
}
return container;
}
root.PrincessLimaV2Maps = Object.freeze({ build, objects, properties });
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -0,0 +1,699 @@
(function (root) {
"use strict";
const Data = root.PrincessLimaV2Data;
const State = root.PrincessLimaV2State;
const Systems = root.PrincessLimaV2Systems;
const Maps = root.PrincessLimaV2Maps;
function createSceneClasses(controller) {
class BootScene extends Phaser.Scene {
constructor() { super("LimaBoot"); }
create() {
const errors = Data.validateAll();
if (errors.length) return controller.fatal(`Game data failed validation: ${errors.join(", ")}`);
this.scene.start("LimaPreload");
}
}
class PreloadScene extends Phaser.Scene {
constructor() { super("LimaPreload"); }
preload() {
const bar = this.add.rectangle(Data.WIDTH / 2, Data.HEIGHT / 2, 420, 10, 0x352c42).setOrigin(0.5);
const fill = this.add.rectangle(Data.WIDTH / 2 - 210, Data.HEIGHT / 2, 0, 10, 0xdcb96c).setOrigin(0, 0.5);
this.load.on("progress", (value) => { fill.width = 420 * value; });
this.load.on("loaderror", (file) => {
if (Data.ASSETS.shared.find((asset) => asset.key === file.key && asset.required)) controller.fatal(`Required asset could not load: ${file.key}`);
});
Data.ASSETS.shared.forEach((asset) => {
if (asset.type === "image") this.load.image(asset.key, asset.url);
if (asset.type === "spritesheet") this.load.spritesheet(asset.key, asset.url, { frameWidth: asset.frameWidth, frameHeight: asset.frameHeight });
});
Object.entries(Data.MAP_URLS).forEach(([id, url]) => this.load.tilemapTiledJSON(`map-${id}`, url));
const audio = "/assets/audio/princess-lima/";
["step", "attack", "hit", "damage", "defeat", "pickup", "quest", "puzzle", "door", "victory",
"village-theme", "forest-theme", "mountain-theme", "fortress-theme", "boss-theme", "victory-theme",
"intro-narration-1", "intro-narration-2", "intro-narration-3", "intro-narration-4"].forEach((key) =>
this.load.audio(key, `${audio}${key}.wav`));
bar.setDepth(-1);
}
create() {
createAnimations(this);
controller.ready();
this.scene.start("LimaTitle");
}
}
class TitleScene extends Phaser.Scene {
constructor() { super("LimaTitle"); }
create() {
this.add.image(Data.WIDTH / 2, Data.HEIGHT / 2, "lima-title").setDisplaySize(Data.WIDTH, Data.HEIGHT).setTint(0xb9a8c8);
this.add.rectangle(Data.WIDTH / 2, Data.HEIGHT / 2, Data.WIDTH, Data.HEIGHT, 0x070912, 0.45);
controller.showMenu();
}
}
class IntroScene extends Phaser.Scene {
constructor() { super("LimaIntro"); }
init(data) { this.replay = Boolean(data && data.replay); }
create() {
controller.hideMenu();
controller.audio.attach(this, "village");
this.token = controller.inputLock.acquire("intro");
this.step = 0;
this.paused = false;
this.finished = false;
this.background = this.add.image(Data.WIDTH / 2, Data.HEIGHT / 2, "lima-title").setDisplaySize(Data.WIDTH, Data.HEIGHT).setTint(0x7e8faa);
this.vignette = this.add.rectangle(Data.WIDTH / 2, Data.HEIGHT / 2, Data.WIDTH, Data.HEIGHT, 0x070912, 0.48);
this.caption = this.add.text(Data.WIDTH / 2, 420, "", {
fontFamily: "Georgia, serif", fontSize: "24px", color: "#fff2d0", align: "center",
wordWrap: { width: 760 }, stroke: "#10101b", strokeThickness: 5
}).setOrigin(0.5);
this.title = this.add.text(Data.WIDTH / 2, 230, "", {
fontFamily: "Georgia, serif", fontSize: "54px", color: "#f7d88a", align: "center",
stroke: "#17101f", strokeThickness: 8
}).setOrigin(0.5).setAlpha(0);
this.input.keyboard.on("keydown-P", () => this.togglePause());
this.input.keyboard.on("keydown-ESC", () => this.finish(true));
this.runBeat();
}
runBeat() {
const beats = [
["Before shadow crossed the northern road, Princess Lima listened before she ruled.", 0xbac9d8, "intro-narration-1"],
["Lord Malrec came for the royal oath. Lima refused him, and the kingdom paid for her courage.", 0x7e5a72, "intro-narration-2"],
["Bound in shadow, Lima left a trail of courage through the woods and over Frostpeak.", 0x6f86a0, "intro-narration-3"],
["At dawn, one traveller reached the broken village.", 0xd1a66b, "intro-narration-4"]
];
if (this.step >= beats.length) {
this.title.setText("RESCUE\nPRINCESS LIMA");
this.tweens.add({ targets: this.title, alpha: 1, scale: { from: 0.92, to: 1 }, duration: controller.reducedMotion() ? 1 : 900 });
this.time.delayedCall(1800, () => this.finish(false));
return;
}
const [text, tint, voice] = beats[this.step];
this.caption.setText(text).setAlpha(0);
this.background.setTint(tint);
if (controller.state.settings.soundEnabled && controller.state.settings.narrationEnabled) controller.audio.playVoice(voice);
this.tweens.add({
targets: this.caption, alpha: 1, y: { from: 436, to: 420 }, duration: controller.reducedMotion() ? 1 : 500,
onComplete: () => { this.timer = this.time.delayedCall(controller.reducedMotion() ? 1800 : 4300, () => { this.step += 1; this.runBeat(); }); }
});
}
togglePause() {
this.paused = !this.paused;
if (this.timer) this.timer.paused = this.paused;
this.caption.setText(this.paused ? "Paused · press P to continue" : this.caption.text);
}
finish(skipped) {
if (this.finished) return;
this.finished = true;
controller.audio.stopVoice();
controller.inputLock.release(this.token);
if (!this.replay) {
controller.state.introSeen = true;
controller.save();
controller.beginAdventure(this);
} else {
controller.showMenu();
this.scene.start("LimaTitle");
}
if (skipped) controller.status("Introduction skipped safely.");
}
shutdown() {
controller.audio.stopVoice();
if (this.token) controller.inputLock.release(this.token);
}
}
class WorldScene extends Phaser.Scene {
constructor() { super("LimaWorld"); }
init(data) {
this.regionId = data && Data.MAP_IDS.includes(data.region) ? data.region : controller.state.region;
this.attack = null;
this.attackSerial = 0;
this.attackHits = new Set();
this.combo = 0;
this.spaceDownAt = 0;
this.blocking = false;
this.lastDamagedAt = -1000;
this.lastStepAt = 0;
this.interactionTarget = null;
this.enemySerial = 0;
this.projectiles = [];
this.dialogueSnapshot = null;
this.pathTick = 0;
this.puzzleProgress = {};
this.spawnedObjects = new Set();
}
create() {
controller.scene = this;
controller.state.region = this.regionId;
const safe = State.safeSpawn(this.regionId, controller.state.position.spawn);
if (controller.state.region !== this.regionId) controller.state.position = Object.assign(safe, { facing: "south" });
this.world = Maps.build(this, this.regionId, controller.state);
this.physics.world.setBounds(0, 0, this.world.bounds.width, this.world.bounds.height);
this.cameras.main.setBounds(0, 0, this.world.bounds.width, this.world.bounds.height);
this.createPlayer();
this.createActors();
this.createInputs();
this.physics.add.collider(this.player, this.world.collisions);
this.physics.add.collider(this.enemies, this.world.collisions);
this.cameras.main.startFollow(this.player, true, controller.reducedMotion() ? 1 : 0.12, controller.reducedMotion() ? 1 : 0.12);
this.cameras.main.setDeadzone(126, 84).setZoom(1.04);
this.setupPathfinding();
controller.audio.attach(this, this.regionId);
controller.showHud();
controller.updateHud();
controller.save();
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.cleanup());
if (this.regionId === "bossArena" && !controller.state.flags.boss_intro_seen) {
controller.state.flags.boss_intro_seen = true;
this.time.delayedCall(350, () => controller.ui.startDialogue("malrec", {
target: this.findActor("malrec"),
done: () => this.world.enemies.filter((object) => Maps.properties(object).enemy === "malrec").forEach((object) => this.spawnEnemy(object))
}));
}
if (this.regionId === "chamber") this.time.delayedCall(500, () => controller.ui.startDialogue("lima", {
target: this.findActor("lima"), done: () => { controller.state.rescued = true; controller.state.flags.chamber_resolved = true; controller.save(); controller.ui.ending(); }
}));
}
createPlayer() {
const position = controller.state.position;
this.facing = position.facing || "south";
this.player = this.physics.add.sprite(position.x, position.y, "actors-v2", actorFrame("player", this.facing, 0))
.setDepth(position.y + 100).setCollideWorldBounds(true);
this.player.body.setSize(25, 26).setOffset(12, 35);
this.player.setData("actorId", "player");
this.shadow = this.add.ellipse(position.x, position.y + 25, 31, 12, 0x080912, 0.34).setDepth(position.y + 80);
}
createActors() {
this.npcs = this.physics.add.staticGroup();
this.enemies = this.physics.add.group();
this.world.npcs.forEach((object) => {
const actor = this.npcs.create(object.x, object.y, "actors-v2", actorFrame(object.name, "south", 0));
actor.setData({ actorId: object.name, dialogue: Maps.properties(object).dialogue }).setDepth(object.y + 100);
actor.body.setSize(24, 24).setOffset(12, 38);
});
this.world.enemies.forEach((object) => this.spawnEnemy(object));
}
spawnEnemy(object) {
const props = Maps.properties(object);
const id = props.enemy;
const spec = Data.ENEMIES[id];
if (!spec || this.spawnedObjects.has(object.id) || controller.state.defeatedBosses.includes(id)) return;
if (this.regionId === "village" && controller.state.quests.village_defence.status !== "active") return;
if (id === "briar_wolf" && controller.state.quests.ruins_light.status !== "complete") return;
if (id === "stone_guardian" && controller.state.quests.repair_bridge.status !== "complete") return;
if (id === "captain" && controller.state.quests.rally_resistance.status === "locked") return;
if (id === "malrec" && !controller.state.flags.boss_started) return;
this.spawnedObjects.add(object.id);
const enemy = this.physics.add.sprite(object.x, object.y, "actors-v2", actorFrame(id, "south", 0));
enemy.body.setSize(spec.boss ? 36 : 25, spec.boss ? 34 : 25).setOffset(spec.boss ? 6 : 12, spec.boss ? 26 : 35);
enemy.setData({
uid: `${id}-${++this.enemySerial}`, actorId: id, spec, health: spec.health, maxHealth: spec.health,
state: spec.role === "ambush" ? "hidden" : "patrol", homeX: object.x, homeY: object.y,
leash: Number(props.leash) || 190, nextAction: this.time.now + 500, telegraphUntil: 0,
attackUntil: 0, recoverUntil: 0, stunnedUntil: 0, phase: 1, lastPathAt: 0, path: []
}).setDepth(object.y + 100);
if (spec.role === "ambush") enemy.setAlpha(0.22);
this.enemies.add(enemy);
}
createInputs() {
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({
up: "W", down: "S", left: "A", right: "D", interact: "E", alternateInteract: "ENTER",
attack: "SPACE", block: "SHIFT", item: "Q", inventory: "I", quests: "J", menu: "M",
fullscreen: "F", charged: "C", escape: "ESC"
});
this.input.keyboard.on("keydown-SPACE", () => { if (!controller.inputLock.locked()) this.spaceDownAt = this.time.now; });
this.input.keyboard.on("keyup-SPACE", () => {
if (controller.inputLock.locked()) return;
const held = this.time.now - this.spaceDownAt;
this.startAttack(held >= 440 ? "charged" : (this.combo === 1 && this.time.now - this.lastAttackEnd < 360 ? "light2" : "light1"));
});
}
setupPathfinding() {
if (!root.EasyStar || !root.EasyStar.js) return;
const grid = Array.from({ length: this.world.map.height }, () => Array(this.world.map.width).fill(0));
Maps.objects(this.world.map, "Collision").forEach((solid) => {
const sx = Math.floor(solid.x / 32), sy = Math.floor(solid.y / 32);
const ex = Math.ceil((solid.x + solid.width) / 32), ey = Math.ceil((solid.y + solid.height) / 32);
for (let y = sy; y < ey; y += 1) for (let x = sx; x < ex; x += 1) if (grid[y] && grid[y][x] !== undefined) grid[y][x] = 1;
});
this.pathfinder = new EasyStar.js();
this.pathfinder.setGrid(grid);
this.pathfinder.setAcceptableTiles([0]);
this.pathfinder.enableDiagonals();
this.pathfinder.disableCornerCutting();
this.pathfinder.setIterationsPerCalculation(180);
}
update(time, delta) {
if (!this.player || !this.player.active) return;
if (!controller.inputLock.locked()) {
this.updatePlayer(time, delta);
this.updateInteractions();
this.updateShortcuts();
} else this.player.setVelocity(0);
this.updateEnemies(time, delta);
this.updateProjectiles(time);
this.updateCameraZones();
this.shadow.setPosition(this.player.x, this.player.y + 25).setDepth(this.player.y + 80);
this.player.setDepth(this.player.y + 100);
if (this.pathfinder) this.pathfinder.calculate();
}
updatePlayer(time, delta) {
let x = (this.cursors.left.isDown || this.keys.left.isDown ? -1 : 0) + (this.cursors.right.isDown || this.keys.right.isDown ? 1 : 0);
let y = (this.cursors.up.isDown || this.keys.up.isDown ? -1 : 0) + (this.cursors.down.isDown || this.keys.down.isDown ? 1 : 0);
this.blocking = this.keys.block.isDown && !this.attack;
if (this.attack || this.blocking) { x = 0; y = 0; }
const speed = controller.state.equipment.boots ? 188 : 168;
const velocity = Systems.movementVelocity(this.player.body.velocity.x, this.player.body.velocity.y, x, y, delta, speed, Boolean(controller.state.equipment.boots));
this.player.setVelocity(velocity.x, velocity.y);
if (x || y) {
if (Math.abs(x) > Math.abs(y)) this.facing = x > 0 ? "east" : "west";
else this.facing = y > 0 ? "south" : "north";
this.player.anims.play(`walk-${Data.ACTORS.player.row}-${this.facing}`, true);
if (time - this.lastStepAt > 310) { controller.audio.play("step", 0.22); this.lastStepAt = time; }
} else if (!this.attack) this.player.anims.play(`idle-${Data.ACTORS.player.row}-${this.facing}`, true);
if (this.blocking) this.player.setTint(0x9ec9e8); else if (!this.attack) this.player.clearTint();
if (Phaser.Input.Keyboard.JustDown(this.keys.interact) || Phaser.Input.Keyboard.JustDown(this.keys.alternateInteract)) this.interact();
}
updateShortcuts() {
if (Phaser.Input.Keyboard.JustDown(this.keys.item)) controller.useTonic();
if (Phaser.Input.Keyboard.JustDown(this.keys.inventory)) controller.ui.openPanel("inventory");
if (Phaser.Input.Keyboard.JustDown(this.keys.quests)) controller.ui.openPanel("quests");
if (Phaser.Input.Keyboard.JustDown(this.keys.menu) || Phaser.Input.Keyboard.JustDown(this.keys.escape)) controller.ui.openPanel("pause");
if (Phaser.Input.Keyboard.JustDown(this.keys.fullscreen)) controller.toggleFullscreen();
}
updateInteractions() {
const candidates = [];
this.npcs.getChildren().forEach((actor) => candidates.push({ kind: "npc", target: actor, x: actor.x, y: actor.y, label: `Speak with ${Data.ACTORS[actor.getData("actorId")].name}` }));
this.world.interactions.forEach((object) => candidates.push({ kind: object.type === "secret" ? "secret" : "interaction", target: object, x: object.x, y: object.y, label: readable(object.name) }));
this.world.transitions.forEach((object) => candidates.push({ kind: "transition", target: object, x: object.x + object.width / 2, y: object.y + object.height / 2, label: readable(object.name) }));
candidates.forEach((candidate) => { candidate.distance = Phaser.Math.Distance.Between(this.player.x, this.player.y, candidate.x, candidate.y); });
this.interactionTarget = candidates.filter((candidate) => candidate.distance < (candidate.kind === "transition" ? 72 : 62)).sort((a, b) => a.distance - b.distance)[0] || null;
controller.prompt(this.interactionTarget ? `E · ${this.interactionTarget.label}` : "");
}
interact() {
const candidate = this.interactionTarget;
if (!candidate) return;
if (candidate.kind === "npc") {
const id = candidate.target.getData("dialogue");
controller.ui.startDialogue(id, {
target: candidate.target,
done: () => {
if (id === "elder" && controller.state.quests.village_defence.status === "locked") {
controller.setState(State.startQuest(controller.state, "village_defence"), "The Second Raid has begun.");
this.world.enemies.forEach((object) => this.spawnEnemy(object));
}
if (id === "tovin") controller.setState(State.progressQuest(controller.state, "find_guide", 1));
if (id === "elowen" && this.regionId === "camp") {
controller.setState(State.startQuest(controller.state, "rally_resistance"));
this.world.enemies.forEach((object) => this.spawnEnemy(object));
}
if (id === "prisoner") controller.setState(State.startQuest(controller.state, "break_wards"));
}
});
return;
}
if (candidate.kind === "transition") return this.transition(candidate.target);
const props = Maps.properties(candidate.target);
const action = props.action || props.observation;
if (candidate.kind === "secret") {
if (!controller.state.discoveredSecrets.includes(candidate.target.name)) {
controller.state.discoveredSecrets.push(candidate.target.name);
controller.setState(State.addItem(controller.state, "moon_coin", 1), props.observation || "A secret waits here.");
} else controller.status("You have already found this secret.");
return;
}
this.handleInteraction(candidate.target.name, action);
}
handleInteraction(name, action) {
if (action === "puzzle") {
const quest = this.regionId === "ruins" ? "ruins_light" : this.regionId === "mountain" ? "repair_bridge" : this.regionId === "fortressInterior" ? "break_wards" : "find_guide";
if (controller.state.quests[quest].status === "locked") controller.state = State.startQuest(controller.state, quest);
const key = `${this.regionId}:${name}`;
if (!controller.state.solvedPuzzles.includes(key)) {
controller.state.solvedPuzzles.push(key);
controller.setState(State.progressQuest(controller.state, quest, 1), `${readable(name)} answers with light.`);
controller.audio.play("puzzle");
if (quest === "repair_bridge" && controller.state.quests.repair_bridge.status === "complete") {
this.world.enemies.forEach((object) => this.spawnEnemy(object));
}
} else controller.status("This mechanism is already awake.");
return;
}
if (action === "free-prisoner") {
if (!controller.state.flags[`freed_${name}`]) {
controller.state.flags[`freed_${name}`] = true;
controller.setState(State.progressQuest(controller.state, "free_prisoners", 1), "A prisoner joins the resistance.");
}
return;
}
if (action === "disable-defence") {
if (!controller.state.flags[`disabled_${name}`]) {
controller.state.flags[`disabled_${name}`] = true;
controller.setState(State.progressQuest(controller.state, "disable_defences", 1), `${readable(name)} disabled.`);
}
return;
}
if (action === "route-choice") return controller.ui.startDialogue("elowen", { target: this.findActor("elowen") });
if (action === "chest") {
if (!controller.state.openedChests.includes(name)) {
controller.state.openedChests.push(name);
controller.setState(State.addItem(controller.state, "healing_tonic", 1), "Found a Healing Tonic.");
controller.audio.play("pickup");
}
return;
}
if (action === "boss-mechanic") {
controller.state.flags.sun_veil_broken = true;
controller.save();
this.cameras.main.flash(200, 255, 230, 150);
controller.status("The Sun Crystal tears open Malrec's veil.");
return;
}
if (action === "block-tutorial") return controller.status("Hold Shift to block. Release and strike during an enemy's recovery.");
controller.status(action || readable(name));
}
transition(object) {
const props = Maps.properties(object);
if (props.requirement && !controller.state.flags[props.requirement]) {
controller.status(`The route is not yet open · ${readable(props.requirement)}`);
return;
}
controller.travel(props.target, props.spawn);
}
startAttack(kind) {
if (this.attack || this.blocking || controller.inputLock.locked()) return;
const spec = Systems.ATTACKS[kind];
this.attack = { kind, spec, started: this.time.now, serial: ++this.attackSerial, active: false };
this.combo = kind === "light1" ? 1 : 0;
this.attackHits.clear();
this.player.setVelocity(0).setTint(kind === "charged" ? 0xffd47a : 0xffffff);
controller.audio.play("attack");
}
updateAttack(time) {
if (!this.attack) return;
const phase = Systems.attackPhase(time - this.attack.started, this.attack.kind);
if (phase === "active" && !this.attack.active) {
this.attack.active = true;
this.performHit(this.attack);
this.swingEffect(this.attack.kind);
}
if (phase === "complete") {
this.player.clearTint();
this.lastAttackEnd = time;
this.attack = null;
}
}
performHit(attack) {
const box = Systems.attackHitbox(this.facing, this.player.x, this.player.y, attack.kind);
const hitbox = this.add.rectangle(box.x + box.width / 2, box.y + box.height / 2, box.width, box.height, 0xffd66d, 0);
this.physics.add.existing(hitbox);
this.physics.overlap(hitbox, this.enemies, (_hit, enemy) => this.hitEnemy(enemy, attack));
this.time.delayedCall(20, () => hitbox.destroy());
}
swingEffect(kind) {
if (!controller.state.settings.particles) return;
const vector = Systems.facingVector(this.facing);
const arc = this.add.arc(this.player.x + vector.x * 30, this.player.y + vector.y * 26, kind === "charged" ? 42 : 31, 200, 340, false, kind === "charged" ? 0xffd77c : 0xeaf3ff, 0.72).setDepth(this.player.depth + 2);
arc.setRotation(Math.atan2(vector.y, vector.x) + Math.PI / 2);
this.tweens.add({ targets: arc, alpha: 0, scale: 1.25, duration: 140, onComplete: () => arc.destroy() });
}
hitEnemy(enemy, attack) {
if (!enemy.active || this.attackHits.has(enemy.getData("uid"))) return;
this.attackHits.add(enemy.getData("uid"));
const spec = enemy.getData("spec");
const blocking = spec.role === "shield" && enemy.getData("state") !== "recover" && attack.kind !== "charged";
const damage = blocking ? 0 : (controller.state.attack + attack.spec.damage);
if (!damage) {
enemy.setTint(0x9fb4cb);
this.time.delayedCall(100, () => enemy.active && enemy.clearTint());
controller.status("The guard blocks the strike. Charge or counter after an attack.");
return;
}
enemy.setData("health", enemy.getData("health") - damage);
enemy.setData("stunnedUntil", this.time.now + 220);
enemy.setData("state", "hurt");
enemy.setTint(0xffe1ad).setTintMode(Phaser.TintModes.FILL);
const vector = Systems.facingVector(this.facing);
enemy.setVelocity(vector.x * attack.spec.knockback * 4, vector.y * attack.spec.knockback * 4);
controller.audio.play("hit", spec.boss ? 1 : 0.72);
this.time.delayedCall(75, () => { if (enemy.active) enemy.clearTint(); });
this.time.delayedCall(120, () => { if (enemy.active) enemy.setVelocity(0); });
if (controller.state.settings.screenShake && !controller.reducedMotion()) this.cameras.main.shake(spec.boss ? 80 : 45, spec.boss ? 0.004 : 0.0018);
if (enemy.getData("health") <= 0) this.defeatEnemy(enemy);
}
defeatEnemy(enemy) {
const id = enemy.getData("actorId");
const spec = enemy.getData("spec");
enemy.setData("state", "dead");
this.tweens.add({ targets: enemy, alpha: 0, y: enemy.y - 10, duration: controller.reducedMotion() ? 1 : 280, onComplete: () => enemy.destroy() });
if (this.regionId === "village" && controller.state.quests.village_defence.status === "active") controller.setState(State.progressQuest(controller.state, "village_defence", 1));
if (id === "briar_wolf") this.completeBoss(id, "wolf_miniboss");
if (id === "stone_guardian") this.completeBoss(id, "stone_guardian");
if (id === "captain") {
controller.state = State.progressQuest(controller.state, "rally_resistance", 2);
this.completeBoss(id, "rally_resistance");
}
if (id === "malrec") {
this.completeBoss(id, "defeat_malrec");
this.cameras.main.flash(900, 255, 232, 176);
this.time.delayedCall(1100, () => controller.travel("chamber", "door"));
}
if (spec.boss) controller.boss(null);
}
completeBoss(id, quest) {
if (!controller.state.defeatedBosses.includes(id)) controller.state.defeatedBosses.push(id);
controller.setState(State.completeQuest(controller.state, quest), `${Data.ACTORS[id].name} defeated.`);
controller.audio.play("victory");
}
updateEnemies(time) {
this.updateAttack(time);
this.enemies.getChildren().forEach((enemy) => {
if (!enemy.active) return;
const spec = enemy.getData("spec");
const distance = Phaser.Math.Distance.Between(enemy.x, enemy.y, this.player.x, this.player.y);
const homeDistance = Phaser.Math.Distance.Between(enemy.x, enemy.y, enemy.getData("homeX"), enemy.getData("homeY"));
const state = Systems.nextEnemyState(enemy.getData("state"), {
distance, homeDistance, spec, health: enemy.getData("health"), leash: enemy.getData("leash"),
stunned: time < enemy.getData("stunnedUntil"), telegraphDone: time >= enemy.getData("telegraphUntil"),
attackDone: time >= enemy.getData("attackUntil"), cooldownDone: time >= enemy.getData("recoverUntil")
});
if (state !== enemy.getData("state")) this.enterEnemyState(enemy, state, time);
this.runEnemyState(enemy, state, time, distance);
enemy.setDepth(enemy.y + 100);
const phase = Systems.bossPhase(enemy.getData("health"), enemy.getData("maxHealth"), spec.phases || 1);
if (phase !== enemy.getData("phase")) {
enemy.setData("phase", phase);
this.bossPhaseTransition(enemy, phase);
}
if (spec.boss) controller.boss(Data.ACTORS[enemy.getData("actorId")].name, enemy.getData("health"), enemy.getData("maxHealth"), phase);
});
}
enterEnemyState(enemy, state, time) {
enemy.setData("state", state);
const spec = enemy.getData("spec");
if (state === "telegraph") {
enemy.setVelocity(0).setTint(spec.role === "caster" ? 0xc887ff : 0xffc07b);
enemy.setData("telegraphUntil", time + spec.telegraph);
}
if (state === "attack") {
enemy.clearTint();
enemy.setData("attackUntil", time + 180);
this.enemyAttack(enemy);
}
if (state === "recover") {
enemy.setVelocity(0).setTint(0x9aa6b5);
enemy.setData("recoverUntil", time + spec.cooldown);
}
if (state === "patrol") enemy.clearTint();
if (state === "pursue" && spec.role === "ambush") enemy.setAlpha(1);
}
runEnemyState(enemy, state, time) {
const spec = enemy.getData("spec");
if (state === "hurt" || state === "telegraph" || state === "recover" || state === "dead") {
if (state === "telegraph" && time >= enemy.getData("telegraphUntil")) this.enterEnemyState(enemy, "attack", time);
if (state === "attack" && time >= enemy.getData("attackUntil")) this.enterEnemyState(enemy, "recover", time);
return;
}
if (state === "attack") {
if (time >= enemy.getData("attackUntil")) this.enterEnemyState(enemy, "recover", time);
return;
}
if (state === "return") return this.moveEnemy(enemy, enemy.getData("homeX"), enemy.getData("homeY"), spec.speed);
if (state === "pursue") return this.moveEnemy(enemy, this.player.x, this.player.y, spec.speed);
if (state === "patrol" && time >= enemy.getData("nextAction")) {
enemy.setData("nextAction", time + 1100);
const angle = ((enemy.getData("uid").length * 37 + Math.floor(time / 1000)) % 8) * Math.PI / 4;
enemy.setVelocity(Math.cos(angle) * spec.speed * 0.35, Math.sin(angle) * spec.speed * 0.35);
this.time.delayedCall(420, () => enemy.active && enemy.getData("state") === "patrol" && enemy.setVelocity(0));
}
}
moveEnemy(enemy, x, y, speed) {
if (this.pathfinder && this.time.now - enemy.getData("lastPathAt") > 650) {
enemy.setData("lastPathAt", this.time.now);
const sx = Phaser.Math.Clamp(Math.floor(enemy.x / 32), 0, this.world.map.width - 1);
const sy = Phaser.Math.Clamp(Math.floor(enemy.y / 32), 0, this.world.map.height - 1);
const tx = Phaser.Math.Clamp(Math.floor(x / 32), 0, this.world.map.width - 1);
const ty = Phaser.Math.Clamp(Math.floor(y / 32), 0, this.world.map.height - 1);
this.pathfinder.findPath(sx, sy, tx, ty, (path) => { if (enemy.active && path && path.length > 1) enemy.setData("path", path.slice(1)); });
}
const path = enemy.getData("path");
const next = path && path[0];
const target = next ? { x: next.x * 32 + 16, y: next.y * 32 + 16 } : { x, y };
const vector = Systems.normalizedVector(target.x - enemy.x, target.y - enemy.y, speed);
enemy.setVelocity(vector.x, vector.y);
if (next && Phaser.Math.Distance.Between(enemy.x, enemy.y, target.x, target.y) < 10) path.shift();
const facing = Math.abs(vector.x) > Math.abs(vector.y) ? (vector.x > 0 ? "east" : "west") : (vector.y > 0 ? "south" : "north");
enemy.anims.play(`walk-${Data.ACTORS[enemy.getData("actorId")].row}-${facing}`, true);
}
enemyAttack(enemy) {
const spec = enemy.getData("spec");
const id = enemy.getData("actorId");
const phase = enemy.getData("phase");
if (["ranged", "caster", "support"].includes(spec.role) || id === "malrec") {
const count = id === "malrec" ? phase * 2 + 1 : 1;
for (let index = 0; index < count; index += 1) {
const base = Phaser.Math.Angle.Between(enemy.x, enemy.y, this.player.x, this.player.y);
const angle = base + (index - (count - 1) / 2) * 0.22;
this.spawnProjectile(enemy.x, enemy.y, angle, spec.damage, id === "malrec" ? 185 : 145);
}
return;
}
if (!Systems.canDamageThroughWall(this, enemy, this.player)) return;
const distance = Phaser.Math.Distance.Between(enemy.x, enemy.y, this.player.x, this.player.y);
if (distance <= spec.attackRange + 20) this.hurtPlayer(spec.damage, enemy.x, enemy.y);
else {
const vector = Systems.normalizedVector(this.player.x - enemy.x, this.player.y - enemy.y, spec.speed * (spec.role === "fast" ? 2.8 : 2));
enemy.setVelocity(vector.x, vector.y);
}
}
spawnProjectile(x, y, angle, damage, speed) {
const projectile = this.add.circle(x, y, 7, 0x9e5ad1, 0.92).setStrokeStyle(2, 0xe7c5ff).setDepth(8000);
this.physics.add.existing(projectile);
projectile.body.setCircle(7).setVelocity(Math.cos(angle) * speed, Math.sin(angle) * speed);
projectile.setData({ damage, born: this.time.now });
this.projectiles.push(projectile);
}
updateProjectiles(time) {
this.projectiles = this.projectiles.filter((projectile) => {
if (!projectile.active) return false;
if (time - projectile.getData("born") > 3200) { projectile.destroy(); return false; }
if (Phaser.Math.Distance.Between(projectile.x, projectile.y, this.player.x, this.player.y) < 24) {
this.hurtPlayer(projectile.getData("damage"), projectile.x, projectile.y);
projectile.destroy();
return false;
}
return true;
});
}
hurtPlayer(amount, sourceX, sourceY) {
if (this.time.now - this.lastDamagedAt < 700) return;
this.lastDamagedAt = this.time.now;
const reduced = this.blocking ? Math.ceil(amount * 0.3) : Math.max(1, amount - controller.state.defence);
controller.state.health = Math.max(0, controller.state.health - reduced);
const vector = Systems.normalizedVector(this.player.x - sourceX, this.player.y - sourceY, this.blocking ? 80 : 180);
this.player.setVelocity(vector.x, vector.y).setTint(0xff7777).setTintMode(Phaser.TintModes.FILL);
controller.audio.play("damage");
this.time.delayedCall(120, () => this.player.active && this.player.clearTint());
controller.updateHud();
controller.save();
if (controller.state.health <= 0) {
this.player.setVelocity(0);
controller.audio.play("defeat");
controller.ui.gameOver();
}
}
bossPhaseTransition(enemy, phase) {
this.cameras.main.flash(controller.reducedMotion() ? 1 : 250, 105, 44, 126);
enemy.setScale(1 + phase * 0.04);
if (enemy.getData("actorId") === "malrec" && phase >= 2) {
this.world.tileLayers.forEach((layer) => layer.setTint(phase === 3 ? 0x9b719e : 0xb48db4));
controller.status(phase === 3 ? "Lima's voice cuts through the shadow: use the Sun pedestal!" : "Malrec tears open the throne's second seal.");
}
}
updateCameraZones() {
if (controller.inputLock.locked()) return;
const zone = this.world.cameraZones.find((object) => this.player.x >= object.x && this.player.x <= object.x + object.width && this.player.y >= object.y && this.player.y <= object.y + object.height);
const targetZoom = zone ? Number(Maps.properties(zone).zoom) || 1.04 : 1.04;
if (Math.abs(this.cameras.main.zoom - targetZoom) > 0.015) this.cameras.main.zoom += (targetZoom - this.cameras.main.zoom) * 0.04;
const velocity = this.player.body.velocity;
if (velocity.lengthSq() > 16) this.cameras.main.setFollowOffset(-velocity.x * 0.12, -velocity.y * 0.08);
}
beginDialogue(target) {
this.dialogueSnapshot = { zoom: this.cameras.main.zoom, follow: this.player, scrollX: this.cameras.main.scrollX, scrollY: this.cameras.main.scrollY };
this.player.setVelocity(0);
this.enemies.getChildren().forEach((enemy) => enemy.setVelocity(0));
const focus = target || this.player;
this.cameras.main.stopFollow();
this.cameras.main.pan(focus.x, focus.y, controller.reducedMotion() ? 1 : 500, "Sine.easeInOut");
this.cameras.main.zoomTo(1.24, controller.reducedMotion() ? 1 : 500);
controller.root.classList.add(controller.state.settings.blur && controller.state.settings.effectsQuality === "full" ? "is-dialogue-blur" : "is-dialogue-depth");
controller.audio.duck(true);
}
frameDialogue(node) {
if (!this.dialogueSnapshot) return;
const actor = this.findActor(node.speaker);
if (actor) this.cameras.main.pan(actor.x, actor.y, controller.reducedMotion() ? 1 : 280, "Sine.easeInOut");
}
endDialogue() {
controller.root.classList.remove("is-dialogue-blur", "is-dialogue-depth");
controller.audio.duck(false);
if (!this.dialogueSnapshot) return;
this.cameras.main.pan(this.player.x, this.player.y, controller.reducedMotion() ? 1 : 420, "Sine.easeInOut");
this.cameras.main.zoomTo(this.dialogueSnapshot.zoom, controller.reducedMotion() ? 1 : 420);
this.time.delayedCall(controller.reducedMotion() ? 1 : 430, () => this.player.active && this.cameras.main.startFollow(this.player, true, 0.12, 0.12));
this.dialogueSnapshot = null;
}
findActor(id) {
if (id === "player") return this.player;
return this.npcs.getChildren().find((actor) => actor.getData("actorId") === id) ||
this.enemies.getChildren().find((actor) => actor.getData("actorId") === id);
}
cleanup() {
controller.prompt("");
controller.boss(null);
controller.inputLock.clear();
this.projectiles.forEach((projectile) => projectile.destroy());
this.projectiles = [];
if (this.pathfinder) this.pathfinder = null;
controller.audio.duck(false);
}
}
class EndingScene extends Phaser.Scene {
constructor() { super("LimaEnding"); }
create() {
this.add.image(Data.WIDTH / 2, Data.HEIGHT / 2, "lima-title").setDisplaySize(Data.WIDTH, Data.HEIGHT).setTint(0xf3dca3);
controller.ui.ending();
}
}
return [BootScene, PreloadScene, TitleScene, IntroScene, WorldScene, EndingScene];
}
function actorFrame(id, facing, phase) {
const row = Data.ACTORS[id] ? Data.ACTORS[id].row : 0;
const direction = { north: 0, east: 1, south: 2, west: 3 }[facing] || 2;
return row * 16 + direction * 4 + (phase || 0);
}
function createAnimations(scene) {
const rows = Array.from(new Set(Object.values(Data.ACTORS).map((actor) => actor.row)));
["north", "east", "south", "west"].forEach((facing) => rows.forEach((row) => {
const start = row * 16 + ({ north: 0, east: 1, south: 2, west: 3 }[facing] * 4);
if (!scene.anims.exists(`idle-${row}-${facing}`)) scene.anims.create({
key: `idle-${row}-${facing}`, frames: [{ key: "actors-v2", frame: start }], frameRate: 1, repeat: -1
});
if (!scene.anims.exists(`walk-${row}-${facing}`)) scene.anims.create({
key: `walk-${row}-${facing}`, frames: scene.anims.generateFrameNumbers("actors-v2", { start, end: start + 3 }),
frameRate: 9, repeat: -1
});
}));
}
function readable(value) {
return String(value || "").replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
root.PrincessLimaV2Scenes = Object.freeze({ createSceneClasses, actorFrame });
}(typeof globalThis !== "undefined" ? globalThis : this));

View File

@@ -0,0 +1,243 @@
(function (root, factory) {
"use strict";
const data = root.PrincessLimaV2Data || (typeof require === "function" ? require("./princess-lima-v2-data.js") : null);
const api = factory(data);
if (typeof module === "object" && module.exports) module.exports = api;
root.PrincessLimaV2State = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data) {
"use strict";
const VERSION = 2;
const STORAGE_KEY = "zxh_princess_lima_rpg_v2";
const LEGACY_KEY = "zxh_princess_lima_rpg_v1";
const APPEARANCES = Object.freeze(["azure", "ember", "pine"]);
const FACES = Object.freeze(["north", "east", "south", "west"]);
const SAFE_SPAWNS = Object.freeze({
village: { start: [496, 624], square: [496, 400], forestRoad: [496, 80] },
forest: { villagePath: [80, 400], ruinsPath: [784, 656], mountainPath: [784, 112] },
ruins: { forestDoor: [496, 656] }, mountain: { forestTrail: [80, 592], campRoad: [880, 592] },
camp: { mountainRoad: [80, 400], fortressRoad: [880, 400] },
fortressExterior: { campGate: [80, 592], innerGate: [496, 240] },
fortressInterior: { frontHall: [496, 656], throneDoor: [496, 80] },
bossArena: { entrance: [496, 592] }, chamber: { door: [496, 592] }
});
const LEGACY_REGION_BY_CHAPTER = Object.freeze({ 1: "village", 2: "forest", 3: "mountain", 4: "fortressInterior" });
const LEGACY_QUEST_MAP = Object.freeze({
aftermath: "aftermath", village_defence: "village_defence", healer_herbs: "healer_herbs",
find_guide: "find_guide", ruins_light: "ruins_light", wolf_miniboss: "wolf_miniboss",
repair_bridge: "repair_bridge", stone_guardian: "stone_guardian", free_scout: "rally_resistance",
free_prisoners: "free_prisoners", break_wards: "break_wards", defeat_malrec: "defeat_malrec"
});
function validName(value) {
const name = String(value || "").trim().replace(/\s+/g, " ");
return name.length >= 1 && name.length <= 20 ? name : null;
}
function finite(value, fallback, minimum, maximum) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : fallback;
}
function questDefaults() {
return Object.fromEntries(Object.keys(Data.QUESTS).map((id) => [id, { status: "locked", count: 0, rewarded: false }]));
}
function defaultSettings() {
return {
soundEnabled: false, master: 0.8, music: 0.48, effects: 0.75, voice: 0.8,
narrationEnabled: true, subtitles: true, reducedMotion: null, highContrast: false,
effectsQuality: "full", blur: true, particles: true, screenShake: true, lighting: true,
textSpeed: "normal"
};
}
function fresh(name, appearance) {
return {
version: VERSION,
player: { name: validName(name) || "Traveller", appearance: APPEARANCES.includes(appearance) ? appearance : "azure" },
health: 100, maxHealth: 100, attack: 1, defence: 0,
region: "village", position: { spawn: "start", x: 496, y: 624, facing: "north" },
checkpoint: { region: "village", spawn: "start", x: 496, y: 624 },
chapter: 1, story: "arrival", quests: questDefaults(),
inventory: [{ id: "healing_tonic", quantity: 2 }],
equipment: { weapon: null, armour: null, boots: null, charm: null },
flags: {}, solvedPuzzles: [], defeatedBosses: [], openedChests: [], discoveredSecrets: [],
unlockedRoutes: [], dialogueHistory: [], completedCutscenes: [], rescued: false, introSeen: false,
playTimeSeconds: 0, settings: defaultSettings()
};
}
function normalizeInventory(value) {
const quantities = new Map();
(Array.isArray(value) ? value : []).forEach((entry) => {
const item = entry && Data.ITEMS[entry.id];
if (!item) return;
const cap = item.stack || 1;
quantities.set(entry.id, Math.min(cap, (quantities.get(entry.id) || 0) + Math.max(1, Math.floor(Number(entry.quantity) || 1))));
});
return Array.from(quantities, ([id, quantity]) => ({ id, quantity }));
}
function normalizeQuests(value) {
const output = questDefaults();
Object.keys(output).forEach((id) => {
const source = value && value[id];
if (!source) return;
output[id] = {
status: ["locked", "active", "complete"].includes(source.status) ? source.status : "locked",
count: Math.floor(finite(source.count, 0, 0, Data.QUESTS[id].target)),
rewarded: source.rewarded === true
};
});
return output;
}
function safeSpawn(region, spawn) {
const regionId = Data.MAP_IDS.includes(region) ? region : "village";
const entries = SAFE_SPAWNS[regionId];
const spawnId = entries[spawn] ? spawn : Object.keys(entries)[0];
return { region: regionId, spawn: spawnId, x: entries[spawnId][0], y: entries[spawnId][1] };
}
function normalize(candidate) {
if (!candidate || candidate.version !== VERSION) return null;
const name = validName(candidate.player && candidate.player.name);
const appearance = candidate.player && APPEARANCES.includes(candidate.player.appearance) ? candidate.player.appearance : null;
if (!name || !appearance) return null;
const safe = safeSpawn(candidate.region, candidate.position && candidate.position.spawn);
const checkpoint = safeSpawn(candidate.checkpoint && candidate.checkpoint.region, candidate.checkpoint && candidate.checkpoint.spawn);
const inventory = normalizeInventory(candidate.inventory);
const has = (id) => inventory.some((entry) => entry.id === id);
const weapon = has("tempered_sword") ? "tempered_sword" : has("village_sword") ? "village_sword" : null;
const armour = has("reinforced_buckler") ? "reinforced_buckler" : has("buckler") ? "buckler" : null;
const settings = Object.assign(defaultSettings(), candidate.settings || {});
settings.effectsQuality = ["full", "reduced", "minimal"].includes(settings.effectsQuality) ? settings.effectsQuality : "full";
settings.textSpeed = ["slow", "normal", "fast", "instant"].includes(settings.textSpeed) ? settings.textSpeed : "normal";
const maxHealth = finite(candidate.maxHealth, 100, 100, 160);
return {
version: VERSION, player: { name, appearance },
health: finite(candidate.health, maxHealth, 0, maxHealth), maxHealth,
attack: weapon ? Data.ITEMS[weapon].attack : 1, defence: armour ? Data.ITEMS[armour].defence : 0,
region: safe.region,
position: { spawn: safe.spawn, x: finite(candidate.position && candidate.position.x, safe.x, 32, 928), y: finite(candidate.position && candidate.position.y, safe.y, 32, 672), facing: FACES.includes(candidate.position && candidate.position.facing) ? candidate.position.facing : "south" },
checkpoint,
chapter: Math.floor(finite(candidate.chapter, 1, 1, 4)),
story: String(candidate.story || "arrival").slice(0, 64),
quests: normalizeQuests(candidate.quests), inventory,
equipment: { weapon, armour, boots: has("trail_boots") ? "trail_boots" : null, charm: has("forest_charm") ? "forest_charm" : null },
flags: candidate.flags && typeof candidate.flags === "object" && !Array.isArray(candidate.flags)
? Object.fromEntries(Object.entries(candidate.flags).filter(([key, value]) => /^[a-z0-9_-]{1,64}$/.test(key) && typeof value === "boolean").slice(0, 160)) : {},
solvedPuzzles: cleanIds(candidate.solvedPuzzles, 32), defeatedBosses: cleanIds(candidate.defeatedBosses, 16),
openedChests: cleanIds(candidate.openedChests, 64), discoveredSecrets: cleanIds(candidate.discoveredSecrets, 64),
unlockedRoutes: cleanIds(candidate.unlockedRoutes, 64), dialogueHistory: cleanIds(candidate.dialogueHistory, 128),
completedCutscenes: cleanIds(candidate.completedCutscenes, 64),
rescued: candidate.rescued === true || Boolean(candidate.flags && candidate.flags.rescued),
introSeen: candidate.introSeen === true, playTimeSeconds: Math.floor(finite(candidate.playTimeSeconds, 0, 0, 999999)),
settings
};
}
function cleanIds(value, cap) {
return Array.isArray(value) ? Array.from(new Set(value.filter((id) => typeof id === "string" && /^[a-z0-9_-]{1,64}$/.test(id)))).slice(0, cap) : [];
}
function parse(raw) {
try { return normalize(JSON.parse(raw)); } catch (_error) { return null; }
}
function addItem(state, id, amount) {
const next = normalize(state);
if (!next || !Data.ITEMS[id]) return next;
next.inventory = normalizeInventory(next.inventory.concat([{ id, quantity: amount || 1 }]));
return normalize(next);
}
function quantity(state, id) {
const entry = state.inventory.find((item) => item.id === id);
return entry ? entry.quantity : 0;
}
function startQuest(state, id) {
const next = normalize(state);
if (next && Data.QUESTS[id] && next.quests[id].status === "locked") next.quests[id].status = "active";
return normalize(next);
}
function completeQuest(state, id) {
let next = normalize(state);
if (!next || !Data.QUESTS[id]) return next;
next.quests[id] = { status: "complete", count: Data.QUESTS[id].target, rewarded: true };
Data.QUESTS[id].reward.forEach(([item, amount]) => { next = addItem(next, item, amount); });
const consequences = {
aftermath: ["aftermath_complete"], village_defence: ["village_defended", "village_resolved"],
find_guide: ["guide_found"], ruins_light: ["ruins_complete", "ruins_resolved"],
wolf_miniboss: ["briar_defeated", "forest_resolved"], repair_bridge: ["bridge_repaired", "mountain_resolved"],
stone_guardian: ["guardian_defeated"], rally_resistance: ["emblem_found", "camp_resolved"],
disable_defences: ["defences_disabled", "fortressExterior_resolved"], free_prisoners: ["prisoners_freed"],
break_wards: ["wards_broken", "fortressInterior_resolved"], defeat_malrec: ["malrec_defeated", "bossArena_resolved"]
};
(consequences[id] || []).forEach((flag) => { next.flags[flag] = true; });
if (id === "village_defence") next.chapter = 2;
if (id === "stone_guardian") next.chapter = 3;
if (id === "rally_resistance") next.chapter = 4;
return normalize(next);
}
function progressQuest(state, id, amount) {
let next = normalize(state);
if (!next || !Data.QUESTS[id]) return next;
if (next.quests[id].status === "locked") next = startQuest(next, id);
if (next.quests[id].status === "complete") return next;
next.quests[id].count = Math.min(Data.QUESTS[id].target, next.quests[id].count + Math.max(1, amount || 1));
return next.quests[id].count >= Data.QUESTS[id].target ? completeQuest(next, id) : normalize(next);
}
function applyEffects(state, effects) {
let next = normalize(state);
(effects || []).forEach((effect) => {
if (effect.type === "startQuest") next = startQuest(next, effect.id);
if (effect.type === "completeQuest") next = completeQuest(next, effect.id);
if (effect.type === "progressQuest") next = progressQuest(next, effect.id, effect.amount);
if (effect.type === "item") next = addItem(next, effect.id, effect.amount);
if (effect.type === "flag" && /^[a-z0-9_-]+$/.test(effect.id)) next.flags[effect.id] = effect.value !== false;
});
return normalize(next);
}
function useTonic(state) {
let next = normalize(state);
if (!next || next.health >= next.maxHealth || quantity(next, "healing_tonic") < 1) return { state: next, used: false };
next.health = Math.min(next.maxHealth, next.health + Data.ITEMS.healing_tonic.heal);
next.inventory = next.inventory.map((item) => item.id === "healing_tonic" ? { id: item.id, quantity: item.quantity - 1 } : item).filter((item) => item.quantity > 0);
return { state: normalize(next), used: true };
}
function migrateLegacy(candidate) {
if (!candidate || candidate.version !== 1) return null;
const next = fresh(validName(candidate.player && candidate.player.name) || "Traveller",
candidate.player && APPEARANCES.includes(candidate.player.appearance) ? candidate.player.appearance : "azure");
next.chapter = Math.floor(finite(candidate.chapter, 1, 1, 4));
const region = Data.MAP_IDS.includes(candidate.region) ? candidate.region : LEGACY_REGION_BY_CHAPTER[next.chapter];
const safe = safeSpawn(region, Object.keys(SAFE_SPAWNS[region])[0]);
next.region = region;
next.position = Object.assign(safe, { facing: "south" });
next.checkpoint = safe;
Object.entries(LEGACY_QUEST_MAP).forEach(([oldId, newId]) => {
const source = candidate.quests && candidate.quests[oldId];
if (source) next.quests[newId] = {
status: ["locked", "active", "complete"].includes(source.status) ? source.status : "locked",
count: Math.min(Data.QUESTS[newId].target, Math.max(0, Number(source.count) || 0)),
rewarded: source.rewarded === true
};
});
next.inventory = normalizeInventory(candidate.inventory);
next.defeatedBosses = cleanIds(candidate.defeatedBosses, 16);
next.flags = Object.assign({}, candidate.flags || {}, { legacy_imported: true });
next.introSeen = candidate.introSeen !== false;
next.rescued = candidate.rescued === true;
next.settings = Object.assign(defaultSettings(), candidate.settings || {});
return normalize(next);
}
function load(storage) {
const current = parse(storage.getItem(STORAGE_KEY));
if (current) return { state: current, migrated: false };
try {
const legacy = JSON.parse(storage.getItem(LEGACY_KEY) || "null");
const migrated = migrateLegacy(legacy);
if (migrated) {
storage.setItem(STORAGE_KEY, JSON.stringify(migrated));
return { state: migrated, migrated: true };
}
} catch (_error) { /* invalid legacy data is intentionally ignored */ }
return { state: null, migrated: false };
}
return Object.freeze({
VERSION, STORAGE_KEY, LEGACY_KEY, APPEARANCES, FACES, SAFE_SPAWNS,
validName, fresh, normalize, parse, migrateLegacy, load, safeSpawn,
addItem, quantity, startQuest, progressQuest, completeQuest, applyEffects, useTonic
});
}));

View File

@@ -0,0 +1,217 @@
(function (root, factory) {
"use strict";
const data = root.PrincessLimaV2Data || (typeof require === "function" ? require("./princess-lima-v2-data.js") : null);
const state = root.PrincessLimaV2State || (typeof require === "function" ? require("./princess-lima-v2-state.js") : null);
const api = factory(data, state);
if (typeof module === "object" && module.exports) module.exports = api;
root.PrincessLimaV2Systems = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data, State) {
"use strict";
const REQUIRED_MAP_LAYERS = Object.freeze([
"Base terrain", "Terrain variation", "Paths", "Water", "Cliffs and buildings", "Props",
"Objects behind actors", "Actor layer", "Objects above actors", "Shadows", "Lighting",
"Collision", "Interaction zones", "Dialogue triggers", "Quest triggers", "Enemy zones",
"Camera zones", "Scene transitions", "Named safe spawns", "Audio zones", "Optional secrets"
]);
const ATTACKS = Object.freeze({
light1: { windup: 90, active: 100, recovery: 145, reach: 44, width: 36, damage: 1, knockback: 34 },
light2: { windup: 80, active: 110, recovery: 170, reach: 52, width: 44, damage: 1, knockback: 46 },
charged: { windup: 520, active: 150, recovery: 260, reach: 64, width: 58, damage: 3, knockback: 72 }
});
function createInputLock(onChange) {
const reasons = new Map();
return Object.freeze({
acquire(reason) {
const token = Symbol(reason);
reasons.set(token, reason || "unknown");
if (onChange) onChange(true, Array.from(reasons.values()));
return token;
},
release(token) {
reasons.delete(token);
if (onChange) onChange(reasons.size > 0, Array.from(reasons.values()));
},
releaseReason(reason) {
Array.from(reasons).forEach(([token, value]) => { if (value === reason) reasons.delete(token); });
if (onChange) onChange(reasons.size > 0, Array.from(reasons.values()));
},
clear() {
reasons.clear();
if (onChange) onChange(false, []);
},
locked: () => reasons.size > 0,
reasons: () => Array.from(reasons.values())
});
}
function normalizedVector(x, y, speed) {
const length = Math.hypot(x, y);
return length ? { x: x / length * speed, y: y / length * speed } : { x: 0, y: 0 };
}
function approach(current, target, amount) {
return current < target ? Math.min(target, current + amount) : Math.max(target, current - amount);
}
function movementVelocity(currentX, currentY, inputX, inputY, delta, speed, accelerated) {
const target = normalizedVector(inputX, inputY, speed);
const rate = (accelerated ? 780 : 610) * Math.min(0.05, delta / 1000);
return { x: approach(currentX, target.x, rate), y: approach(currentY, target.y, rate) };
}
function attackPhase(elapsed, attack) {
const spec = ATTACKS[attack] || ATTACKS.light1;
if (elapsed < spec.windup) return "windup";
if (elapsed < spec.windup + spec.active) return "active";
if (elapsed < spec.windup + spec.active + spec.recovery) return "recovery";
return "complete";
}
function facingVector(facing) {
return { north: { x: 0, y: -1 }, east: { x: 1, y: 0 }, south: { x: 0, y: 1 }, west: { x: -1, y: 0 } }[facing] || { x: 0, y: 1 };
}
function attackHitbox(facing, x, y, attack) {
const spec = ATTACKS[attack] || ATTACKS.light1;
const vector = facingVector(facing);
const horizontal = vector.x !== 0;
return {
x: x + vector.x * spec.reach - (horizontal ? spec.reach / 2 : spec.width / 2),
y: y + vector.y * spec.reach - (horizontal ? spec.width / 2 : spec.reach / 2),
width: horizontal ? spec.reach : spec.width,
height: horizontal ? spec.width : spec.reach
};
}
function bossPhase(health, maximum, phases) {
const count = Math.max(1, phases || 1);
return Math.min(count, Math.floor((1 - Math.max(0, health) / maximum) * count) + 1);
}
function nextEnemyState(current, context) {
const distance = context.distance;
const homeDistance = context.homeDistance;
const spec = context.spec;
if (context.health <= 0) return "dead";
if (context.stunned) return "hurt";
if (homeDistance > context.leash) return "return";
if (current === "return" && homeDistance > 12) return "return";
if (current === "telegraph" && !context.telegraphDone) return "telegraph";
if (current === "attack" && !context.attackDone) return "attack";
if (current === "recover" && !context.cooldownDone) return "recover";
if (distance <= spec.attackRange && context.cooldownDone) return "telegraph";
if (distance <= spec.awareness) return spec.role === "ambush" && distance > 90 ? "hidden" : "pursue";
return "patrol";
}
function canDamageThroughWall(scene, source, target) {
if (!scene || !scene.collisionLayer || !scene.collisionLayer.getRayCastTiles) return true;
const line = new Phaser.Geom.Line(source.x, source.y, target.x, target.y);
return scene.collisionLayer.getRayCastTiles(line, 4, true).length === 0;
}
function propertyMap(object) {
return Object.fromEntries((object && object.properties || []).map((property) => [property.name, property.value]));
}
function mapLayer(map, name) {
return map.layers.find((layer) => layer.name === name);
}
function validateMap(map, knownMaps) {
const errors = [];
if (!map || map.type !== "map" || map.tilewidth !== Data.TILE || map.tileheight !== Data.TILE) return ["invalid-header"];
REQUIRED_MAP_LAYERS.forEach((name) => { if (!mapLayer(map, name)) errors.push(`missing-layer:${name}`); });
const layerNames = new Set();
map.layers.forEach((layer) => {
if (layerNames.has(layer.name)) errors.push(`duplicate-layer:${layer.name}`);
layerNames.add(layer.name);
});
const ids = new Set();
map.layers.filter((layer) => layer.objects).forEach((layer) => layer.objects.forEach((object) => {
if (ids.has(object.id)) errors.push(`duplicate-object:${object.id}`);
ids.add(object.id);
}));
const collisions = (mapLayer(map, "Collision") || { objects: [] }).objects;
const points = ["Named safe spawns", "Dialogue triggers", "Enemy zones"].flatMap((name) => (mapLayer(map, name) || { objects: [] }).objects);
points.forEach((point) => {
const inside = collisions.some((solid) => point.x >= solid.x && point.x <= solid.x + solid.width && point.y >= solid.y && point.y <= solid.y + solid.height);
if (inside) errors.push(`point-in-collision:${point.name}`);
});
(mapLayer(map, "Scene transitions") || { objects: [] }).objects.forEach((transition) => {
const props = propertyMap(transition);
if (!knownMaps.includes(props.target)) errors.push(`invalid-transition:${transition.name}`);
if (!State.SAFE_SPAWNS[props.target] || !State.SAFE_SPAWNS[props.target][props.spawn]) errors.push(`invalid-spawn:${transition.name}`);
});
validateReachability(map, collisions, errors);
return errors;
}
function validateReachability(map, collisions, errors) {
const width = map.width;
const height = map.height;
const blocked = Array.from({ length: height }, () => Array(width).fill(false));
collisions.forEach((solid) => {
const left = Math.max(0, Math.floor(solid.x / Data.TILE));
const top = Math.max(0, Math.floor(solid.y / Data.TILE));
const right = Math.min(width, Math.ceil((solid.x + solid.width) / Data.TILE));
const bottom = Math.min(height, Math.ceil((solid.y + solid.height) / Data.TILE));
for (let y = top; y < bottom; y += 1) for (let x = left; x < right; x += 1) blocked[y][x] = true;
});
const spawns = (mapLayer(map, "Named safe spawns") || { objects: [] }).objects;
if (!spawns.length) {
errors.push("missing-safe-spawn");
return;
}
const origin = tileFor(spawns[0], width, height);
if (blocked[origin.y][origin.x]) {
errors.push(`unreachable-safe-spawn:${spawns[0].name}`);
return;
}
const reached = new Set([`${origin.x}:${origin.y}`]);
const queue = [origin];
while (queue.length) {
const current = queue.shift();
[[1, 0], [-1, 0], [0, 1], [0, -1]].forEach(([dx, dy]) => {
const x = current.x + dx, y = current.y + dy, key = `${x}:${y}`;
if (x < 0 || y < 0 || x >= width || y >= height || blocked[y][x] || reached.has(key)) return;
reached.add(key);
queue.push({ x, y });
});
}
const required = ["Named safe spawns", "Dialogue triggers", "Quest triggers", "Scene transitions"]
.flatMap((name) => (mapLayer(map, name) || { objects: [] }).objects);
required.forEach((object) => {
const tile = tileFor(object, width, height);
const candidates = [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]
.map(([dx, dy]) => `${tile.x + dx}:${tile.y + dy}`);
if (!candidates.some((key) => reached.has(key))) errors.push(`unreachable-object:${object.name}`);
});
}
function tileFor(object, width, height) {
const x = object.width ? object.x + object.width / 2 : object.x;
const y = object.height ? object.y + object.height / 2 : object.y;
return {
x: Math.max(0, Math.min(width - 1, Math.floor(x / Data.TILE))),
y: Math.max(0, Math.min(height - 1, Math.floor(y / Data.TILE)))
};
}
function dialogueNode(dialogueId, nodeId, state) {
const dialogue = Data.DIALOGUES[dialogueId];
const node = dialogue && dialogue.nodes[nodeId || dialogue.start];
if (!node) return null;
if (node.condition && !state.flags[node.condition]) return null;
return node;
}
function dialogueChoice(dialogueId, nodeId, choiceIndex, state) {
const node = dialogueNode(dialogueId, nodeId, state);
const choice = node && node.choices && node.choices[choiceIndex];
if (!choice) return null;
return { next: choice.next, state: State.applyEffects(state, choice.effects) };
}
function currentObjective(state) {
const active = Object.keys(Data.QUESTS).find((id) => Data.QUESTS[id].main && state.quests[id].status === "active");
if (active) {
const progress = state.quests[active];
return `${Data.QUESTS[active].title} · ${progress.count}/${Data.QUESTS[active].target}`;
}
const next = Object.keys(Data.QUESTS).find((id) => Data.QUESTS[id].main && state.quests[id].status === "locked" && Data.QUESTS[id].chapter <= state.chapter);
return next ? Data.QUESTS[next].title : state.rescued ? "The kingdom is free." : "Explore and speak with the people nearby.";
}
return Object.freeze({
REQUIRED_MAP_LAYERS, ATTACKS, createInputLock, normalizedVector, movementVelocity,
attackPhase, attackHitbox, facingVector, bossPhase, nextEnemyState,
canDamageThroughWall, propertyMap, mapLayer, validateMap, dialogueNode, dialogueChoice,
currentObjective
});
}));

View File

@@ -0,0 +1,239 @@
(function (root) {
"use strict";
const Data = root.PrincessLimaV2Data;
const State = root.PrincessLimaV2State;
const Systems = root.PrincessLimaV2Systems;
function create(container, actions) {
const overlay = container.querySelector("[data-lima-overlay]");
const panel = container.querySelector("[data-lima-panel]");
const heading = container.querySelector("[data-lima-panel-title]");
const body = container.querySelector("[data-lima-panel-body]");
const closeButton = container.querySelector("[data-lima-panel-close]");
const live = container.querySelector("[data-lima-screenreader]");
let previousFocus = null;
let dialogue = null;
let typingTimer = null;
let displayed = "";
let fullText = "";
function portraitPosition(speaker, expression) {
const index = Data.PORTRAITS[speaker] && Data.PORTRAITS[speaker][expression] !== undefined
? Data.PORTRAITS[speaker][expression] : 14;
return `${(index % 4) * 33.333}% ${Math.floor(index / 4) * 33.333}%`;
}
function open(kind, title, html, closable = true) {
previousFocus = document.activeElement;
overlay.hidden = false;
panel.dataset.kind = kind;
heading.textContent = title;
body.innerHTML = html;
closeButton.hidden = !closable;
requestAnimationFrame(() => (body.querySelector("button, input, [tabindex]") || closeButton).focus());
}
function close() {
if (dialogue) return advance();
overlay.hidden = true;
panel.dataset.kind = "";
body.innerHTML = "";
actions.lock(false, "menu");
if (previousFocus && previousFocus.focus) previousFocus.focus();
else actions.focusGame();
}
function stopTyping() {
if (typingTimer) clearInterval(typingTimer);
typingTimer = null;
}
function typeLine(target, text) {
stopTyping();
fullText = text;
displayed = "";
const settings = actions.getState().settings;
const speed = { slow: 42, normal: 27, fast: 12, instant: 0 }[settings.textSpeed];
if (!speed || settings.reducedMotion) {
target.textContent = text;
displayed = text;
return;
}
let index = 0;
typingTimer = setInterval(() => {
index += 1;
displayed = text.slice(0, index);
target.textContent = displayed;
if (index >= text.length) stopTyping();
}, speed);
}
function startDialogue(id, options = {}) {
const definition = Data.DIALOGUES[id];
if (!definition) return;
actions.lock(true, "dialogue");
actions.beginDialogue(id, options.target);
dialogue = { id, node: definition.start, history: [], done: options.done || null, essential: definition.essential };
renderDialogue();
}
function renderDialogue() {
const definition = Data.DIALOGUES[dialogue.id];
const node = Systems.dialogueNode(dialogue.id, dialogue.node, actions.getState());
if (!node) return finishDialogue();
const speaker = Data.ACTORS[node.speaker] ? Data.ACTORS[node.speaker].name : node.speaker;
dialogue.history.push({ speaker, text: node.text });
live.textContent = `${speaker}: ${node.text}`;
open("dialogue", speaker, `
<div class="lima-cinematic-dialogue" data-expression="${escapeHtml(node.expression || "neutral")}">
<div class="lima-cinematic-dialogue__portrait" role="img" aria-label="${escapeHtml(speaker)}, ${escapeHtml(node.expression || "neutral")}"
style="background-position:${portraitPosition(node.speaker, node.expression || "neutral")}"></div>
<div class="lima-cinematic-dialogue__copy">
<p class="lima-cinematic-dialogue__speaker">${escapeHtml(speaker)} <span>${escapeHtml(node.expression || "")}</span></p>
<p class="lima-cinematic-dialogue__text" data-dialogue-line></p>
<div class="lima-cinematic-dialogue__choices" data-dialogue-choices></div>
<div class="lima-cinematic-dialogue__controls">
<button type="button" class="lima-primary" data-dialogue-advance>${node.choices ? "Choose" : node.next ? "Continue" : "Finish"}</button>
<button type="button" data-dialogue-history>History</button>
${!dialogue.essential && actions.getState().dialogueHistory.includes(dialogue.id) ? '<button type="button" data-dialogue-skip>Skip viewed scene</button>' : ""}
</div>
</div>
</div>`, false);
const line = body.querySelector("[data-dialogue-line]");
typeLine(line, node.text);
const choices = body.querySelector("[data-dialogue-choices]");
if (node.choices) {
body.querySelector("[data-dialogue-advance]").hidden = true;
choices.innerHTML = node.choices.map((choice, index) =>
`<button type="button" data-choice="${index}"><span>${index + 1}</span>${escapeHtml(choice.text)}</button>`).join("");
choices.querySelectorAll("[data-choice]").forEach((button) => button.addEventListener("click", () => choose(Number(button.dataset.choice))));
}
body.querySelector("[data-dialogue-advance]").addEventListener("click", advance);
body.querySelector("[data-dialogue-history]").addEventListener("click", showHistory);
const skip = body.querySelector("[data-dialogue-skip]");
if (skip) skip.addEventListener("click", finishDialogue);
actions.frameDialogue(node);
}
function advance() {
if (!dialogue) return;
if (displayed !== fullText) {
stopTyping();
const line = body.querySelector("[data-dialogue-line]");
if (line) line.textContent = fullText;
displayed = fullText;
return;
}
const node = Systems.dialogueNode(dialogue.id, dialogue.node, actions.getState());
if (!node || node.choices) return;
actions.applyEffects(node.effects);
if (node.next) {
dialogue.node = node.next;
renderDialogue();
} else finishDialogue();
}
function choose(index) {
const result = Systems.dialogueChoice(dialogue.id, dialogue.node, index, actions.getState());
if (!result) return;
actions.setState(result.state);
dialogue.node = result.next;
renderDialogue();
}
function finishDialogue() {
stopTyping();
const done = dialogue && dialogue.done;
if (dialogue) actions.recordDialogue(dialogue.id);
dialogue = null;
overlay.hidden = true;
body.innerHTML = "";
live.textContent = "";
actions.endDialogue();
actions.lock(false, "dialogue");
if (done) done();
actions.focusGame();
}
function showHistory() {
const history = dialogue.history.map((entry) => `<li><strong>${escapeHtml(entry.speaker)}</strong><p>${escapeHtml(entry.text)}</p></li>`).join("");
const dialog = document.createElement("dialog");
dialog.className = "lima-history";
dialog.innerHTML = `<h3>Conversation history</h3><ol>${history}</ol><button type="button">Return</button>`;
container.appendChild(dialog);
dialog.querySelector("button").addEventListener("click", () => { dialog.close(); dialog.remove(); });
dialog.showModal();
}
function openPanel(kind) {
const state = actions.getState();
actions.lock(true, "menu");
if (kind === "credits") {
open("credits", "Credits", `
<p class="lima-panel-lead">A locally hosted storybook RPG built for this site.</p>
<ul class="lima-list">
<li><strong>Story & world</strong><span>Princess Lima</span></li>
<li><strong>Engine</strong><span>Phaser 4.1.0</span></li>
<li><strong>Pathfinding</strong><span>EasyStar.js 0.4.4</span></li>
<li><strong>Portrait source art</strong><span>OpenAI ImageGen, edited and atlas-packed locally</span></li>
</ul>
<p>All game data, maps, images, code, and audio are served from this website.</p>`);
} else if (kind === "quests") {
const quests = Object.entries(Data.QUESTS).filter(([id]) => state.quests[id].status !== "locked").map(([id, quest]) => {
const progress = state.quests[id];
return `<li class="${progress.status}"><strong>${quest.main ? "Main · " : "Optional · "}${escapeHtml(quest.title)}</strong><span>${progress.status === "complete" ? "Complete" : `${progress.count}/${quest.target}`}</span><small>${escapeHtml(Data.MAP_NAMES[quest.region])}</small></li>`;
}).join("");
open("quests", "Quest Chronicle", `<p class="lima-panel-lead">${escapeHtml(Systems.currentObjective(state))}</p><ul class="lima-list">${quests || "<li>No quests have begun.</li>"}</ul>`);
} else if (kind === "inventory") {
const items = state.inventory.map((entry) => `<li><strong>${escapeHtml(Data.ITEMS[entry.id].name)}</strong><span>×${entry.quantity}</span></li>`).join("");
open("inventory", "Traveller's Satchel", `<ul class="lima-list">${items || "<li>The satchel is empty.</li>"}</ul><button type="button" class="lima-primary" data-use-tonic>Use Healing Tonic</button>`);
body.querySelector("[data-use-tonic]").addEventListener("click", () => { actions.useTonic(); openPanel("inventory"); });
} else if (kind === "settings") {
const checked = (key) => state.settings[key] ? "checked" : "";
open("settings", "Accessibility & Effects", `
<div class="lima-settings-grid">
<label><input type="checkbox" data-setting="soundEnabled" ${checked("soundEnabled")}> Sound</label>
<label><input type="checkbox" data-setting="subtitles" ${checked("subtitles")}> Subtitles</label>
<label><input type="checkbox" data-setting="narrationEnabled" ${checked("narrationEnabled")}> Narration</label>
<label><input type="checkbox" data-setting="reducedMotion" ${checked("reducedMotion")}> Reduced motion</label>
<label><input type="checkbox" data-setting="highContrast" ${checked("highContrast")}> High contrast</label>
<label><input type="checkbox" data-setting="blur" ${checked("blur")}> Dialogue blur</label>
<label><input type="checkbox" data-setting="particles" ${checked("particles")}> Particles</label>
<label><input type="checkbox" data-setting="screenShake" ${checked("screenShake")}> Screen shake</label>
<label><input type="checkbox" data-setting="lighting" ${checked("lighting")}> Lighting</label>
<label>Effects quality <select data-setting="effectsQuality"><option value="full">Full</option><option value="reduced">Reduced</option><option value="minimal">Minimal</option></select></label>
<label>Text speed <select data-setting="textSpeed"><option value="slow">Slow</option><option value="normal">Normal</option><option value="fast">Fast</option><option value="instant">Instant</option></select></label>
</div>`);
body.querySelector('[data-setting="effectsQuality"]').value = state.settings.effectsQuality;
body.querySelector('[data-setting="textSpeed"]').value = state.settings.textSpeed;
body.querySelectorAll("[data-setting]").forEach((control) => control.addEventListener("change", () =>
actions.setting(control.dataset.setting, control.type === "checkbox" ? control.checked : control.value)));
} else {
open("pause", "Roadside Menu", `
<p class="lima-panel-lead">${escapeHtml(Data.MAP_NAMES[state.region])}</p>
<div class="lima-menu-stack">
<button type="button" class="lima-primary" data-close-panel>Resume</button>
<button type="button" data-open-panel="quests">Quest Chronicle</button>
<button type="button" data-open-panel="inventory">Inventory</button>
<button type="button" data-open-panel="settings">Settings</button>
<button type="button" data-replay-intro>Replay Introduction</button>
</div>`);
body.querySelector("[data-close-panel]").addEventListener("click", close);
body.querySelectorAll("[data-open-panel]").forEach((button) => button.addEventListener("click", () => openPanel(button.dataset.openPanel)));
body.querySelector("[data-replay-intro]").addEventListener("click", actions.replayIntro);
}
}
function gameOver() {
actions.lock(true, "defeat");
open("gameover", "The Road Grows Quiet", `<p>You wake at the last safe fire. Your story progress and important items remain.</p><button type="button" class="lima-primary" data-respawn>Rise Again</button>`, false);
body.querySelector("[data-respawn]").addEventListener("click", () => { overlay.hidden = true; actions.lock(false, "defeat"); actions.respawn(); });
}
function ending() {
actions.lock(true, "ending");
const allies = ["promised_village", "defences_disabled", "prisoners_freed"].filter((flag) => actions.getState().flags[flag]).length;
open("ending", "Dawn Over Lima", `<p>At dawn the roads reopen. Village bells answer the resistance fires, and the fortress windows shine with ordinary sunlight.</p><p>${allies >= 2 ? "The people you helped arrive together; no one returns home alone." : "The kingdom begins the slower work of finding one another again."}</p><p class="lima-ending-mark">Princess Lima is free.</p><button type="button" class="lima-primary" data-ending-menu>Return to title</button>`, false);
body.querySelector("[data-ending-menu]").addEventListener("click", actions.returnToMenu);
}
closeButton.addEventListener("click", close);
overlay.addEventListener("click", (event) => { if (event.target === overlay && !dialogue) close(); });
document.addEventListener("keydown", (event) => {
if (dialogue && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); advance(); }
if (!dialogue && !overlay.hidden && event.key === "Escape") { event.preventDefault(); close(); }
});
return Object.freeze({ openPanel, startDialogue, gameOver, ending, close, finishDialogue });
}
function escapeHtml(value) {
return String(value || "").replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[character]));
}
root.PrincessLimaV2UI = Object.freeze({ create });
}(typeof globalThis !== "undefined" ? globalThis : this));

File diff suppressed because one or more lines are too long

View File

@@ -105,6 +105,7 @@ button:disabled {
max-width: none;
max-height: none;
image-rendering: pixelated;
transition: filter 320ms ease, transform 320ms ease;
}
.lima-rpg__loading,
@@ -231,7 +232,9 @@ button:disabled {
height: 64px;
border: 2px solid #d8b66d;
border-radius: 4px;
background-size: 256px 256px;
background-image: url("/assets/images/play/princess-lima/portrait-atlas-v2.png");
background-position: 100% 100%;
background-size: 400% 400%;
}
.lima-hud__equipment {
@@ -423,10 +426,16 @@ button:disabled {
padding: 0.75rem;
}
.lima-list li.is-complete strong::before {
.lima-list li.is-complete strong::before,
.lima-list li.complete strong::before {
content: "✓ ";
}
.lima-list small {
grid-column: 1 / -1;
color: var(--lima-muted);
}
.lima-list p {
grid-column: 1 / -1;
margin: 0;
@@ -652,6 +661,200 @@ button:disabled {
height: 100vh;
}
.lima-screenreader {
position: fixed;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.lima-rpg.is-dialogue-blur .lima-rpg__game canvas {
filter: blur(4px) saturate(0.58) brightness(0.62);
transform: scale(1.015);
}
.lima-rpg.is-dialogue-depth .lima-rpg__game canvas {
filter: saturate(0.68) brightness(0.55);
}
.lima-panel[data-kind="dialogue"] {
position: absolute;
right: clamp(1rem, 4vw, 4rem);
bottom: clamp(1rem, 4vh, 3rem);
left: max(288px, clamp(1rem, 4vw, 4rem));
width: auto;
max-height: none;
overflow: visible;
border-color: #e4c477;
background:
linear-gradient(135deg, rgb(23 25 39 / 0.98), rgb(8 12 23 / 0.98)),
url("/assets/images/play/princess-lima/world-tiles.png");
box-shadow: 0 18px 70px rgb(0 0 0 / 0.82), inset 0 0 0 1px rgb(255 239 190 / 0.16);
}
.lima-panel[data-kind="dialogue"] > header {
display: none;
}
.lima-panel[data-kind="dialogue"] .lima-panel__body {
padding: 0;
}
.lima-cinematic-dialogue {
display: grid;
grid-template-columns: minmax(170px, 26%) 1fr;
min-height: 236px;
overflow: hidden;
}
.lima-cinematic-dialogue__portrait {
min-height: 236px;
border-right: 2px solid #a8844d;
background-image:
linear-gradient(0deg, rgb(7 10 18 / 0.3), transparent 45%),
url("/assets/images/play/princess-lima/portrait-atlas-v2.png");
background-repeat: no-repeat;
background-size: 400% 400%;
image-rendering: auto;
animation: lima-portrait-arrive 320ms ease-out both;
}
.lima-cinematic-dialogue__copy {
display: flex;
flex-direction: column;
padding: 1.1rem 1.25rem;
}
.lima-cinematic-dialogue__speaker {
margin: 0 0 0.6rem;
color: #f6d785;
font-family: Georgia, serif;
font-size: clamp(1.25rem, 2.2vw, 1.8rem);
font-weight: 800;
}
.lima-cinematic-dialogue__speaker span {
margin-left: 0.5rem;
color: #b8c1ce;
font-family: Inter, system-ui, sans-serif;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.lima-cinematic-dialogue__text {
flex: 1;
margin: 0;
color: #fff8e7;
font-family: Georgia, serif;
font-size: clamp(1rem, 1.7vw, 1.28rem);
line-height: 1.55;
}
.lima-cinematic-dialogue__choices {
display: grid;
gap: 0.48rem;
margin: 0.8rem 0;
}
.lima-cinematic-dialogue__choices button {
display: grid;
grid-template-columns: 2rem 1fr;
align-items: center;
border-color: #7d94aa;
background: rgb(24 41 59 / 0.82);
text-align: left;
}
.lima-cinematic-dialogue__choices button span {
display: grid;
width: 1.5rem;
height: 1.5rem;
place-items: center;
border: 1px solid #e3bf6d;
border-radius: 50%;
color: #f4d27e;
}
.lima-cinematic-dialogue__controls {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.lima-history {
width: min(44rem, calc(100% - 2rem));
max-height: 78dvh;
overflow: auto;
border: 3px solid var(--lima-border);
background: #0b111e;
color: var(--lima-text);
padding: 1rem;
}
.lima-history::backdrop {
background: rgb(2 4 8 / 0.8);
}
.lima-history li {
margin-bottom: 0.75rem;
border-left: 3px solid #9e7c46;
padding-left: 0.75rem;
}
.lima-history p {
margin: 0.2rem 0;
}
.lima-settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.lima-settings-grid label {
display: flex;
min-height: 48px;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 1px solid #46546a;
background: #0b1220;
padding: 0.65rem;
}
.lima-settings-grid input[type="checkbox"] {
width: 24px;
height: 24px;
}
.lima-ending-mark {
color: #f4d37e;
font-family: Georgia, serif;
font-size: 1.7rem;
text-align: center;
}
.lima-rpg[data-saved="true"]::after {
content: "Journey saved";
position: absolute;
z-index: 80;
top: 1rem;
right: 1rem;
border: 1px solid #d6b466;
background: rgb(6 10 18 / 0.9);
color: #fff2cd;
padding: 0.48rem 0.7rem;
}
@keyframes lima-portrait-arrive {
from { opacity: 0; transform: translateX(-14px); }
to { opacity: 1; transform: translateX(0); }
}
@media (max-width: 820px) {
.lima-hud {
width: 220px;
@@ -677,6 +880,18 @@ button:disabled {
display: none;
}
.lima-panel[data-kind="dialogue"] {
left: 232px;
}
.lima-cinematic-dialogue {
grid-template-columns: 130px 1fr;
}
.lima-settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-height: 560px) and (orientation: landscape) {