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) {
|
||||
|
||||
Reference in New Issue
Block a user