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) {

View File

@@ -810,3 +810,9 @@ at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/build-logs/gite
2026-07-30T12:48:58.6913478+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-30T12:48:58.7600936+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-30T12:48:59.0618648+01:00 [INFO] Sent authoring server test notification.
2026-07-30T13:59:08.6928821+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-07-30T13:59:08.7003150+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-07-30T13:59:09.5187278+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-07-30T13:59:09.5410036+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-07-30T13:59:09.6127470+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-07-30T13:59:09.8840005+01:00 [INFO] Sent authoring server test notification.

115
docs/princess-lima-map-authoring.md Normal file → Executable file
View File

@@ -1,58 +1,87 @@
# Princess Lima map authoring
# Princess Lima v2 map authoring
## Framework
## Runtime contract
Use [Tiled](https://www.mapeditor.org/) as the visual authoring tool and Phaser
as the runtime. Every region is a 720×720 orthogonal map whose locked image
layer is the corresponding frame from `regional-style-atlas.png`.
Princess Lima uses committed Tiled JSON maps rendered by Phaser 4.1.0. The
regional style atlas is reference art only and must never be used as runtime
terrain or collision geometry. Maps use orthogonal 32-pixel tiles and may vary
in width and height.
The runtime contract mirrors Tiled object groups in
`PrincessLimaData.MAP_LAYERS`. Every map has these named layers:
The current source maps live in `assets/maps/princess-lima/`. Rebuild them with:
1. `Collision`
2. `Dynamic Collision`
3. `Exits`
4. `NPCs`
5. `Enemies`
6. `Puzzles`
7. `Items`
```sh
node tools/generate-princess-lima-maps.mjs
```
Do not paint collision or interaction geometry into the finished artwork.
Production collision objects are invisible.
The generator is deterministic so authored content can be reviewed as ordinary
JSON. It creates the nine production regions and a Tiled tileset reference.
Hand editing in Tiled is supported as long as the schema below is preserved.
## Collision rules
Every map has these uniquely named layers:
- Trace only terrain that is visibly solid: buildings, walls, cliff faces,
deep water, fences, furniture, and the four outer edges.
1. Base terrain
2. Terrain variation
3. Paths
4. Water
5. Cliffs and buildings
6. Props
7. Objects behind actors
8. Actor layer
9. Objects above actors
10. Shadows
11. Lighting
12. Collision
13. Interaction zones
14. Dialogue triggers
15. Quest triggers
16. Enemy zones
17. Camera zones
18. Scene transitions
19. Named safe spawns
20. Audio zones
21. Optional secrets
Object IDs must be unique. Scene transitions require valid `target` and `spawn`
properties. Enemy objects require a validated `enemy` ID. Dialogue objects use
a validated `dialogue` ID. Safe spawns are named points and may not overlap
collision.
## Collision and navigation
- Trace only visibly solid buildings, walls, cliffs, water, fences, furniture,
and map edges.
- Keep paths, doors, bridges, stairs, and floor tiles walkable.
- Use rectangles while the game uses Phaser Arcade Physics. A future switch to
Matter Physics can use Tiled polygon objects without approximating them.
- Every dynamic blocker needs an `opensWith` property matching a validated
route ID.
- Never put a puzzle control, item, actor, or safe spawn inside collision.
- Keep enemies away from puzzle controls and safe spawns.
- Use rectangles while the runtime uses Arcade Physics.
- Never place a required objective, transition, actor, or safe spawn inside
collision.
- Keep encounter zones away from chapter-safe spawns.
- Preserve a collision-free route between each required spawn and objective in
every reachable state variant.
- Route blockers must name the flag or quest consequence that opens them.
## Interaction rules
EasyStar receives a navigation grid derived from the collision layer at runtime.
It is pathfinding only; Phaser remains responsible for movement and physics.
- Exits, NPCs, enemies, puzzles, and items belong on their named object layer.
- Puzzle and item labels appear only within 92 pixels of the player.
- The interaction radius is 54 pixels; exit portals use 70 pixels.
- Every mechanism must be reachable before the state change it triggers.
- Interaction labels must use the object name rather than an unexplained shape.
## Interactions and state variants
Interaction objects declare a short `action` or `observation`. Reusable actions
include `puzzle`, `chest`, `free-prisoner`, `disable-defence`,
`route-choice`, and `boss-mechanic`. Secrets use the Optional secrets layer.
Resolved variants are driven by `<region>_resolved` story flags and must leave
all required routes reachable.
## Verification
On localhost, use:
The Node map tests validate layers, IDs, transitions, safe spawns, navigation
reachability, and content references. During manual authoring, also:
```text
/play/rpg.html?region=mountain&spawn=bridgeControls
/play/rpg.html?region=mountain&spawn=bridgeControls&collisionDebug=1
```
1. Inspect the clean render and collision-debug render.
2. Walk every boundary and transition in both directions.
3. Exercise locked and resolved routes.
4. Check all named spawns after save/reload.
5. Repeat transitions while watching the browser console and memory.
6. Confirm production URLs never expose debug geometry.
Before publishing:
1. Compare the clean rendered map with its locked source image.
2. Enable collision debugging and check every visible boundary.
3. Walk each route in both its locked and unlocked states.
4. Confirm all object-layer points pass the collision-safety tests.
5. Confirm ordinary production URLs show no collision geometry.
Use `?collisionDebug=1` on localhost for Arcade Physics bounds. The v2 save
always restores at a named chapter-safe spawn rather than trusting legacy
coordinates.

View File

@@ -0,0 +1,72 @@
# Princess Lima v2 implementation
## Architecture
The rebuild keeps the existing four-chapter story, cast, quest consequences,
keyboard-first accessibility, Org publishing entry point, and v1 save. It
replaces the static regional backgrounds and single-scene prototype with:
- Boot, preload, title/setup, cinematic intro, region, boss, and ending flows.
- Versioned data contracts for maps, actors, enemies, items, quests, dialogue,
cutscenes, assets, interactions, and saves.
- Focused map, state, input-lock, combat, AI, dialogue, camera, audio, save, and
accessibility controllers.
- Nine committed Tiled JSON regions with collision, navigation, interaction,
transition, spawn, camera, audio, secret, and state layers.
- EasyStar.js 0.4.4 for obstacle-aware paths; Phaser remains the renderer,
input, physics, animation, timeline, camera, and effects engine.
Only `/play/rpg.html` loads the game runtime and its locally hosted assets.
## Art direction
The final field style uses 32px terrain, 48×64 four-direction actors, nearest
neighbour scaling, dark restrained outlines, warm upper-left lighting, compact
shadows, parchment and aged-metal DOM UI, serif display faces, and sans-serif
body copy. Regional palettes distinguish the village, woods, ruins, mountain,
camp, fortress, throne, and dawn chamber.
`tools/generate-princess-lima-v2-assets.py` normalizes the field atlas, effects,
palette, transparency, and tile source into deterministic PNG assets. The
portrait contact sheet began as original ImageGen source art and was inspected,
cropped through CSS atlas coordinates, and kept free of generated text. It is
presentation art only and is never used as collision geometry.
## Play and story systems
The complete opening-to-rescue arc includes:
- Broken Village production slice, eight enemy roles, four multi-phase bosses,
optional discoveries, puzzles, prisoners, fortress defences, and three
approach consequences.
- Acceleration movement, normalized diagonals, directional animation, two-hit
combo, charged strike, block, active-frame hitboxes, one hit per swing,
knockback, recovery, particles, and layered local audio.
- Data-driven conversations with expressions, choices, conditional effects,
history, typing controls, camera framing, background focus treatment, and
reliable input/focus restoration.
- A skippable, replayable, pausable, narrated introduction with subtitles,
title reveal, muted play, and reduced-motion timing.
- Resolved-state flags for all nine regions and an ending that reflects rescued
allies and preparation choices.
## Saves and accessibility
New progress uses `zxh_princess_lima_rpg_v2`. The v1 key remains untouched.
Migration validates identity, appearance, chapter, quests, inventory,
equipment, bosses, intro preference, and settings; rejects unknown IDs;
reconstructs derived statistics; and restores to a named safe spawn.
Menus and dialogue remain DOM-backed. They provide keyboard focus, an
`aria-live` spoken-line mirror, history, instant completion, choice navigation,
subtitles, narration toggle, text speed, high contrast, reduced motion,
particles, lighting, shake, blur, and effects-quality controls. Canvas and
blur-disabled modes retain dimming and actor depth separation.
## Validation
Automated coverage lives in `tests/princess-lima-*.test.cjs` and checks schemas,
map contracts and reachability, dialogue effects, cutscene cleanup, input
restoration, quests, movement/combat phases, hitbox rules, AI transitions,
boss phases, v1 migration, invalid-data recovery, and accessibility settings.
Publishing remains covered by the Emacs and clean Org-build suites.

View File

@@ -131,10 +131,10 @@ Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> \
"<meta charset=\"utf-8\" />\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\" />\n"
"<meta name=\"theme-color\" content=\"#080a12\" />\n"
"<meta name=\"description\" content=\"A standalone fantasy RPG about rescuing Princess Lima.\" />\n"
"<meta name=\"description\" content=\"A storybook pixel fantasy RPG about helping a wounded kingdom and rescuing Princess Lima.\" />\n"
"<title>Rescue Princess Lima</title>\n"
"<link rel=\"icon\" href=\"/assets/icons/icons8-film-tape-100.png\" />\n"
"<link rel=\"stylesheet\" href=\"/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.1.2\" />\n"
"<link rel=\"stylesheet\" href=\"/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-2.0.0\" />\n"
"</head>\n<body class=\"princess-lima-page\">\n"
body
"\n</body>\n</html>\n"))

View File

@@ -16,9 +16,9 @@
</section>
<section class="lima-main-menu" data-lima-menu hidden aria-labelledby="lima-game-title">
<p class="lima-main-menu__eyebrow">A four-chapter fantasy adventure</p>
<p class="lima-main-menu__eyebrow">A storybook fantasy adventure</p>
<h1 id="lima-game-title">Rescue<br />Princess Lima</h1>
<p class="lima-main-menu__tagline">The princess has been taken beyond the mountains. Help a wounded village, cross the wild roads, and enter the Fortress of Shadows.</p>
<p class="lima-main-menu__tagline">A kingdom remembers the light. Help its people rise, cross the wild roads, and bring Princess Lima home.</p>
<div class="lima-menu-stack">
<button type="button" class="lima-primary" data-lima-new>New Game</button>
<button type="button" data-lima-continue disabled>Continue</button>
@@ -41,24 +41,11 @@
<label><input type="radio" name="appearance" value="pine" /> Pine</label>
</fieldset>
<p class="lima-setup__error" data-lima-setup-error aria-live="polite"></p>
<button type="submit" class="lima-primary">Begin Chapter One</button>
<button type="submit" class="lima-primary">Begin the Journey</button>
<button type="button" data-lima-setup-cancel>Back</button>
</form>
</section>
<section class="lima-intro" data-lima-intro hidden tabindex="-1" aria-label="Opening cinematic">
<div class="lima-intro__picture" data-lima-intro-picture data-panel="0" role="img" aria-label="Princess Lima and the fall of the northern kingdom"></div>
<div class="lima-intro__vignette" aria-hidden="true"></div>
<div class="lima-intro__subtitle" data-lima-intro-subtitle aria-live="polite"></div>
<div class="lima-intro__progress" role="progressbar" aria-label="Introduction progress" aria-valuemin="1" aria-valuemax="5" aria-valuenow="1"><i data-lima-intro-progress></i></div>
<div class="lima-intro__controls">
<button type="button" data-lima-intro-pause aria-pressed="false">Pause</button>
<button type="button" data-lima-intro-mute aria-pressed="true">Enable sound</button>
<button type="button" data-lima-intro-skip>Skip introduction</button>
<span>Hold Escape to skip · P to pause</span>
</div>
</section>
<section class="lima-hud" data-lima-hud hidden aria-label="Game status">
<header class="lima-hud__identity">
<div class="lima-hud__portrait lima-sprite" aria-hidden="true"></div>
@@ -84,14 +71,16 @@
<button type="button" data-lima-pause>Menu <kbd>M</kbd></button>
<a class="lima-button-link" href="/">Exit to Website</a>
</div>
<p class="lima-hud__keys">Move WASD / arrows · Attack Space · Interact E / Enter · Item Q · Cycle Tab · Fullscreen F</p>
<p class="lima-hud__keys">Move WASD / arrows · Tap Space combo · Hold Space charge · Block Shift · Interact E · Item Q · Fullscreen F</p>
</section>
<div class="lima-status-stack" data-lima-status-stack hidden>
<p class="lima-prompt" data-lima-prompt>Move with WASD or arrow keys.</p>
<p class="lima-prompt" data-lima-prompt>Explore · speak · protect · discover</p>
<p class="lima-status" data-lima-status aria-live="polite">Audio begins muted. Press Sound to enable it.</p>
</div>
<p class="lima-screenreader" data-lima-screenreader aria-live="assertive" aria-atomic="true"></p>
<div class="lima-overlay" data-lima-overlay hidden>
<section class="lima-panel" data-lima-panel role="dialog" aria-modal="true" aria-labelledby="lima-panel-title">
<header>
@@ -107,16 +96,16 @@
</noscript>
</main>
<link rel="stylesheet" href="/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-1.1.2" />
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-data.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-state.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-systems.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-audio.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-ui.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-maps.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-intro.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-scenes.js?v=princess-lima-1.1.2" defer></script>
<script src="/assets/scripts/pages/princess-lima-game.js?v=princess-lima-1.1.2" defer></script>
<link rel="stylesheet" href="/assets/styles/pages/princess-lima-rpg.css?v=princess-lima-2.0.0" />
<script src="/assets/scripts/vendor/phaser-4.1.0.min.js?v=princess-lima-2.0.0" defer></script>
<script src="/assets/scripts/vendor/easystar-0.4.4.min.js?v=princess-lima-2.0.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-v2-data.js?v=princess-lima-2.0.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-v2-state.js?v=princess-lima-2.0.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-v2-systems.js?v=princess-lima-2.0.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-audio.js?v=princess-lima-2.0.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-v2-ui.js?v=princess-lima-2.0.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-v2-maps.js?v=princess-lima-2.0.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-v2-scenes.js?v=princess-lima-2.0.0" defer></script>
<script src="/assets/scripts/pages/princess-lima-v2-game.js?v=princess-lima-2.0.0" defer></script>
<!-- RPG-STANDALONE-END -->
#+END_EXPORT

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">30-07-2026 12:42</span>@@
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">30-07-2026 14:54</span>@@
- [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@

View File

@@ -112,9 +112,9 @@ flowchart TD
n43 --> n53
n54["Tag: insights"]
n43 --> n54
n55["Tag: reading"]
n55["Tag: emacs"]
n43 --> n55
n56["Tag: emacs"]
n56["Tag: reading"]
n43 --> n56
n57["Tag: maths"]
n43 --> n57
@@ -228,8 +228,8 @@ flowchart TD
click n52 "tags/life.html" "Tag: life"
click n53 "tags/education.html" "Tag: education"
click n54 "tags/insights.html" "Tag: insights"
click n55 "tags/reading.html" "Tag: reading"
click n56 "tags/emacs.html" "Tag: emacs"
click n55 "tags/emacs.html" "Tag: emacs"
click n56 "tags/reading.html" "Tag: reading"
click n57 "tags/maths.html" "Tag: maths"
click n59 "posts/posts-intro.html" "Posts Introduction"
click n60 "posts/posts-list.html" "Posts List"
@@ -322,8 +322,8 @@ flowchart TD
- [[file:tags/life.org][Tag: life]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/insights.org][Tag: insights]]
- [[file:tags/reading.org][Tag: reading]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/reading.org][Tag: reading]]
- [[file:tags/maths.org][Tag: maths]]
- posts
- [[file:posts/posts-intro.org][Posts Introduction]]

View File

@@ -2,74 +2,93 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const data = require("../assets/scripts/pages/princess-lima-data.js");
const state = require("../assets/scripts/pages/princess-lima-state.js");
const systems = require("../assets/scripts/pages/princess-lima-systems.js");
const intro = require("../assets/scripts/pages/princess-lima-intro.js");
const data = require("../assets/scripts/pages/princess-lima-v2-data.js");
const state = require("../assets/scripts/pages/princess-lima-v2-state.js");
const systems = require("../assets/scripts/pages/princess-lima-v2-systems.js");
const root = path.resolve(__dirname, "..");
const rpgSource = fs.readFileSync(path.join(root, "play/rpg.org"), "utf8");
const sceneSource = fs.readFileSync(path.join(root, "assets/scripts/pages/princess-lima-scenes.js"), "utf8");
const source = (file) => fs.readFileSync(path.join(root, file), "utf8");
test("all nine regions use the exact atlas artwork and valid collision-safe transitions", () => {
const mapsSource = fs.readFileSync(path.join(root, "assets/scripts/pages/princess-lima-maps.js"), "utf8");
test("v2 content contracts and all dialogue graphs validate", () => {
assert.deepEqual(data.validateAll(), []);
assert.equal(data.SCHEMA_VERSION, 2);
assert.equal(Object.keys(data.DIALOGUES).length >= 8, true);
Object.values(data.DIALOGUES).forEach((dialogue) => assert.equal(data.validateDialogue(dialogue), true));
});
test("all nine regions are genuine Tiled maps with authored runtime layers", () => {
assert.equal(data.MAP_IDS.length, 9);
data.MAP_IDS.forEach((region) => {
assert.match(mapsSource, new RegExp(`${region}:\\s*\\d`));
Object.values(data.MAPS[region].spawns).forEach((spawn) => {
assert.ok(spawn.x > 0 && spawn.x < data.WIDTH);
assert.ok(spawn.y > 0 && spawn.y < data.HEIGHT);
});
data.MAPS[region].exits.forEach((exit) => {
assert.ok(data.MAPS[exit.target]);
assert.ok(data.MAPS[exit.target].spawns[exit.spawn]);
});
});
assert.equal(data.WIDTH, data.HEIGHT);
assert.match(mapsSource, /\.setAlpha\(1\)/);
assert.doesNotMatch(mapsSource, /tilemap|tileSprite|add\.circle|add\.ellipse|drawDecor|drawObstacle/);
assert.doesNotMatch(sceneSource, /lima-world-tiles|world-tiles\.png/);
});
test("all authored actors and interaction points sit outside visible terrain collision", () => {
const auditState = state.fresh("Collision audit", "azure");
auditState.unlockedRoutes = ["bridge_repaired"];
data.MAP_IDS.forEach((region) => {
const map = data.MAPS[region];
const points = [
...Object.entries(map.spawns).map(([name, point]) => [`spawn:${name}`, point.x, point.y]),
...(map.npcs || []).map(([name, x, y]) => [`npc:${name}`, x, y]),
...(map.enemies || []).map((enemy, index) => [`enemy:${enemy.type}:${index}`, enemy.x, enemy.y]),
...(map.pickups || []).map(([name, x, y], index) => [`pickup:${name}:${index}`, x, y]),
...(map.puzzle ? map.puzzle.objects.map(([name, x, y]) => [`puzzle:${name}`, x, y]) : [])
];
points.forEach(([name, x, y]) => {
assert.equal(systems.isSafePosition(auditState, region, x, y), true, `${region} ${name} must be collision-safe`);
});
const map = JSON.parse(source(`assets/maps/princess-lima/${region}.json`));
assert.equal(map.type, "map");
assert.equal(map.tilewidth, 32);
assert.equal(map.tileheight, 32);
assert.deepEqual(systems.validateMap(map, data.MAP_IDS), [], region);
assert.deepEqual(map.layers.map((layer) => layer.name), systems.REQUIRED_MAP_LAYERS);
assert.ok(map.layers.find((layer) => layer.name === "Base terrain").data.some((tile) => tile > 0));
});
});
test("every region exposes named Tiled-compatible collision and object layers", () => {
const expected = ["Collision", "Dynamic Collision", "Exits", "NPCs", "Enemies", "Puzzles", "Items"];
data.MAP_IDS.forEach((region) => {
const layers = data.MAP_LAYERS[region];
assert.deepEqual(layers.map((layer) => layer.name), expected);
assert.ok(layers.every((layer) => layer.type === "objectgroup"));
assert.equal(layers.find((layer) => layer.name === "Collision").objects, data.MAPS[region].obstacles);
});
test("the runtime loads Tiled maps rather than regional backdrop frames", () => {
const maps = source("assets/scripts/pages/princess-lima-v2-maps.js");
const scenes = source("assets/scripts/pages/princess-lima-v2-scenes.js");
assert.match(maps, /make\.tilemap/);
assert.match(maps, /createLayer/);
assert.match(scenes, /tilemapTiledJSON/);
assert.doesNotMatch(maps, /regional-style-atlas|REGION_FRAME/);
});
test("keyboard-only page contains one left HUD and no touch controls", () => {
assert.match(rpgSource, /class="lima-hud"/);
assert.match(rpgSource, /Inventory <kbd>I<\/kbd>/);
assert.match(rpgSource, /Menu <kbd>M<\/kbd>/);
assert.doesNotMatch(rpgSource, /data-lima-touch|data-lima-move|Touch controls|data-lima-attack/);
test("character animation, cinematic dialogue and eight enemy roles are present", () => {
const roles = new Set(Object.values(data.ENEMIES).filter((enemy) => !enemy.boss).map((enemy) => enemy.role));
["melee", "shield", "ranged", "fast", "ambush", "caster", "flying", "support", "elite"].forEach((role) => assert.ok(roles.has(role)));
assert.ok(fs.existsSync(path.join(root, "assets/images/play/princess-lima/actor-atlas-v2.png")));
assert.ok(fs.existsSync(path.join(root, "assets/images/play/princess-lima/portrait-atlas-v2.png")));
const ui = source("assets/scripts/pages/princess-lima-v2-ui.js");
assert.match(ui, /lima-cinematic-dialogue/);
assert.match(ui, /aria-label/);
assert.match(ui, /Conversation history/);
});
test("opening cinematic is complete, replayable and accessible", () => {
assert.equal(intro.BEATS.length, 5);
assert.ok(intro.BEATS.reduce((total, beat) => total + beat.duration, 0) >= 60000);
assert.ok(intro.BEATS.every((beat) => beat.subtitle.length > 20));
assert.match(rpgSource, /data-lima-replay-intro/);
assert.match(rpgSource, /data-lima-intro-pause/);
assert.match(rpgSource, /Hold Escape to skip/);
test("the standalone page loads only the local v2 runtime and accessibility surface", () => {
const rpg = source("play/rpg.org");
assert.match(rpg, /easystar-0\.4\.4\.min\.js/);
assert.match(rpg, /princess-lima-v2-game\.js/);
assert.match(rpg, /data-lima-screenreader/);
assert.match(rpg, /Block Shift/);
assert.doesNotMatch(rpg, /princess-lima-game\.js\?v=/);
assert.doesNotMatch(rpg, /https?:\/\//);
});
test("v1 progress migrates to v2 without overwriting legacy fields", () => {
const legacy = {
version: 1,
player: { name: "Rowan", appearance: "pine" },
chapter: 4,
region: "fortressInterior",
quests: { break_wards: { status: "complete", count: 3, rewarded: true } },
inventory: [{ id: "tempered_sword", quantity: 1 }, { id: "bad_item", quantity: 5 }],
defeatedBosses: ["stone_guardian", "unknown"],
settings: { subtitles: false, highContrast: true },
introSeen: true
};
const migrated = state.migrateLegacy(legacy);
assert.equal(migrated.version, 2);
assert.equal(migrated.player.name, "Rowan");
assert.equal(migrated.quests.break_wards.status, "complete");
assert.equal(migrated.inventory.some((item) => item.id === "bad_item"), false);
assert.equal(migrated.position.spawn, "frontHall");
assert.equal(migrated.settings.highContrast, true);
});
test("combat phases, hitboxes and enemy states are deterministic", () => {
assert.equal(systems.attackPhase(0, "charged"), "windup");
assert.equal(systems.attackPhase(520, "charged"), "active");
assert.equal(systems.attackPhase(670, "charged"), "recovery");
const north = systems.attackHitbox("north", 100, 100, "light1");
assert.ok(north.y + north.height <= 100);
const enemy = data.ENEMIES.raider;
assert.equal(systems.nextEnemyState("patrol", {
distance: 30, homeDistance: 0, spec: enemy, health: 5, leash: 190,
stunned: false, telegraphDone: true, attackDone: true, cooldownDone: true
}), "telegraph");
});

View File

@@ -0,0 +1,60 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const data = require("../assets/scripts/pages/princess-lima-v2-data.js");
const state = require("../assets/scripts/pages/princess-lima-v2-state.js");
const systems = require("../assets/scripts/pages/princess-lima-v2-systems.js");
test("fresh saves use the v2 key and complete accessibility defaults", () => {
const fresh = state.fresh("Aster", "azure");
assert.equal(state.STORAGE_KEY, "zxh_princess_lima_rpg_v2");
assert.equal(state.LEGACY_KEY, "zxh_princess_lima_rpg_v1");
assert.equal(fresh.settings.subtitles, true);
assert.equal(fresh.settings.blur, true);
assert.equal(fresh.settings.particles, true);
assert.equal(fresh.settings.screenShake, true);
});
test("loading imports legacy data once and leaves the legacy key untouched", () => {
const values = new Map([[state.LEGACY_KEY, JSON.stringify({
version: 1, player: { name: "Legacy", appearance: "ember" }, chapter: 2,
region: "forest", inventory: [], quests: {}, settings: {}
})]]);
const storage = { getItem: (key) => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
const result = state.load(storage);
assert.equal(result.migrated, true);
assert.equal(result.state.player.name, "Legacy");
assert.ok(values.has(state.LEGACY_KEY));
assert.ok(values.has(state.STORAGE_KEY));
});
test("dialogue choices apply flags and quest effects", () => {
const fresh = state.fresh("Aster", "azure");
const result = systems.dialogueChoice("elder", "choice", 0, fresh);
assert.equal(result.next, "accept");
assert.equal(result.state.flags.promised_village, true);
assert.equal(result.state.quests.aftermath.status, "active");
});
test("quest completion applies rewards and world state transformations", () => {
let current = state.fresh("Aster", "azure");
current = state.startQuest(current, "village_defence");
current = state.progressQuest(current, "village_defence", 3);
assert.equal(current.quests.village_defence.status, "complete");
assert.equal(current.flags.village_resolved, true);
assert.equal(current.flags.village_defended, true);
assert.equal(current.chapter, 2);
assert.equal(current.inventory.some((item) => item.id === "buckler"), true);
});
test("normalization removes invalid IDs and clamps unsafe values", () => {
const candidate = state.fresh("Aster", "azure");
candidate.health = 999;
candidate.inventory.push({ id: "not-real", quantity: 99 });
candidate.region = "missing";
candidate.settings.effectsQuality = "impossible";
const normalized = state.normalize(candidate);
assert.equal(normalized.health, normalized.maxHealth);
assert.equal(normalized.region, "village");
assert.equal(normalized.inventory.some((entry) => !data.ITEMS[entry.id]), false);
assert.equal(normalized.settings.effectsQuality, "full");
});

View File

@@ -0,0 +1,260 @@
#!/usr/bin/env node
/**
* Generate committed Tiled-compatible maps from compact, deliberately authored
* region specifications. The generated JSON is runtime data, while this file
* remains the editable source of truth.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const OUT = path.join(ROOT, "assets", "maps", "princess-lima");
const TILE = 32;
const W = 30;
const H = 22;
const regions = {
village: {
name: "Broken Village", theme: 0, ground: 0, music: "village-theme",
paths: [[13, 0, 5, 22], [3, 9, 24, 5]],
solids: [[1, 1, 8, 5, "Damaged home"], [20, 1, 8, 5, "Healer"], [1, 16, 8, 5, "Blacksmith"], [21, 16, 7, 5, "Burnt farm"], [1, 7, 4, 2, "Cart"], [24, 8, 4, 2, "Barricade"]],
props: [[15, 10, "Village well"], [11, 7, "Abandoned belongings"], [19, 14, "Memorial tree"], [6, 13, "Supply chest"]],
spawns: { start: [15, 19], square: [15, 12], forestRoad: [15, 2] },
npcs: [["elder", 15, 10], ["bram", 9, 16], ["nia", 21, 7]],
enemies: [["raider", 11, 13], ["raider", 18, 14], ["shield", 15, 16]],
transitions: [["forest-road", 14, 0, 3, 1, "forest", "villagePath", "village_defended"]],
interactions: [["well", 15, 10, "The old well reflects a sky bruised by smoke."], ["memorial", 19, 14, "Fresh ribbons promise that the village will endure."], ["chest-village", 6, 13, "chest"]],
camera: [[10, 7, 10, 9, "square-reveal", 1.08]],
secrets: [["cellar", 3, 15, "A loose foundation stone hides a moon coin."]]
},
forest: {
name: "Whispering Woods", theme: 1, ground: 0, music: "forest-theme",
paths: [[0, 10, 30, 4], [22, 2, 4, 20], [5, 4, 20, 3]],
water: [[14, 0, 3, 10], [14, 14, 3, 8]],
solids: [[1, 1, 5, 8, "Canopy"], [7, 1, 6, 3, "Canopy"], [18, 1, 4, 3, "Canopy"], [26, 1, 3, 9, "Canopy"], [1, 14, 8, 7, "Canopy"], [18, 15, 4, 6, "Canopy"], [26, 14, 3, 7, "Canopy"]],
props: [[8, 11, "Ancient stone"], [13, 12, "Bridge"], [20, 6, "Flower shrine"], [24, 18, "Briar den"]],
spawns: { villagePath: [2, 12], ruinsPath: [24, 20], mountainPath: [24, 3] },
npcs: [["tovin", 20, 7]],
enemies: [["wolf", 8, 7], ["ambusher", 20, 12], ["archer", 25, 16], ["briar_wolf", 24, 18]],
transitions: [["village", 0, 10, 1, 4, "village", "forestRoad", ""], ["ruins", 23, 21, 3, 1, "ruins", "forestDoor", "guide_found"], ["mountain", 23, 0, 3, 1, "mountain", "forestTrail", "briar_defeated"]],
interactions: [["stone-young", 8, 11, "puzzle"], ["stone-old", 20, 6, "puzzle"], ["tracks", 18, 12, "Claw marks turn toward the briar clearing."]],
camera: [[20, 14, 8, 7, "briar-clearing", 1.12]],
secrets: [["hollow", 6, 5, "Inside the hollow: a Forest Charm fragment."]]
},
ruins: {
name: "Ancient Ruins", theme: 2, ground: 2, music: "forest-theme",
paths: [[2, 9, 26, 4], [13, 2, 4, 18]],
water: [[1, 1, 7, 7], [22, 1, 7, 7], [1, 15, 7, 6], [22, 15, 7, 6]],
solids: [[8, 1, 2, 8, "Collapsed wall"], [20, 1, 2, 8, "Collapsed wall"], [8, 13, 2, 8, "Collapsed wall"], [20, 13, 2, 8, "Collapsed wall"], [11, 5, 8, 2, "Mosaic gate"]],
props: [[11, 10, "Dawn brazier"], [15, 6, "Noon brazier"], [19, 10, "Dusk brazier"], [15, 17, "Night brazier"], [15, 11, "Sun mosaic"]],
spawns: { forestDoor: [15, 20] },
npcs: [],
enemies: [["flying", 11, 8], ["caster", 19, 8], ["ambusher", 15, 15]],
transitions: [["forest", 14, 21, 3, 1, "forest", "ruinsPath", ""]],
interactions: [["dawn", 11, 10, "puzzle"], ["noon", 15, 6, "puzzle"], ["dusk", 19, 10, "puzzle"], ["night", 15, 17, "puzzle"], ["mural", 15, 3, "The mural shows dawn, noon, dusk, then night."]],
camera: [[9, 4, 12, 14, "mosaic-hall", 1.1]],
secrets: [["hidden-room", 23, 18, "A concealed chamber holds the Sun Crystal."]]
},
mountain: {
name: "Frostpeak Mountain", theme: 3, ground: 0, music: "mountain-theme",
paths: [[1, 16, 28, 4], [4, 10, 22, 4], [22, 3, 4, 17]],
water: [[0, 0, 10, 8], [10, 0, 10, 5], [26, 0, 4, 16]],
solids: [[0, 8, 4, 8, "Cliff"], [8, 4, 12, 5, "Cliff"], [26, 20, 4, 2, "Cliff"], [12, 13, 7, 3, "Broken bridge"]],
props: [[10, 12, "West winch"], [20, 12, "East winch"], [23, 6, "Sheltered camp"], [15, 17, "Rope bridge"]],
spawns: { forestTrail: [2, 18], campRoad: [27, 18] },
npcs: [["bram", 23, 7]],
enemies: [["flying", 7, 12], ["shield", 22, 16], ["stone_guardian", 24, 11]],
transitions: [["forest", 0, 16, 1, 4, "forest", "mountainPath", ""], ["camp", 29, 16, 1, 4, "camp", "mountainRoad", "guardian_defeated"]],
interactions: [["west-winch", 10, 12, "puzzle"], ["east-winch", 20, 12, "puzzle"], ["campfire", 23, 6, "The sheltered flame steadies your breath."]],
camera: [[19, 7, 9, 8, "guardian-arena", 1.12]],
secrets: [["ice-cave", 5, 9, "Behind the frozen fall lies a Royal Draught."]]
},
camp: {
name: "Resistance Camp", theme: 4, ground: 0, music: "village-theme",
paths: [[0, 9, 30, 5], [13, 2, 5, 20]],
solids: [[2, 2, 7, 5, "Supply tent"], [21, 2, 7, 5, "Infirmary"], [2, 16, 7, 5, "Barracks"], [21, 16, 7, 5, "Scout post"], [10, 15, 10, 2, "Barricade"]],
props: [[15, 11, "Command fire"], [7, 10, "Training dummies"], [23, 11, "Supply table"], [15, 18, "Mobilisation map"]],
spawns: { mountainRoad: [2, 12], fortressRoad: [27, 12] },
npcs: [["elowen", 15, 9], ["nia", 23, 8], ["elder", 14, 18]],
enemies: [["shield", 11, 12], ["archer", 19, 12], ["captain", 18, 18]],
transitions: [["mountain", 0, 10, 1, 4, "mountain", "campRoad", ""], ["fortress", 29, 10, 1, 4, "fortressExterior", "campGate", "emblem_found"]],
interactions: [["map", 15, 18, "route-choice"], ["supplies", 23, 11, "A ledger records medicine sent to the wounded."], ["dummies", 7, 10, "block-tutorial"]],
camera: [[10, 7, 10, 9, "command-area", 1.08]],
secrets: [["scout-cache", 27, 17, "A scout cache contains smoke bombs."]]
},
fortressExterior: {
name: "Fortress Exterior", theme: 5, ground: 0, music: "fortress-theme",
paths: [[1, 16, 28, 4], [13, 3, 5, 17], [3, 9, 24, 4]],
solids: [[0, 0, 30, 3, "Fortress wall"], [0, 3, 4, 13, "Cliff wall"], [26, 3, 4, 13, "Cliff wall"], [5, 4, 5, 5, "West tower"], [20, 4, 5, 5, "East tower"], [11, 2, 8, 4, "Gatehouse"]],
props: [[7, 12, "Ballista"], [22, 12, "Detection ward"], [15, 8, "Portcullis"], [4, 18, "Siege breach"]],
spawns: { campGate: [2, 18], innerGate: [15, 7] },
npcs: [["elowen", 5, 18]],
enemies: [["shield", 9, 18], ["archer", 7, 11], ["archer", 22, 11], ["caster", 21, 15], ["support", 15, 12]],
transitions: [["camp", 0, 16, 1, 4, "camp", "fortressRoad", ""], ["interior", 14, 5, 3, 1, "fortressInterior", "frontHall", "emblem_found"]],
interactions: [["ballista", 7, 12, "disable-defence"], ["ward", 22, 12, "disable-defence"], ["breach", 4, 18, "side-route"]],
camera: [[4, 2, 22, 14, "fortress-reveal", 1.0]],
secrets: [["drain", 25, 19, "A half-flooded drain bypasses the main courtyard."]]
},
fortressInterior: {
name: "Fortress Interior", theme: 6, ground: 2, music: "fortress-theme",
paths: [[13, 0, 5, 22], [2, 9, 26, 4], [4, 3, 22, 4], [4, 16, 22, 4]],
solids: [[0, 0, 4, 22, "Outer wall"], [26, 0, 4, 22, "Outer wall"], [4, 0, 9, 3, "Library"], [18, 0, 8, 3, "Chapel"], [4, 7, 7, 2, "Prison wall"], [19, 7, 7, 2, "Armoury wall"], [4, 13, 7, 3, "Kitchen"], [19, 13, 7, 3, "Barracks"]],
props: [[7, 11, "Prison cells"], [22, 11, "Armoury"], [7, 5, "War table"], [22, 5, "Ritual altar"], [15, 18, "Servant passage"]],
spawns: { frontHall: [15, 20], throneDoor: [15, 2] },
npcs: [["prisoner", 7, 11], ["elowen", 8, 11]],
enemies: [["shield", 15, 16], ["archer", 22, 11], ["caster", 22, 5], ["support", 7, 5], ["elite", 15, 7]],
transitions: [["outside", 14, 21, 3, 1, "fortressExterior", "innerGate", ""], ["throne", 14, 0, 3, 1, "bossArena", "entrance", "wards_broken"]],
interactions: [["cell-west", 6, 11, "free-prisoner"], ["cell-east", 8, 11, "free-prisoner"], ["ward-moon", 7, 5, "puzzle"], ["ward-crown", 15, 7, "puzzle"], ["ward-flame", 22, 5, "puzzle"], ["passage", 15, 18, "shortcut"]],
camera: [[4, 3, 22, 16, "great-hall", 1.04]],
secrets: [["library-cache", 5, 2, "Malrec's notes reveal the throne's weakness."]]
},
bossArena: {
name: "Throne of Night", theme: 7, ground: 2, music: "boss-theme",
paths: [[5, 2, 20, 18]],
solids: [[0, 0, 5, 22, "Void"], [25, 0, 5, 22, "Void"], [5, 0, 20, 2, "Throne wall"], [5, 20, 20, 2, "Arena wall"], [7, 4, 2, 2, "Pillar"], [21, 4, 2, 2, "Pillar"], [7, 16, 2, 2, "Pillar"], [21, 16, 2, 2, "Pillar"]],
props: [[15, 4, "Malrec's throne"], [10, 11, "Sun pedestal"], [20, 11, "Moon pedestal"], [15, 11, "Shadow seal"]],
spawns: { entrance: [15, 18] },
npcs: [["malrec", 15, 5]],
enemies: [["malrec", 15, 8]],
transitions: [["chamber", 14, 0, 3, 2, "chamber", "door", "malrec_defeated"]],
interactions: [["sun-pedestal", 10, 11, "boss-mechanic"], ["moon-pedestal", 20, 11, "boss-mechanic"]],
camera: [[5, 2, 20, 18, "throne-arena", 1.0]],
secrets: []
},
chamber: {
name: "Lima's Chamber", theme: 8, ground: 2, music: "victory-theme",
paths: [[4, 2, 22, 18]],
solids: [[0, 0, 4, 22, "Outer wall"], [26, 0, 4, 22, "Outer wall"], [4, 0, 22, 2, "Balcony"], [4, 20, 22, 2, "Outer wall"], [6, 4, 7, 5, "Bed"], [19, 4, 5, 4, "Writing desk"]],
props: [[15, 7, "Dawn window"], [21, 7, "Lima's journal"], [9, 10, "Broken chain"], [15, 14, "Royal crest"]],
spawns: { door: [15, 18] },
npcs: [["lima", 15, 9]],
enemies: [],
transitions: [],
interactions: [["journal", 21, 7, "Lima wrote plans for rebuilding every village Malrec harmed."], ["chain", 9, 10, "The broken shackle is scored where Lima worked at it night after night."]],
camera: [[7, 3, 16, 14, "rescue-framing", 1.14]],
secrets: [["keepsake", 23, 17, "A small carved bird bears the traveller's name."]]
}
};
function tileLayer(name, data, visible = true, opacity = 1) {
return { id: 0, name, type: "tilelayer", width: W, height: H, x: 0, y: 0, visible, opacity, data };
}
function objectLayer(name, objects) {
return { id: 0, name, type: "objectgroup", visible: true, opacity: 1, draworder: "topdown", objects };
}
function propList(values) {
return Object.entries(values).map(([name, value]) => ({
name, type: typeof value === "number" ? "float" : "string", value
}));
}
function pointObject(name, type, tx, ty, properties = {}) {
return { id: 0, name, type, x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2, width: 0, height: 0, point: true, visible: true, rotation: 0, properties: propList(properties) };
}
function rectObject(name, type, tx, ty, tw, th, properties = {}) {
return { id: 0, name, type, x: tx * TILE, y: ty * TILE, width: tw * TILE, height: th * TILE, visible: true, rotation: 0, properties: propList(properties) };
}
function paint(data, x, y, w, h, tile) {
for (let row = Math.max(0, y); row < Math.min(H, y + h); row += 1) {
for (let col = Math.max(0, x); col < Math.min(W, x + w); col += 1) data[row * W + col] = tile;
}
}
function build(id, spec) {
const first = spec.theme * 4 + 1;
const base = Array(W * H).fill(first + spec.ground);
const variation = Array(W * H).fill(0);
const paths = Array(W * H).fill(0);
const water = Array(W * H).fill(0);
const cliffs = Array(W * H).fill(0);
const props = Array(W * H).fill(0);
const shadows = Array(W * H).fill(0);
const lighting = Array(W * H).fill(0);
spec.paths.forEach(([x, y, w, h]) => paint(paths, x, y, w, h, first + 2));
(spec.water || []).forEach(([x, y, w, h]) => paint(water, x, y, w, h, first + 3));
spec.solids.forEach(([x, y, w, h]) => {
paint(cliffs, x, y, w, h, first + 1);
paint(shadows, x, y + h - 1, w, 1, first + 3);
});
spec.props.forEach(([x, y]) => { props[y * W + x] = first + 1; });
for (let y = 1; y < H - 1; y += 1) for (let x = 1; x < W - 1; x += 1) {
if ((x * 13 + y * 7 + spec.theme * 11) % 41 === 0) variation[y * W + x] = first + 1;
}
const layers = [
tileLayer("Base terrain", base),
tileLayer("Terrain variation", variation),
tileLayer("Paths", paths),
tileLayer("Water", water),
tileLayer("Cliffs and buildings", cliffs),
tileLayer("Props", props),
tileLayer("Objects behind actors", Array(W * H).fill(0)),
tileLayer("Actor layer", Array(W * H).fill(0)),
tileLayer("Objects above actors", Array(W * H).fill(0)),
tileLayer("Shadows", shadows, true, 0.45),
tileLayer("Lighting", lighting, true, 0.3),
objectLayer("Collision", spec.solids.map((v, i) => rectObject(`solid-${i}`, "collision", v[0], v[1], v[2], v[3], { material: v[4] }))),
objectLayer("Interaction zones", spec.interactions.map((v) => pointObject(v[0], "interaction", v[1], v[2], { action: v[3] }))),
objectLayer("Dialogue triggers", spec.npcs.map((v) => pointObject(v[0], "npc", v[1], v[2], { dialogue: v[0] }))),
objectLayer("Quest triggers", spec.interactions.filter((v) => ["puzzle", "route-choice", "disable-defence", "free-prisoner", "boss-mechanic"].includes(v[3])).map((v) => pointObject(v[0], "quest", v[1], v[2], { action: v[3] }))),
objectLayer("Enemy zones", spec.enemies.map((v, i) => pointObject(`${v[0]}-${i}`, "enemy", v[1], v[2], { enemy: v[0], leash: v[0].includes("malrec") ? 420 : 190 }))),
objectLayer("Camera zones", spec.camera.map((v) => rectObject(v[4], "camera", v[0], v[1], v[2], v[3], { zoom: v[5] }))),
objectLayer("Scene transitions", spec.transitions.map((v) => rectObject(v[0], "transition", v[1], v[2], v[3], v[4], { target: v[5], spawn: v[6], requirement: v[7] }))),
objectLayer("Named safe spawns", Object.entries(spec.spawns).map(([name, v]) => pointObject(name, "spawn", v[0], v[1]))),
objectLayer("Audio zones", [rectObject(`${id}-ambience`, "audio", 0, 0, W, H, { music: spec.music })]),
objectLayer("Optional secrets", spec.secrets.map((v) => pointObject(v[0], "secret", v[1], v[2], { observation: v[3] })))
];
let objectId = 1;
let layerId = 1;
layers.forEach((layer) => {
layer.id = layerId++;
if (layer.objects) layer.objects.forEach((object) => { object.id = objectId++; });
});
return {
compressionlevel: -1,
height: H,
width: W,
infinite: false,
layers,
nextlayerid: layerId,
nextobjectid: objectId,
orientation: "orthogonal",
renderorder: "right-down",
tiledversion: "1.11.2",
tileheight: TILE,
tilewidth: TILE,
type: "map",
version: "1.10",
properties: propList({ id, displayName: spec.name, schemaVersion: 2, resolvedFlag: `${id}_resolved` }),
tilesets: [{
firstgid: 1,
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: TILE,
tilewidth: TILE
}]
};
}
fs.mkdirSync(OUT, { recursive: true });
for (const [id, spec] of Object.entries(regions)) {
fs.writeFileSync(path.join(OUT, `${id}.json`), `${JSON.stringify(build(id, spec), null, 2)}\n`);
}
fs.writeFileSync(path.join(OUT, "world-tiles.tsj"), `${JSON.stringify({
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: TILE, tilewidth: TILE,
tiledversion: "1.11.2", type: "tileset", version: "1.10"
}, null, 2)}\n`);
console.log(`Generated ${Object.keys(regions).length} authored Tiled maps in ${OUT}`);

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Normalize existing original artwork into gameplay-scale v2 atlases."""
from pathlib import Path
import random
from PIL import Image, ImageDraw, ImageEnhance
ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT / "assets/images/play/princess-lima"
FRAME_W, FRAME_H = 48, 64
SOURCE_CELL = 314
def crop_subject(source: Image.Image, index: int) -> Image.Image:
col, row = index % 4, index // 4
cell = source.crop((col * SOURCE_CELL, row * SOURCE_CELL,
(col + 1) * SOURCE_CELL, (row + 1) * SOURCE_CELL))
box = cell.getbbox()
return cell.crop(box) if box else cell
def fitted(subject: Image.Image, max_w: int = 43, max_h: int = 57) -> Image.Image:
copy = subject.copy()
resampling = getattr(Image, "Resampling", Image)
copy.thumbnail((max_w, max_h), resampling.LANCZOS)
copy = ImageEnhance.Contrast(copy).enhance(1.08)
return copy
def make_actor_atlas() -> None:
source = Image.open(ASSETS / "cast-atlas.png").convert("RGBA")
# Stable actor row contract used by v2-data.js.
source_frames = [0, 4, 5, 6, 7, 11, 8, 9, 10, 11, 12, 13, 14, 13, 7, 4]
atlas = Image.new("RGBA", (FRAME_W * 16, FRAME_H * len(source_frames)))
for actor_row, source_index in enumerate(source_frames):
subject = fitted(crop_subject(source, source_index))
for direction in range(4):
for phase in range(4):
frame = Image.new("RGBA", (FRAME_W, FRAME_H))
bob = (0, -1, 0, 1)[phase]
stride = (-1, 0, 1, 0)[phase]
x = (FRAME_W - subject.width) // 2 + (stride if direction in (1, 3) else 0)
y = FRAME_H - subject.height - 3 + bob
transpose = getattr(Image, "Transpose", Image)
image = subject.transpose(transpose.FLIP_LEFT_RIGHT) if direction == 3 else subject
frame.alpha_composite(image, (x, y))
atlas.alpha_composite(frame, ((direction * 4 + phase) * FRAME_W, actor_row * FRAME_H))
atlas.save(ASSETS / "actor-atlas-v2.png", optimize=True)
def make_effect_atlas() -> None:
size = 32
atlas = Image.new("RGBA", (size * 8, size * 2))
palette = [
(255, 226, 132), (243, 132, 74), (118, 205, 255), (179, 118, 255),
(144, 222, 136), (235, 238, 246), (255, 107, 119), (89, 65, 120)
]
for index, color in enumerate(palette):
draw = ImageDraw.Draw(atlas)
x = index * size
draw.ellipse((x + 12, 12, x + 20, 20), fill=color + (245,))
draw.polygon([(x + 16, 2), (x + 19, 12), (x + 16, 10), (x + 13, 12)], fill=color + (210,))
draw.polygon([(x + 16, 30), (x + 19, 20), (x + 16, 22), (x + 13, 20)], fill=color + (150,))
draw.polygon([(x + 2, 16), (x + 12, 13), (x + 10, 16), (x + 12, 19)], fill=color + (180,))
draw.polygon([(x + 30, 16), (x + 20, 13), (x + 22, 16), (x + 20, 19)], fill=color + (180,))
y = size
draw.arc((x + 2, y + 4, x + 30, y + 30), 205, 335, fill=color + (235,), width=5)
draw.arc((x + 6, y + 8, x + 26, y + 26), 205, 335, fill=(255, 247, 210, 220), width=2)
atlas.save(ASSETS / "effects-atlas-v2.png", optimize=True)
def make_world_tiles() -> None:
themes = [
("#526c3d", "#354932", "#ad8b58", "#516776"),
("#24553b", "#15392c", "#8b704a", "#2b6771"),
("#526f70", "#2c474f", "#85806d", "#217885"),
("#a6bac9", "#60778b", "#e0e8ec", "#58788f"),
("#62523a", "#3d3329", "#9a7950", "#6c4430"),
("#43374d", "#241e2d", "#706179", "#4e264e"),
("#403845", "#211d27", "#817068", "#782f35"),
("#382b42", "#18131e", "#74527e", "#873d72"),
("#c9bd95", "#746d67", "#dccb91", "#537399"),
]
atlas = Image.new("RGBA", (128, 288))
for row, colors in enumerate(themes):
for kind, base in enumerate(colors):
tile = Image.new("RGBA", (32, 32), base)
draw = ImageDraw.Draw(tile)
rng = random.Random(row * 97 + kind * 29)
rgb = tuple(int(base[index:index + 2], 16) for index in (1, 3, 5))
dark = tuple(max(0, int(value * .68)) for value in rgb) + (255,)
light = tuple(min(255, int(value * 1.22)) for value in rgb) + (255,)
if kind == 0:
for _ in range(30):
x, y = rng.randrange(2, 30), rng.randrange(2, 30)
draw.point((x, y), fill=light if rng.random() > .72 else dark)
for _ in range(3):
x, y = rng.randrange(4, 28), rng.randrange(4, 28)
draw.line((x - 2, y, x + 2, y - 1), fill=dark)
elif kind == 1:
for y in range(0, 32, 8):
draw.line((0, y, 32, y), fill=dark, width=2)
offset = 5 if y % 16 else 0
for x in range(-offset, 32, 11):
draw.line((x, y, x + 4, y + 8), fill=dark)
draw.line((x + 1, y + 2, x + 4, y + 2), fill=light)
elif kind == 2:
tile.paste(base, (0, 0, 32, 32))
for y in range(2, 32, 9):
offset = 5 if y % 18 else 0
for x in range(-offset, 32, 10):
draw.rounded_rectangle((x, y, x + 8, y + 6), radius=2, fill=light, outline=dark)
draw.line((0, 31, 32, 31), fill=dark)
else:
for y in range(4, 32, 7):
draw.arc((-7, y - 4, 13, y + 5), 195, 345, fill=light, width=2)
draw.arc((11, y - 4, 31, y + 5), 195, 345, fill=dark, width=2)
atlas.alpha_composite(tile, (kind * 32, row * 32))
atlas.save(ASSETS / "world-tiles-v2.png", optimize=True)
if __name__ == "__main__":
make_actor_atlas()
make_effect_atlas()
make_world_tiles()
print("Generated normalized actor and effects atlases.")