Refine Princess Lima for v2 presentation
All checks were successful
Build Org Website / build (push) Successful in 1m2s
All checks were successful
Build Org Website / build (push) Successful in 1m2s
This commit is contained in:
BIN
assets/images/play/princess-lima/actor-atlas-v2.png
Normal file
BIN
assets/images/play/princess-lima/actor-atlas-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
BIN
assets/images/play/princess-lima/effects-atlas-v2.png
Normal file
BIN
assets/images/play/princess-lima/effects-atlas-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/images/play/princess-lima/portrait-atlas-v2.png
Normal file
BIN
assets/images/play/princess-lima/portrait-atlas-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
assets/images/play/princess-lima/world-tiles-v2.png
Normal file
BIN
assets/images/play/princess-lima/world-tiles-v2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
7896
assets/maps/princess-lima/bossArena.json
Normal file
7896
assets/maps/princess-lima/bossArena.json
Normal file
File diff suppressed because it is too large
Load Diff
7989
assets/maps/princess-lima/camp.json
Normal file
7989
assets/maps/princess-lima/camp.json
Normal file
File diff suppressed because it is too large
Load Diff
7787
assets/maps/princess-lima/chamber.json
Normal file
7787
assets/maps/princess-lima/chamber.json
Normal file
File diff suppressed because it is too large
Load Diff
8071
assets/maps/princess-lima/forest.json
Normal file
8071
assets/maps/princess-lima/forest.json
Normal file
File diff suppressed because it is too large
Load Diff
8036
assets/maps/princess-lima/fortressExterior.json
Normal file
8036
assets/maps/princess-lima/fortressExterior.json
Normal file
File diff suppressed because it is too large
Load Diff
8205
assets/maps/princess-lima/fortressInterior.json
Normal file
8205
assets/maps/princess-lima/fortressInterior.json
Normal file
File diff suppressed because it is too large
Load Diff
7952
assets/maps/princess-lima/mountain.json
Normal file
7952
assets/maps/princess-lima/mountain.json
Normal file
File diff suppressed because it is too large
Load Diff
7985
assets/maps/princess-lima/ruins.json
Normal file
7985
assets/maps/princess-lima/ruins.json
Normal file
File diff suppressed because it is too large
Load Diff
7972
assets/maps/princess-lima/village.json
Normal file
7972
assets/maps/princess-lima/village.json
Normal file
File diff suppressed because it is too large
Load Diff
15
assets/maps/princess-lima/world-tiles.tsj
Normal file
15
assets/maps/princess-lima/world-tiles.tsj
Normal 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"
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
234
assets/scripts/pages/princess-lima-v2-data.js
Normal file
234
assets/scripts/pages/princess-lima-v2-data.js
Normal 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
|
||||
});
|
||||
}));
|
||||
278
assets/scripts/pages/princess-lima-v2-game.js
Normal file
278
assets/scripts/pages/princess-lima-v2-game.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); }
|
||||
|
||||
Object.assign(controller, {
|
||||
ready, showMenu, hideMenu, beginAdventure, showHud, updateHud, setState, save, travel,
|
||||
prompt, status, boss, toggleFullscreen, fatal, frameDialogue, root: null
|
||||
});
|
||||
}());
|
||||
82
assets/scripts/pages/princess-lima-v2-maps.js
Normal file
82
assets/scripts/pages/princess-lima-v2-maps.js
Normal 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));
|
||||
699
assets/scripts/pages/princess-lima-v2-scenes.js
Normal file
699
assets/scripts/pages/princess-lima-v2-scenes.js
Normal 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));
|
||||
243
assets/scripts/pages/princess-lima-v2-state.js
Normal file
243
assets/scripts/pages/princess-lima-v2-state.js
Normal 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
|
||||
});
|
||||
}));
|
||||
217
assets/scripts/pages/princess-lima-v2-systems.js
Normal file
217
assets/scripts/pages/princess-lima-v2-systems.js
Normal 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
|
||||
});
|
||||
}));
|
||||
239
assets/scripts/pages/princess-lima-v2-ui.js
Normal file
239
assets/scripts/pages/princess-lima-v2-ui.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character]));
|
||||
}
|
||||
root.PrincessLimaV2UI = Object.freeze({ create });
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this));
|
||||
25
assets/scripts/vendor/easystar-0.4.4.min.js
vendored
Normal file
25
assets/scripts/vendor/easystar-0.4.4.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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) {
|
||||
|
||||
@@ -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
115
docs/princess-lima-map-authoring.md
Normal file → Executable 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.
|
||||
|
||||
72
docs/princess-lima-v2-implementation.md
Normal file
72
docs/princess-lima-v2-implementation.md
Normal 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.
|
||||
@@ -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"))
|
||||
|
||||
47
play/rpg.org
47
play/rpg.org
@@ -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
|
||||
|
||||
@@ -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>@@
|
||||
|
||||
10
sitemap.org
10
sitemap.org
@@ -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]]
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
60
tests/princess-lima-v2-state.test.cjs
Normal file
60
tests/princess-lima-v2-state.test.cjs
Normal 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");
|
||||
});
|
||||
260
tools/generate-princess-lima-maps.mjs
Normal file
260
tools/generate-princess-lima-maps.mjs
Normal 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}`);
|
||||
126
tools/generate-princess-lima-v2-assets.py
Normal file
126
tools/generate-princess-lima-v2-assets.py
Normal 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.")
|
||||
Reference in New Issue
Block a user