Remove obsolete generated assets and dead code
All checks were successful
Build Org Website / build (push) Successful in 43s
All checks were successful
Build Org Website / build (push) Successful in 43s
This commit is contained in:
@@ -1,356 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const STORAGE_KEY = "zxh_house_of_pages_v1";
|
||||
const MAIN_ROOM_IDS = Object.freeze([
|
||||
"foyer", "library", "study", "kitchen", "workshop", "playroom", "attic", "garden"
|
||||
]);
|
||||
|
||||
const ROOMS = Object.freeze({
|
||||
foyer: room({
|
||||
title: "Foyer",
|
||||
eyebrow: "Arrivals",
|
||||
description: "The front door opens onto the newest corners of the site.",
|
||||
caption: "A brass key waits in a blue bowl beside the door.",
|
||||
curated: [
|
||||
["Home", "/"],
|
||||
["Recently updated", "/recently-updated.html"],
|
||||
["Contact", "/home/contact.html"]
|
||||
],
|
||||
include: ["/recently-updated.html", "/home/contact.html"]
|
||||
}),
|
||||
library: room({
|
||||
title: "Library",
|
||||
eyebrow: "Knowledge",
|
||||
description: "Notes and lasting ideas gather here, arranged less strictly than the shelves suggest.",
|
||||
caption: "One book has been returned with a pressed leaf instead of a bookmark.",
|
||||
curated: [
|
||||
["All posts", "/posts/posts-list.html"],
|
||||
["Categories", "/home/categories.html"],
|
||||
["Posts introduction", "/posts/posts-intro.html"]
|
||||
],
|
||||
include: ["/posts/"],
|
||||
exclude: ["/posts/career/"]
|
||||
}),
|
||||
study: room({
|
||||
title: "Study",
|
||||
eyebrow: "Work",
|
||||
description: "Engineering notes, professional lessons, and active competencies cover the desk.",
|
||||
caption: "The lamp is still warm; somebody meant to come back after tea.",
|
||||
curated: [
|
||||
["Career library", "/posts/career/career-list.html"],
|
||||
["Competency status", "/home/status.html"],
|
||||
["Probation objectives", "/posts/career/probation-objectives.html"]
|
||||
],
|
||||
include: ["/posts/career/"]
|
||||
}),
|
||||
kitchen: room({
|
||||
title: "Kitchen",
|
||||
eyebrow: "Daily life",
|
||||
description: "Weekly reviews and ordinary days stay close to the kettle.",
|
||||
caption: "A shopping list shares the table with a thought worth keeping.",
|
||||
curated: [
|
||||
["All blogs", "/blogs/blogs-list.html"],
|
||||
["Weekly reviews", "/tags/review.html"],
|
||||
["Blog introduction", "/blogs/blogs-intro.html"]
|
||||
],
|
||||
include: ["/blogs/"]
|
||||
}),
|
||||
workshop: room({
|
||||
title: "Workshop",
|
||||
eyebrow: "Living systems",
|
||||
description: "Trackers, services, plans, and practical machinery keep the house running.",
|
||||
caption: "Every drawer is labelled except the one containing all the labels.",
|
||||
curated: [
|
||||
["Services", "/home/services.html"],
|
||||
["Wird tracker", "/home/wird-tracker.html"],
|
||||
["Countdowns", "/home/countdown.html"],
|
||||
["Backlog", "/home/backlog.html"]
|
||||
],
|
||||
include: ["/home/services.html", "/home/wird-tracker.html", "/home/countdown.html", "/home/backlog.html"]
|
||||
}),
|
||||
playroom: room({
|
||||
title: "Playroom",
|
||||
eyebrow: "Experiments",
|
||||
description: "Games, generators, puzzles, and stranger little mechanisms wait on the floor.",
|
||||
caption: "A wooden moon rolls beneath the cabinet whenever nobody is looking.",
|
||||
curated: [
|
||||
["Play hub", "/play/play.html"],
|
||||
["Rescue Princess Lima", "/play/rpg.html"],
|
||||
["The Rain Index", "/play/the-rain-index.html"]
|
||||
],
|
||||
include: ["/play/"],
|
||||
exclude: ["/play/house.html", "/play/play.html"]
|
||||
}),
|
||||
attic: room({
|
||||
title: "Attic",
|
||||
eyebrow: "Keepsakes",
|
||||
description: "Personal fragments and older writing rest beneath the roof beams.",
|
||||
caption: "The smallest box is labelled: things that became important later.",
|
||||
curated: [
|
||||
["Lima archive", "/lima/index.html"],
|
||||
["Blog archive", "/blogs/blogs-list.html"],
|
||||
["Memory Cabinet", "/play/memory.html"]
|
||||
],
|
||||
include: ["/lima/", "/blogs/2025/"]
|
||||
}),
|
||||
garden: room({
|
||||
title: "Garden",
|
||||
eyebrow: "Notes left outside",
|
||||
description: "Loose notes, paths between topics, and recently tended pages grow beyond the back step.",
|
||||
caption: "Someone has tied a question to the pear tree with green thread.",
|
||||
curated: [
|
||||
["Notes wall", "/home/notes.html"],
|
||||
["Categories", "/home/categories.html"],
|
||||
["Recently updated", "/recently-updated.html"],
|
||||
["Sitemap", "/sitemap.html"]
|
||||
],
|
||||
include: ["/tags/", "/home/notes.html", "/home/categories.html"]
|
||||
}),
|
||||
archive: room({
|
||||
title: "Archive Room",
|
||||
eyebrow: "The Ninth Door",
|
||||
description: "The house keeps one room for the shape of itself: maps, labels, plans, and unfinished intentions.",
|
||||
caption: "On the inside of the door: A house is an index that learned how to wait.",
|
||||
curated: [
|
||||
["Sitemap", "/sitemap.html"],
|
||||
["Categories", "/home/categories.html"],
|
||||
["Backlog", "/home/backlog.html"]
|
||||
],
|
||||
include: ["/sitemap.html", "/home/categories.html", "/home/backlog.html"]
|
||||
})
|
||||
});
|
||||
|
||||
function room(config) {
|
||||
return Object.freeze(Object.assign({ exclude: [] }, config, {
|
||||
curated: Object.freeze(config.curated.map((link) => Object.freeze(link.slice()))),
|
||||
include: Object.freeze(config.include.slice()),
|
||||
exclude: Object.freeze((config.exclude || []).slice())
|
||||
}));
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const root = document.querySelector('.house-root[data-play-page="house"]');
|
||||
if (!root) return;
|
||||
initHouse(root);
|
||||
});
|
||||
|
||||
function initHouse(root) {
|
||||
const roomButtons = Array.from(root.querySelectorAll("[data-house-room]"));
|
||||
const mainButtons = roomButtons.filter((button) => MAIN_ROOM_IDS.includes(button.dataset.houseRoom));
|
||||
const secretButton = root.querySelector(".house-secret-door");
|
||||
const panel = root.querySelector("[data-house-panel]");
|
||||
const status = root.querySelector("[data-house-status]");
|
||||
const reset = root.querySelector("[data-house-reset]");
|
||||
const state = loadState();
|
||||
let pages = [];
|
||||
let indexStatus = "loading";
|
||||
|
||||
root.classList.add("is-enhanced");
|
||||
state.visited = state.visited.filter((id) => MAIN_ROOM_IDS.includes(id));
|
||||
if (state.visited.length === MAIN_ROOM_IDS.length) state.secretUnlocked = true;
|
||||
renderSecret();
|
||||
|
||||
roomButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => selectRoom(button.dataset.houseRoom, true));
|
||||
button.addEventListener("keydown", (event) => moveRoomFocus(event, button));
|
||||
});
|
||||
|
||||
reset.addEventListener("click", () => {
|
||||
state.visited = [];
|
||||
state.secretUnlocked = false;
|
||||
saveState(state);
|
||||
roomButtons.forEach((button) => button.classList.remove("is-visited"));
|
||||
renderSecret();
|
||||
selectRoom("foyer", false);
|
||||
status.textContent = "The house has forgotten this visit. Choose a room to begin again.";
|
||||
});
|
||||
|
||||
fetch("/search-index.json", { credentials: "same-origin" })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`Search index returned ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then((index) => {
|
||||
pages = flattenIndex(index);
|
||||
indexStatus = "ready";
|
||||
renderRoom(root.querySelector("[data-house-room][aria-selected='true']")?.dataset.houseRoom || "foyer");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("House shelves could not be catalogued", error);
|
||||
indexStatus = "failed";
|
||||
renderRoom(root.querySelector("[data-house-room][aria-selected='true']")?.dataset.houseRoom || "foyer");
|
||||
});
|
||||
|
||||
function selectRoom(id, countVisit) {
|
||||
const config = ROOMS[id];
|
||||
const selected = roomButtons.find((button) => button.dataset.houseRoom === id);
|
||||
if (!config || !selected) return;
|
||||
|
||||
roomButtons.forEach((button) => {
|
||||
const active = button === selected;
|
||||
button.classList.toggle("is-selected", active);
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
button.tabIndex = active ? 0 : -1;
|
||||
});
|
||||
|
||||
if (countVisit && MAIN_ROOM_IDS.includes(id) && !state.visited.includes(id)) {
|
||||
state.visited.push(id);
|
||||
selected.classList.add("is-visited");
|
||||
if (state.visited.length === MAIN_ROOM_IDS.length) state.secretUnlocked = true;
|
||||
saveState(state);
|
||||
renderSecret();
|
||||
}
|
||||
|
||||
panel.setAttribute("aria-labelledby", selected.id);
|
||||
root.dataset.houseActive = id;
|
||||
renderRoom(id);
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
function renderRoom(id) {
|
||||
const config = ROOMS[id];
|
||||
if (!config) return;
|
||||
root.querySelector("[data-house-eyebrow]").textContent = config.eyebrow;
|
||||
root.querySelector("[data-house-title]").textContent = config.title;
|
||||
root.querySelector("[data-house-description]").textContent = config.description;
|
||||
root.querySelector("[data-house-caption]").textContent = config.caption;
|
||||
renderLinks(root.querySelector("[data-house-curated]"), config.curated.map(([title, href]) => ({ title, href })));
|
||||
|
||||
const dynamicList = root.querySelector("[data-house-dynamic]");
|
||||
if (indexStatus === "loading") {
|
||||
renderEmpty(dynamicList, "The shelves are being catalogued…");
|
||||
return;
|
||||
}
|
||||
if (indexStatus === "failed") {
|
||||
renderEmpty(dynamicList, "The shelves could not be catalogued today.");
|
||||
return;
|
||||
}
|
||||
|
||||
const curatedPaths = new Set(config.curated.map((link) => normalizePath(link[1])));
|
||||
const matches = pages
|
||||
.filter((page) => matchesRoom(page.path, config))
|
||||
.filter((page) => !curatedPaths.has(page.path))
|
||||
.filter((page) => !isGeneratedListing(page.path))
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
.slice(0, 4);
|
||||
|
||||
if (matches.length === 0) renderEmpty(dynamicList, "Nothing else is resting here yet.");
|
||||
else renderLinks(dynamicList, matches.map((page) => ({ title: humanizeFilename(page.name), href: page.path })));
|
||||
}
|
||||
|
||||
function renderSecret() {
|
||||
secretButton.hidden = !state.secretUnlocked;
|
||||
mainButtons.forEach((button) => button.classList.toggle("is-visited", state.visited.includes(button.dataset.houseRoom)));
|
||||
root.classList.toggle("has-secret", state.secretUnlocked);
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
if (state.secretUnlocked) {
|
||||
status.textContent = "All eight rooms remember you. Somewhere nearby, a ninth door has appeared.";
|
||||
} else {
|
||||
const remaining = MAIN_ROOM_IDS.length - state.visited.length;
|
||||
status.textContent = `${state.visited.length} of ${MAIN_ROOM_IDS.length} rooms visited · ${remaining} ${remaining === 1 ? "room" : "rooms"} still unlit.`;
|
||||
}
|
||||
}
|
||||
|
||||
function moveRoomFocus(event, current) {
|
||||
if (!MAIN_ROOM_IDS.includes(current.dataset.houseRoom)) return;
|
||||
const movement = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||
if (!movement) return;
|
||||
event.preventDefault();
|
||||
const index = mainButtons.indexOf(current);
|
||||
const next = mainButtons[(index + movement + mainButtons.length) % mainButtons.length];
|
||||
next.focus();
|
||||
selectRoom(next.dataset.houseRoom, true);
|
||||
}
|
||||
|
||||
selectRoom("foyer", true);
|
||||
}
|
||||
|
||||
function flattenIndex(root) {
|
||||
const found = [];
|
||||
const seen = new Set();
|
||||
|
||||
function visit(node) {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (node.type === "file" && typeof node.url === "string") {
|
||||
const path = normalizePath(node.url);
|
||||
if (path.endsWith(".html") && !seen.has(path)) {
|
||||
seen.add(path);
|
||||
found.push({ name: node.name || path.split("/").pop(), path });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.children)) node.children.forEach(visit);
|
||||
}
|
||||
|
||||
visit(root);
|
||||
return found;
|
||||
}
|
||||
|
||||
function matchesRoom(path, config) {
|
||||
const included = config.include.some((prefix) => path.startsWith(normalizePath(prefix)));
|
||||
const excluded = config.exclude.some((prefix) => path.startsWith(normalizePath(prefix)));
|
||||
return included && !excluded && path !== "/play/house.html";
|
||||
}
|
||||
|
||||
function isGeneratedListing(path) {
|
||||
const filename = path.split("/").pop() || "";
|
||||
return filename === "index.html" || filename.endsWith("-list.html") || filename.endsWith("-intro.html");
|
||||
}
|
||||
|
||||
function humanizeFilename(name) {
|
||||
return String(name || "Untitled page")
|
||||
.replace(/\.html$/i, "")
|
||||
.replace(/^\d{2}-\d{2}-/, "")
|
||||
.replace(/[._-]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
const clean = String(path || "").replace(/\\/g, "/").split(/[?#]/)[0];
|
||||
return clean.startsWith("/") ? clean : `/${clean}`;
|
||||
}
|
||||
|
||||
function renderLinks(list, links) {
|
||||
list.replaceChildren(...links.map(({ title, href }) => {
|
||||
const item = document.createElement("li");
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.textContent = title;
|
||||
item.appendChild(anchor);
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
function renderEmpty(list, message) {
|
||||
const item = document.createElement("li");
|
||||
item.className = "house-empty";
|
||||
item.textContent = message;
|
||||
list.replaceChildren(item);
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
||||
return {
|
||||
visited: Array.isArray(saved.visited) ? saved.visited.slice() : [],
|
||||
secretUnlocked: saved.secretUnlocked === true
|
||||
};
|
||||
} catch (_error) {
|
||||
return { visited: [], secretUnlocked: false };
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(state) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
visited: state.visited,
|
||||
secretUnlocked: state.secretUnlocked
|
||||
}));
|
||||
} catch (_error) {
|
||||
// The house remains navigable when storage is unavailable.
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -1,93 +0,0 @@
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
const TRACKS = Object.freeze({
|
||||
village: "village-theme", forest: "forest-theme", ruins: "forest-theme",
|
||||
mountain: "mountain-theme", camp: "mountain-theme",
|
||||
fortressExterior: "fortress-theme", fortressInterior: "fortress-theme",
|
||||
bossArena: "boss-theme", chamber: "victory-theme"
|
||||
});
|
||||
|
||||
function create(getState) {
|
||||
let scene = null;
|
||||
let ambience = null;
|
||||
let voice = null;
|
||||
let unlocked = false;
|
||||
let ducked = false;
|
||||
|
||||
function attach(nextScene, region) {
|
||||
scene = nextScene;
|
||||
if (ambience) ambience.stop();
|
||||
const key = TRACKS[region] || "village-theme";
|
||||
ambience = scene.cache.audio.exists(key) ? scene.sound.add(key, { loop: true }) : null;
|
||||
apply();
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
unlocked = true;
|
||||
if (scene && scene.sound.locked && scene.sound.unlock) scene.sound.unlock();
|
||||
apply();
|
||||
}
|
||||
|
||||
function apply() {
|
||||
if (!scene) return;
|
||||
const state = getState();
|
||||
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) * (ducked ? 0.38 : 1) : 0);
|
||||
if (enabled && !ambience.isPlaying) ambience.play();
|
||||
if (!enabled && ambience.isPlaying) ambience.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function play(key, volume) {
|
||||
const state = getState();
|
||||
if (!scene || !unlocked || !state || !state.settings.soundEnabled || !scene.cache.audio.exists(key)) return;
|
||||
scene.sound.play(key, { volume: state.settings.master * state.settings.effects * (volume || 1) });
|
||||
}
|
||||
|
||||
function playVoice(key) {
|
||||
const state = getState();
|
||||
stopVoice();
|
||||
if (!scene || !unlocked || !state || !state.settings.soundEnabled || !state.settings.narrationEnabled
|
||||
|| !scene.cache.audio.exists(key)) return null;
|
||||
voice = scene.sound.add(key, { volume: state.settings.master * state.settings.voice });
|
||||
voice.once("complete", () => { voice = null; });
|
||||
voice.play();
|
||||
return voice;
|
||||
}
|
||||
|
||||
function stopVoice() {
|
||||
if (voice) {
|
||||
voice.stop();
|
||||
voice.destroy();
|
||||
}
|
||||
voice = null;
|
||||
}
|
||||
|
||||
function suspend() {
|
||||
if (ambience && ambience.isPlaying) ambience.pause();
|
||||
}
|
||||
|
||||
function resume() {
|
||||
apply();
|
||||
}
|
||||
|
||||
function duck(value) {
|
||||
ducked = Boolean(value);
|
||||
apply();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopVoice();
|
||||
if (ambience) ambience.stop();
|
||||
ambience = null;
|
||||
scene = null;
|
||||
}
|
||||
|
||||
return Object.freeze({ attach, unlock, apply, play, playVoice, stopVoice, suspend, resume, duck, stop, isUnlocked: () => unlocked });
|
||||
}
|
||||
|
||||
root.PrincessLimaAudio = Object.freeze({ create, TRACKS });
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this));
|
||||
@@ -1,458 +0,0 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
const api = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
root.PrincessLimaData = api;
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const WIDTH = 720;
|
||||
const HEIGHT = 720;
|
||||
|
||||
function rect(x, y, width, height, kind) {
|
||||
return Object.freeze({ shape: "rect", x, y, width, height, kind: kind || "wall" });
|
||||
}
|
||||
|
||||
function circle(x, y, radius, kind) {
|
||||
return Object.freeze({ shape: "circle", x, y, radius, kind: kind || "rock" });
|
||||
}
|
||||
|
||||
function exit(id, x, y, width, height, target, spawn, requirement, label) {
|
||||
return Object.freeze({ id, x, y, width, height, target, spawn, requirement: requirement || null, label });
|
||||
}
|
||||
|
||||
function enemy(type, x, y, options) {
|
||||
return Object.freeze(Object.assign({ type, x, y, leash: 170, quest: null, boss: false }, options || {}));
|
||||
}
|
||||
|
||||
const ITEMS = Object.freeze({
|
||||
village_sword: Object.freeze({ name: "Wayfarer Sword", type: "weapon", unique: true, description: "A balanced village-forged blade.", attack: 1 }),
|
||||
tempered_sword: Object.freeze({ name: "Tempered Sword", type: "weapon", unique: true, description: "Bram's reforged blade. It breaks shadow armour.", attack: 2 }),
|
||||
buckler: Object.freeze({ name: "Oak Buckler", type: "armour", unique: true, description: "Reduces incoming damage.", defence: 2 }),
|
||||
trail_boots: Object.freeze({ name: "Trail Boots", type: "equipment", unique: true, description: "Quicker acceleration over rough ground." }),
|
||||
forest_charm: Object.freeze({ name: "Forest Charm", type: "key", unique: true, description: "Proof that the Whispering Woods accepted your passage." }),
|
||||
mountain_key: Object.freeze({ name: "Mountain Key", type: "key", unique: true, description: "Opens the old lift gate." }),
|
||||
fortress_emblem: Object.freeze({ name: "Fortress Emblem", type: "key", unique: true, description: "Taken from the camp captain." }),
|
||||
healing_tonic: Object.freeze({ name: "Healing Tonic", type: "consumable", stack: 9, description: "Restores 40 health.", heal: 40 }),
|
||||
royal_draught: Object.freeze({ name: "Royal Draught", type: "consumable", stack: 3, description: "Fully restores health.", heal: 999 }),
|
||||
silver_leaf: Object.freeze({ name: "Silver Leaf", type: "collectable", stack: 12, description: "A moonlit forest herb." }),
|
||||
moon_coin: Object.freeze({ name: "Moon Coin", type: "currency", stack: 99, description: "Accepted by travelling merchants." }),
|
||||
prison_key: Object.freeze({ name: "Prison Key", type: "quest", unique: true, protected: true, description: "Unlocks the fortress cells." }),
|
||||
bridge_gear: Object.freeze({ name: "Bridge Gear", type: "quest", unique: true, protected: true, description: "Repairs the mountain bridge winch." }),
|
||||
sun_crystal: Object.freeze({ name: "Sun Crystal", type: "quest", unique: true, protected: true, description: "Weakens the Shadow Lord's veil." })
|
||||
});
|
||||
|
||||
const QUESTS = Object.freeze({
|
||||
aftermath: Object.freeze({ title: "After the Black Riders", chapter: 1, region: "Broken Village", main: true, reward: [["village_sword", 1]], description: "Learn what happened to Princess Lima.", target: 1 }),
|
||||
village_defence: Object.freeze({ title: "The Second Raid", chapter: 1, region: "Broken Village", main: true, reward: [["healing_tonic", 2], ["buckler", 1]], description: "Defend the square from three attackers.", target: 3 }),
|
||||
healer_herbs: Object.freeze({ title: "Silver for the Wounded", chapter: 1, region: "Broken Village", main: false, reward: [["healing_tonic", 2]], description: "Bring two Silver Leaves to Healer Nia.", target: 2 }),
|
||||
find_guide: Object.freeze({ title: "The Missing Guide", chapter: 2, region: "Whispering Woods", main: true, reward: [["forest_charm", 1], ["trail_boots", 1]], description: "Follow the standing stones and rescue Tovin.", target: 3 }),
|
||||
ruins_light: Object.freeze({ title: "Light Beneath the Roots", chapter: 2, region: "Sunken Ruins", main: true, reward: [["sun_crystal", 1]], description: "Wake the ruin braziers in the marked order.", target: 4 }),
|
||||
wolf_miniboss: Object.freeze({ title: "The Briar Wolf", chapter: 2, region: "Whispering Woods", main: true, reward: [["mountain_key", 1]], description: "Defeat the corrupted Briar Wolf.", target: 1 }),
|
||||
repair_bridge: Object.freeze({ title: "A Road Across the Sky", chapter: 3, region: "Mountain Pass", main: true, reward: [["bridge_gear", 1], ["tempered_sword", 1]], description: "Restart both winches and repair the bridge.", target: 2 }),
|
||||
stone_guardian: Object.freeze({ title: "Guardian of the Pass", chapter: 3, region: "Mountain Pass", main: true, reward: [["royal_draught", 1]], description: "Defeat the awakened Stone Guardian.", target: 1 }),
|
||||
free_scout: Object.freeze({ title: "The Captured Scout", chapter: 3, region: "Blackridge Camp", main: true, reward: [["fortress_emblem", 1]], description: "Free Scout Elowen and defeat the camp captain.", target: 2 }),
|
||||
free_prisoners: Object.freeze({ title: "No One Left in Shadow", chapter: 4, region: "Shadow Fortress", main: true, reward: [["prison_key", 1]], description: "Open the two prison cells.", target: 2 }),
|
||||
break_wards: Object.freeze({ title: "The Three Shadow Wards", chapter: 4, region: "Shadow Fortress", main: true, reward: [["royal_draught", 1]], description: "Disable the three fortress wards.", target: 3 }),
|
||||
defeat_malrec: Object.freeze({ title: "The Last Shadow", chapter: 4, region: "Throne of Night", main: true, reward: [], description: "Defeat Lord Malrec and rescue Princess Lima.", target: 1 })
|
||||
});
|
||||
|
||||
const ENEMIES = Object.freeze({
|
||||
slime: Object.freeze({ name: "Marsh Slime", health: 3, damage: 8, speed: 58, behaviour: "chase", frame: 9, xp: 8 }),
|
||||
wolf: Object.freeze({ name: "Grey Wolf", health: 4, damage: 10, speed: 88, behaviour: "chase", frame: 10, xp: 12 }),
|
||||
bandit: Object.freeze({ name: "Road Bandit", health: 5, damage: 12, speed: 66, behaviour: "chase", frame: 11, xp: 15 }),
|
||||
bat: Object.freeze({ name: "Cave Bat", health: 3, damage: 9, speed: 96, behaviour: "wander", frame: 12, xp: 10 }),
|
||||
guard: Object.freeze({ name: "Shadow Guard", health: 7, damage: 14, speed: 58, behaviour: "guard", frame: 13, xp: 20 }),
|
||||
briar_wolf: Object.freeze({ name: "Briar Wolf", health: 22, damage: 15, speed: 92, behaviour: "charge", frame: 10, xp: 90, boss: true, phases: 2 }),
|
||||
stone_guardian: Object.freeze({ name: "Stone Guardian", health: 30, damage: 18, speed: 45, behaviour: "slam", frame: 14, xp: 140, boss: true, phases: 2 }),
|
||||
captain: Object.freeze({ name: "Captain Veyr", health: 24, damage: 17, speed: 64, behaviour: "guard", frame: 13, xp: 110, boss: true, phases: 2 }),
|
||||
malrec: Object.freeze({ name: "Lord Malrec", health: 48, damage: 18, speed: 66, behaviour: "final", frame: 8, xp: 300, boss: true, phases: 3 })
|
||||
});
|
||||
|
||||
const NPCS = Object.freeze({
|
||||
elder: Object.freeze({ name: "Elder Corin", frame: 5, dialogue: ["The black riders took Princess Lima toward the northern fortress.", "We are small, traveller, but we are not helpless. Speak to Bram. Take a blade."] }),
|
||||
bram: Object.freeze({ name: "Blacksmith Bram", frame: 6, dialogue: ["This sword was meant for a royal guard. Today, it chooses you.", "Bring the mountain forge back to life and I can temper it."] }),
|
||||
nia: Object.freeze({ name: "Healer Nia", frame: 7, dialogue: ["The wounded need Silver Leaf. It grows where moonlight reaches the forest floor.", "Keep a tonic ready. Courage is easier with a second chance."] }),
|
||||
tovin: Object.freeze({ name: "Guide Tovin", frame: 7, dialogue: ["I followed the riders until the Briar Wolf cornered me.", "Wake the stones from youngest tree to oldest. The true path will answer."] }),
|
||||
elowen: Object.freeze({ name: "Scout Elowen", frame: 11, dialogue: ["Malrec's guards sealed the pass, but their captain carries the fortress emblem.", "Princess Lima is alive. She refused Malrec's bargain."] }),
|
||||
prisoner: Object.freeze({ name: "Resistance Prisoner", frame: 5, dialogue: ["The wards feed the throne room. Break all three before facing Malrec."] }),
|
||||
lima: Object.freeze({ name: "Princess Lima", frame: 4, dialogue: ["You crossed a kingdom for someone you had never met.", "Let us go home—not as legend and princess, but as two people who chose to help."] }),
|
||||
malrec: Object.freeze({ name: "Lord Malrec", frame: 8, dialogue: ["Lima's oath could command every border lord. With it, I would end their endless quarrels.", "If the kingdom will not accept peace, shadow will make it obey."] })
|
||||
});
|
||||
|
||||
const LEGACY_MAPS = Object.freeze({
|
||||
village: Object.freeze({
|
||||
name: "Broken Village", chapter: 1, palette: ["#263b2d", "#596b3a", "#b49355", "#3b2d2a"],
|
||||
spawns: Object.freeze({ start: { x: 160, y: 570 }, square: { x: 640, y: 420 }, forestRoad: { x: 1110, y: 350 } }),
|
||||
obstacles: Object.freeze([
|
||||
rect(0, 0, 1280, 34, "edge"), rect(0, 686, 1280, 34, "edge"), rect(0, 0, 34, 720, "edge"), rect(1246, 0, 34, 720, "edge"),
|
||||
rect(80, 70, 250, 170, "house"), rect(440, 58, 250, 175, "house"), rect(865, 70, 260, 175, "house"),
|
||||
rect(60, 285, 360, 30, "fence"), rect(850, 285, 350, 30, "fence"),
|
||||
circle(210, 430, 54, "well"), circle(1035, 475, 48, "rubble")
|
||||
]),
|
||||
exits: Object.freeze([exit("forest-road", 1210, 300, 60, 120, "forest", "villagePath", "village_defended", "Road to the Whispering Woods")]),
|
||||
npcs: Object.freeze([["elder", 640, 330], ["bram", 520, 285], ["nia", 760, 285]]),
|
||||
enemies: Object.freeze([
|
||||
enemy("slime", 360, 500, { quest: "village_defence", requires: "aftermath_complete" }),
|
||||
enemy("bandit", 620, 545, { quest: "village_defence", requires: "aftermath_complete" }),
|
||||
enemy("slime", 900, 520, { quest: "village_defence", requires: "aftermath_complete" })
|
||||
]),
|
||||
pickups: Object.freeze([["silver_leaf", 365, 360], ["silver_leaf", 915, 370], ["moon_coin", 1090, 570]])
|
||||
}),
|
||||
forest: Object.freeze({
|
||||
name: "Whispering Woods", chapter: 2, palette: ["#122d24", "#28513c", "#6f8a4d", "#a7b46b"],
|
||||
spawns: Object.freeze({ villagePath: { x: 90, y: 355 }, ruinsPath: { x: 1110, y: 570 }, mountainPath: { x: 1120, y: 120 } }),
|
||||
obstacles: Object.freeze([
|
||||
rect(0, 0, 1280, 30, "edge"), rect(0, 690, 1280, 30, "edge"), rect(0, 0, 30, 720, "edge"), rect(1250, 0, 30, 720, "edge"),
|
||||
rect(160, 40, 90, 245, "trees"), rect(160, 430, 90, 230, "trees"), rect(390, 170, 95, 420, "trees"),
|
||||
rect(625, 35, 95, 290, "trees"), rect(625, 455, 95, 230, "trees"), rect(890, 150, 90, 420, "trees"),
|
||||
circle(315, 350, 38, "stone"), circle(550, 385, 42, "stone"), circle(805, 350, 44, "stone")
|
||||
]),
|
||||
exits: Object.freeze([
|
||||
exit("village", 20, 305, 60, 105, "village", "forestRoad", null, "Return to the village"),
|
||||
exit("ruins", 1180, 525, 75, 120, "ruins", "forestDoor", "guide_found", "Sunken Ruins"),
|
||||
exit("mountain", 1070, 20, 130, 65, "mountain", "forestTrail", "briar_defeated", "Mountain trail")
|
||||
]),
|
||||
npcs: Object.freeze([["tovin", 780, 570]]),
|
||||
enemies: Object.freeze([
|
||||
enemy("wolf", 320, 140), enemy("slime", 540, 610), enemy("bandit", 800, 160),
|
||||
enemy("wolf", 1070, 430), enemy("briar_wolf", 1030, 105, { quest: "wolf_miniboss", boss: true, requires: "ruins_complete", leash: 260 })
|
||||
]),
|
||||
puzzle: Object.freeze({ id: "forest_stones", type: "sequence", sequence: ["sapling", "oak", "elder"], objects: [["sapling", 315, 350], ["oak", 550, 385], ["elder", 805, 350]] }),
|
||||
pickups: Object.freeze([["silver_leaf", 325, 620], ["silver_leaf", 760, 90], ["healing_tonic", 1080, 610]])
|
||||
}),
|
||||
ruins: Object.freeze({
|
||||
name: "Sunken Ruins", chapter: 2, palette: ["#17272d", "#31505a", "#6d7567", "#d49c55"],
|
||||
spawns: Object.freeze({ forestDoor: { x: 110, y: 590 } }),
|
||||
obstacles: Object.freeze([
|
||||
rect(0, 0, 1280, 32, "edge"), rect(0, 688, 1280, 32, "edge"), rect(0, 0, 32, 720, "edge"), rect(1248, 0, 32, 720, "edge"),
|
||||
rect(130, 100, 900, 34, "ruin-wall"), rect(130, 100, 34, 410, "ruin-wall"), rect(130, 476, 330, 34, "ruin-wall"),
|
||||
rect(570, 476, 460, 34, "ruin-wall"), rect(996, 100, 34, 410, "ruin-wall"),
|
||||
rect(340, 250, 110, 80, "water"), rect(700, 250, 110, 80, "water")
|
||||
]),
|
||||
exits: Object.freeze([exit("forest", 45, 540, 80, 120, "forest", "ruinsPath", null, "Return to the woods")]),
|
||||
npcs: Object.freeze([]),
|
||||
enemies: Object.freeze([enemy("bat", 300, 190), enemy("bat", 850, 190), enemy("slime", 580, 390)]),
|
||||
puzzle: Object.freeze({ id: "ruin_braziers", type: "sequence", sequence: ["dawn", "noon", "dusk", "night"], objects: [["dawn", 250, 410], ["noon", 480, 190], ["dusk", 680, 410], ["night", 900, 190]] }),
|
||||
pickups: Object.freeze([["moon_coin", 550, 210], ["healing_tonic", 900, 430]])
|
||||
}),
|
||||
mountain: Object.freeze({
|
||||
name: "Mountain Pass", chapter: 3, palette: ["#202a35", "#465563", "#85909a", "#d4b06a"],
|
||||
spawns: Object.freeze({ forestTrail: { x: 100, y: 590 }, campRoad: { x: 1140, y: 560 } }),
|
||||
obstacles: Object.freeze([
|
||||
rect(0, 0, 1280, 34, "edge"), rect(0, 686, 1280, 34, "edge"), rect(0, 0, 34, 720, "edge"), rect(1246, 0, 34, 720, "edge"),
|
||||
rect(170, 60, 150, 430, "cliff"), rect(880, 70, 150, 440, "cliff"),
|
||||
rect(350, 520, 110, 90, "boulder"), rect(780, 500, 105, 100, "boulder")
|
||||
]),
|
||||
dynamicObstacles: Object.freeze([Object.freeze({ id: "bridge", x: 450, y: 225, width: 330, height: 120, kind: "chasm", opensWith: "bridge_repaired" })]),
|
||||
exits: Object.freeze([
|
||||
exit("forest", 35, 530, 75, 120, "forest", "mountainPath", null, "Return to the woods"),
|
||||
exit("camp", 1170, 510, 75, 125, "camp", "mountainRoad", "guardian_defeated", "Road to Blackridge Camp")
|
||||
]),
|
||||
npcs: Object.freeze([["bram", 350, 160]]),
|
||||
enemies: Object.freeze([enemy("bat", 390, 400), enemy("guard", 830, 390), enemy("stone_guardian", 1080, 335, { quest: "stone_guardian", boss: true, requires: "bridge_repaired", leash: 280 })]),
|
||||
puzzle: Object.freeze({ id: "bridge_winches", type: "set", sequence: ["west", "east"], objects: [["west", 380, 180], ["east", 845, 180]] }),
|
||||
pickups: Object.freeze([["moon_coin", 400, 640], ["healing_tonic", 850, 640]])
|
||||
}),
|
||||
camp: Object.freeze({
|
||||
name: "Blackridge Camp", chapter: 3, palette: ["#2b241f", "#584232", "#8b6a43", "#b98b50"],
|
||||
spawns: Object.freeze({ mountainRoad: { x: 100, y: 600 }, fortressRoad: { x: 1160, y: 330 } }),
|
||||
obstacles: Object.freeze([
|
||||
rect(0, 0, 1280, 30, "edge"), rect(0, 690, 1280, 30, "edge"), rect(0, 0, 30, 720, "edge"), rect(1250, 0, 30, 720, "edge"),
|
||||
rect(200, 90, 220, 150, "tent"), rect(520, 80, 220, 160, "tent"), rect(860, 80, 220, 160, "tent"),
|
||||
rect(260, 430, 250, 35, "barricade"), rect(730, 430, 280, 35, "barricade"),
|
||||
rect(520, 500, 20, 130, "cage"), rect(700, 500, 20, 130, "cage"), rect(520, 500, 200, 20, "cage"),
|
||||
rect(520, 610, 70, 20, "cage"), rect(650, 610, 70, 20, "cage")
|
||||
]),
|
||||
exits: Object.freeze([
|
||||
exit("mountain", 35, 550, 75, 120, "mountain", "campRoad", null, "Return to the pass"),
|
||||
exit("fortress", 1170, 280, 75, 120, "fortressExterior", "campGate", "emblem_found", "Fortress road")
|
||||
]),
|
||||
npcs: Object.freeze([["elowen", 620, 555]]),
|
||||
enemies: Object.freeze([enemy("guard", 340, 330, { quest: "free_scout" }), enemy("guard", 820, 340, { quest: "free_scout" }), enemy("captain", 1080, 530, { quest: "free_scout", boss: true, leash: 260 })]),
|
||||
pickups: Object.freeze([["healing_tonic", 170, 300], ["moon_coin", 1070, 280]])
|
||||
}),
|
||||
fortressExterior: Object.freeze({
|
||||
name: "Shadow Fortress Gate", chapter: 4, palette: ["#15131d", "#30283d", "#554a62", "#8a718f"],
|
||||
spawns: Object.freeze({ campGate: { x: 120, y: 590 }, innerGate: { x: 1100, y: 625 } }),
|
||||
obstacles: Object.freeze([
|
||||
rect(0, 0, 1280, 30, "edge"), rect(0, 690, 1280, 30, "edge"), rect(0, 0, 30, 720, "edge"), rect(1250, 0, 30, 720, "edge"),
|
||||
rect(120, 70, 1040, 85, "fortress-wall"), rect(120, 70, 95, 450, "fortress-wall"), rect(1065, 70, 95, 450, "fortress-wall"),
|
||||
rect(120, 500, 400, 75, "fortress-wall"), rect(760, 500, 400, 75, "fortress-wall"),
|
||||
circle(420, 320, 65, "tower"), circle(860, 320, 65, "tower")
|
||||
]),
|
||||
exits: Object.freeze([
|
||||
exit("camp", 35, 540, 80, 120, "camp", "fortressRoad", null, "Return to Blackridge"),
|
||||
exit("interior", 580, 485, 120, 90, "fortressInterior", "frontHall", "emblem_found", "Enter the fortress")
|
||||
]),
|
||||
npcs: Object.freeze([]),
|
||||
enemies: Object.freeze([enemy("guard", 330, 590), enemy("guard", 640, 350), enemy("guard", 950, 590)]),
|
||||
pickups: Object.freeze([["healing_tonic", 640, 200]])
|
||||
}),
|
||||
fortressInterior: Object.freeze({
|
||||
name: "Shadow Fortress", chapter: 4, palette: ["#111119", "#272333", "#51465d", "#b58b66"],
|
||||
spawns: Object.freeze({ frontHall: { x: 640, y: 620 }, throneDoor: { x: 640, y: 110 } }),
|
||||
obstacles: Object.freeze([
|
||||
rect(0, 0, 1280, 32, "edge"), rect(0, 688, 1280, 32, "edge"), rect(0, 0, 32, 720, "edge"), rect(1248, 0, 32, 720, "edge"),
|
||||
rect(170, 100, 35, 470, "wall"), rect(1075, 100, 35, 470, "wall"), rect(170, 100, 360, 35, "wall"), rect(750, 100, 360, 35, "wall"),
|
||||
rect(390, 260, 35, 300, "wall"), rect(855, 260, 35, 300, "wall"),
|
||||
rect(205, 420, 185, 35, "cell"), rect(890, 420, 185, 35, "cell")
|
||||
]),
|
||||
exits: Object.freeze([
|
||||
exit("outside", 580, 650, 120, 60, "fortressExterior", "innerGate", null, "Leave the fortress"),
|
||||
exit("throne", 580, 70, 120, 70, "bossArena", "entrance", "wards_broken", "Throne of Night")
|
||||
]),
|
||||
npcs: Object.freeze([["prisoner", 285, 350], ["elowen", 995, 350]]),
|
||||
enemies: Object.freeze([enemy("guard", 520, 470), enemy("guard", 760, 470), enemy("bat", 640, 220)]),
|
||||
puzzle: Object.freeze({ id: "shadow_wards", type: "set", sequence: ["moon", "crown", "flame"], objects: [["moon", 270, 180], ["crown", 640, 360], ["flame", 1010, 180]] }),
|
||||
pickups: Object.freeze([["prison_key", 640, 520], ["healing_tonic", 1010, 560]])
|
||||
}),
|
||||
bossArena: Object.freeze({
|
||||
name: "Throne of Night", chapter: 4, palette: ["#0d0b14", "#21182d", "#51335f", "#b76a85"],
|
||||
spawns: Object.freeze({ entrance: { x: 640, y: 620 } }),
|
||||
obstacles: Object.freeze([
|
||||
rect(0, 0, 1280, 42, "edge"), rect(0, 678, 1280, 42, "edge"), rect(0, 0, 42, 720, "edge"), rect(1238, 0, 42, 720, "edge"),
|
||||
circle(210, 180, 52, "pillar"), circle(1070, 180, 52, "pillar"), circle(210, 540, 52, "pillar"), circle(1070, 540, 52, "pillar")
|
||||
]),
|
||||
exits: Object.freeze([exit("chamber", 570, 30, 140, 70, "chamber", "door", "malrec_defeated", "Princess Lima's chamber")]),
|
||||
npcs: Object.freeze([["malrec", 640, 170]]),
|
||||
enemies: Object.freeze([enemy("malrec", 640, 260, { quest: "defeat_malrec", boss: true, requires: "boss_started", leash: 500 })]),
|
||||
puzzle: Object.freeze({ id: "sun_pedestals", type: "set", sequence: ["west", "east"], objects: [["west", 320, 360], ["east", 960, 360]] }),
|
||||
pickups: Object.freeze([])
|
||||
}),
|
||||
chamber: Object.freeze({
|
||||
name: "The Dawn Chamber", chapter: 4, palette: ["#293346", "#58687e", "#d2b878", "#f1e4c4"],
|
||||
spawns: Object.freeze({ door: { x: 640, y: 610 } }),
|
||||
obstacles: Object.freeze([
|
||||
rect(0, 0, 1280, 34, "edge"), rect(0, 686, 1280, 34, "edge"), rect(0, 0, 34, 720, "edge"), rect(1246, 0, 34, 720, "edge"),
|
||||
rect(180, 90, 250, 80, "balcony"), rect(850, 90, 250, 80, "balcony"), circle(640, 270, 70, "dais")
|
||||
]),
|
||||
exits: Object.freeze([]),
|
||||
npcs: Object.freeze([["lima", 640, 180]]),
|
||||
enemies: Object.freeze([]),
|
||||
pickups: Object.freeze([])
|
||||
})
|
||||
});
|
||||
|
||||
function edges() {
|
||||
const thickness = 18;
|
||||
return [
|
||||
rect(0, 0, WIDTH, thickness, "edge"),
|
||||
rect(0, HEIGHT - thickness, WIDTH, thickness, "edge"),
|
||||
rect(0, 0, thickness, HEIGHT, "edge"),
|
||||
rect(WIDTH - thickness, 0, thickness, HEIGHT, "edge")
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
* Coordinates below are authored against the nine 418x418 atlas panels
|
||||
* displayed at 720x720. They describe only visible, solid terrain. The
|
||||
* geometry is never rendered in production; ?collisionDebug=1 reveals it.
|
||||
*/
|
||||
const VISUAL_LAYOUT = Object.freeze({
|
||||
village: Object.freeze({
|
||||
spawns: Object.freeze({ start: { x: 365, y: 665 }, square: { x: 365, y: 355 }, forestRoad: { x: 365, y: 52 } }),
|
||||
obstacles: Object.freeze([
|
||||
...edges({ top: [318, 414], bottom: [330, 430] }),
|
||||
rect(18, 18, 176, 205, "house"), rect(454, 18, 248, 205, "house"),
|
||||
rect(18, 248, 118, 132, "cart"), rect(18, 475, 286, 227, "house"),
|
||||
rect(498, 338, 98, 104, "well"), rect(466, 465, 236, 92, "wall"),
|
||||
rect(548, 560, 154, 142, "cart")
|
||||
]),
|
||||
exits: Object.freeze([exit("forest-road", 330, 0, 70, 48, "forest", "villagePath", "village_defended", "Road to the Whispering Woods")]),
|
||||
npcs: Object.freeze([["elder", 365, 292], ["bram", 270, 350], ["nia", 466, 350]]),
|
||||
enemies: Object.freeze([
|
||||
enemy("slime", 260, 435, { quest: "village_defence", requires: "aftermath_complete" }),
|
||||
enemy("bandit", 365, 500, { quest: "village_defence", requires: "aftermath_complete" }),
|
||||
enemy("slime", 470, 420, { quest: "village_defence", requires: "aftermath_complete" })
|
||||
]),
|
||||
pickups: Object.freeze([["silver_leaf", 225, 265], ["silver_leaf", 515, 280], ["moon_coin", 440, 625]])
|
||||
}),
|
||||
forest: Object.freeze({
|
||||
spawns: Object.freeze({ villagePath: { x: 355, y: 660 }, ruinsPath: { x: 660, y: 392 }, mountainPath: { x: 355, y: 55 } }),
|
||||
obstacles: Object.freeze([
|
||||
...edges({ top: [315, 405], right: [348, 438], bottom: [318, 408] }),
|
||||
rect(18, 18, 220, 330, "trees"), rect(18, 418, 218, 284, "trees"),
|
||||
rect(470, 18, 232, 184, "trees"), rect(535, 202, 167, 146, "water"),
|
||||
rect(535, 438, 167, 264, "water"), rect(238, 18, 80, 178, "trees"),
|
||||
rect(235, 515, 72, 187, "trees"), rect(438, 475, 97, 227, "trees"),
|
||||
rect(470, 250, 65, 105, "trees")
|
||||
]),
|
||||
exits: Object.freeze([
|
||||
exit("village", 325, 672, 75, 48, "village", "forestRoad", null, "Return to the village"),
|
||||
exit("ruins", 665, 360, 55, 72, "ruins", "forestDoor", "guide_found", "Sunken Ruins"),
|
||||
exit("mountain", 325, 0, 72, 48, "mountain", "forestTrail", "briar_defeated", "Mountain trail")
|
||||
]),
|
||||
npcs: Object.freeze([["tovin", 390, 535]]),
|
||||
enemies: Object.freeze([
|
||||
enemy("wolf", 330, 225), enemy("slime", 420, 580), enemy("bandit", 430, 310),
|
||||
enemy("wolf", 560, 400), enemy("briar_wolf", 360, 110, { quest: "wolf_miniboss", boss: true, requires: "ruins_complete", leash: 190 })
|
||||
]),
|
||||
puzzle: Object.freeze({ id: "forest_stones", type: "sequence", sequence: ["sapling", "oak", "elder"], objects: [["sapling", 340, 565], ["oak", 365, 420], ["elder", 405, 270]] }),
|
||||
pickups: Object.freeze([["silver_leaf", 350, 610], ["silver_leaf", 445, 180], ["healing_tonic", 585, 390]])
|
||||
}),
|
||||
ruins: Object.freeze({
|
||||
spawns: Object.freeze({ forestDoor: { x: 360, y: 660 } }),
|
||||
obstacles: Object.freeze([
|
||||
...edges({ bottom: [320, 410] }),
|
||||
rect(18, 18, 248, 268, "water"), rect(454, 18, 248, 268, "water"),
|
||||
rect(18, 286, 180, 416, "water"), rect(522, 286, 180, 416, "water"),
|
||||
rect(198, 460, 92, 242, "water"), rect(430, 460, 92, 242, "water"),
|
||||
rect(266, 18, 55, 172, "ruin-wall"), rect(399, 18, 55, 172, "ruin-wall"),
|
||||
rect(198, 286, 92, 82, "water"), rect(430, 286, 92, 82, "water")
|
||||
]),
|
||||
exits: Object.freeze([exit("forest", 325, 672, 75, 48, "forest", "ruinsPath", null, "Return to the woods")]),
|
||||
npcs: Object.freeze([]),
|
||||
enemies: Object.freeze([enemy("bat", 350, 220), enemy("bat", 400, 370), enemy("slime", 350, 500)]),
|
||||
puzzle: Object.freeze({ id: "ruin_braziers", type: "sequence", sequence: ["dawn", "noon", "dusk", "night"], objects: [["dawn", 270, 430], ["noon", 325, 245], ["dusk", 450, 430], ["night", 395, 245]] }),
|
||||
pickups: Object.freeze([["moon_coin", 360, 330], ["healing_tonic", 390, 525]])
|
||||
}),
|
||||
mountain: Object.freeze({
|
||||
spawns: Object.freeze({
|
||||
forestTrail: { x: 365, y: 660 },
|
||||
campRoad: { x: 355, y: 55 },
|
||||
bridgeControls: { x: 365, y: 430 }
|
||||
}),
|
||||
obstacles: Object.freeze([
|
||||
...edges({ top: [315, 405], bottom: [320, 410] }),
|
||||
rect(18, 18, 230, 684, "cliff"), rect(500, 18, 202, 684, "cliff"),
|
||||
rect(248, 18, 67, 208, "cliff"), rect(405, 18, 95, 245, "cliff"),
|
||||
rect(248, 430, 62, 272, "cliff"), rect(430, 455, 70, 247, "cliff"),
|
||||
rect(248, 295, 58, 85, "cliff"), rect(440, 315, 60, 92, "cliff")
|
||||
]),
|
||||
dynamicObstacles: Object.freeze([Object.freeze({ id: "bridge", x: 306, y: 315, width: 134, height: 58, kind: "chasm", opensWith: "bridge_repaired" })]),
|
||||
exits: Object.freeze([
|
||||
exit("forest", 325, 672, 75, 48, "forest", "mountainPath", null, "Return to the woods"),
|
||||
exit("camp", 325, 0, 75, 48, "camp", "mountainRoad", "guardian_defeated", "Road to Blackridge Camp")
|
||||
]),
|
||||
npcs: Object.freeze([["bram", 325, 250]]),
|
||||
enemies: Object.freeze([enemy("guard", 360, 230), enemy("stone_guardian", 365, 115, { quest: "stone_guardian", boss: true, requires: "bridge_repaired", leash: 180 })]),
|
||||
puzzle: Object.freeze({ id: "bridge_winches", type: "set", sequence: ["west", "east"], objects: [["west", 335, 405], ["east", 395, 445]] }),
|
||||
pickups: Object.freeze([["moon_coin", 340, 575], ["healing_tonic", 400, 520]])
|
||||
}),
|
||||
camp: Object.freeze({
|
||||
spawns: Object.freeze({ mountainRoad: { x: 360, y: 660 }, fortressRoad: { x: 360, y: 55 } }),
|
||||
obstacles: Object.freeze([
|
||||
...edges({ top: [320, 405], bottom: [320, 405] }),
|
||||
rect(18, 18, 255, 175, "tent"), rect(455, 18, 247, 190, "tower"),
|
||||
rect(18, 518, 250, 184, "fence"), rect(468, 515, 234, 187, "fence"),
|
||||
rect(18, 193, 42, 325, "fence"), rect(660, 208, 42, 307, "fence"),
|
||||
rect(90, 365, 155, 75, "weapons"), rect(482, 380, 150, 78, "tent")
|
||||
]),
|
||||
exits: Object.freeze([
|
||||
exit("mountain", 325, 672, 75, 48, "mountain", "campRoad", null, "Return to the pass"),
|
||||
exit("fortress", 325, 0, 75, 48, "fortressExterior", "campGate", "emblem_found", "Fortress road")
|
||||
]),
|
||||
npcs: Object.freeze([["elowen", 440, 500]]),
|
||||
enemies: Object.freeze([enemy("guard", 250, 300, { quest: "free_scout" }), enemy("guard", 495, 300, { quest: "free_scout" }), enemy("captain", 420, 585, { quest: "free_scout", boss: true, leash: 180 })]),
|
||||
pickups: Object.freeze([["healing_tonic", 120, 260], ["moon_coin", 585, 250]])
|
||||
}),
|
||||
fortressExterior: Object.freeze({
|
||||
spawns: Object.freeze({ campGate: { x: 360, y: 660 }, innerGate: { x: 360, y: 455 } }),
|
||||
obstacles: Object.freeze([
|
||||
...edges({ bottom: [315, 405] }),
|
||||
rect(18, 18, 286, 430, "fortress-wall"), rect(416, 18, 286, 430, "fortress-wall"),
|
||||
rect(18, 448, 266, 254, "chasm"), rect(436, 448, 266, 254, "chasm"),
|
||||
rect(284, 18, 56, 310, "tower"), rect(380, 18, 56, 310, "tower")
|
||||
]),
|
||||
exits: Object.freeze([
|
||||
exit("camp", 325, 672, 75, 48, "camp", "fortressRoad", null, "Return to Blackridge"),
|
||||
exit("interior", 335, 285, 50, 64, "fortressInterior", "frontHall", "emblem_found", "Enter the fortress")
|
||||
]),
|
||||
npcs: Object.freeze([]),
|
||||
enemies: Object.freeze([enemy("guard", 330, 570), enemy("guard", 390, 445), enemy("guard", 350, 365)]),
|
||||
pickups: Object.freeze([["healing_tonic", 410, 520]])
|
||||
}),
|
||||
fortressInterior: Object.freeze({
|
||||
spawns: Object.freeze({ frontHall: { x: 360, y: 660 }, throneDoor: { x: 360, y: 95 } }),
|
||||
obstacles: Object.freeze([
|
||||
...edges({ top: [320, 400], bottom: [320, 400] }),
|
||||
rect(18, 18, 300, 160, "wall"), rect(402, 18, 300, 160, "wall"),
|
||||
rect(18, 178, 250, 175, "cell"), rect(452, 178, 250, 175, "cell"),
|
||||
rect(18, 420, 250, 282, "cell"), rect(452, 420, 250, 282, "cell"),
|
||||
rect(268, 178, 50, 110, "wall"), rect(402, 178, 50, 110, "wall")
|
||||
]),
|
||||
exits: Object.freeze([
|
||||
exit("outside", 325, 672, 75, 48, "fortressExterior", "innerGate", null, "Leave the fortress"),
|
||||
exit("throne", 325, 0, 75, 48, "bossArena", "entrance", "wards_broken", "Throne of Night")
|
||||
]),
|
||||
npcs: Object.freeze([["prisoner", 280, 375], ["elowen", 440, 375]]),
|
||||
enemies: Object.freeze([enemy("guard", 320, 500), enemy("guard", 400, 500), enemy("bat", 360, 240)]),
|
||||
puzzle: Object.freeze({ id: "shadow_wards", type: "set", sequence: ["moon", "crown", "flame"], objects: [["moon", 335, 205], ["crown", 360, 390], ["flame", 385, 205]] }),
|
||||
pickups: Object.freeze([["prison_key", 360, 540], ["healing_tonic", 425, 580]])
|
||||
}),
|
||||
bossArena: Object.freeze({
|
||||
spawns: Object.freeze({ entrance: { x: 360, y: 650 } }),
|
||||
obstacles: Object.freeze([
|
||||
...edges({ top: [320, 400] }),
|
||||
rect(18, 18, 175, 235, "wall"), rect(527, 18, 175, 235, "wall"),
|
||||
rect(18, 545, 190, 157, "wall"), rect(512, 545, 190, 157, "wall"),
|
||||
rect(250, 18, 220, 104, "throne")
|
||||
]),
|
||||
exits: Object.freeze([exit("chamber", 330, 0, 60, 48, "chamber", "door", "malrec_defeated", "Princess Lima's chamber")]),
|
||||
npcs: Object.freeze([["malrec", 360, 165]]),
|
||||
enemies: Object.freeze([enemy("malrec", 360, 245, { quest: "defeat_malrec", boss: true, requires: "boss_started", leash: 300 })]),
|
||||
puzzle: Object.freeze({ id: "sun_pedestals", type: "set", sequence: ["west", "east"], objects: [["west", 220, 390], ["east", 500, 390]] }),
|
||||
pickups: Object.freeze([])
|
||||
}),
|
||||
chamber: Object.freeze({
|
||||
spawns: Object.freeze({ door: { x: 360, y: 660 } }),
|
||||
obstacles: Object.freeze([
|
||||
...edges({ bottom: [320, 400] }),
|
||||
rect(18, 18, 265, 120, "wall"), rect(438, 18, 264, 120, "wall"),
|
||||
rect(465, 155, 220, 190, "bed"), rect(460, 475, 225, 190, "table"),
|
||||
rect(35, 475, 125, 190, "fountain")
|
||||
]),
|
||||
exits: Object.freeze([]),
|
||||
npcs: Object.freeze([["lima", 360, 180]]),
|
||||
enemies: Object.freeze([]),
|
||||
pickups: Object.freeze([])
|
||||
})
|
||||
});
|
||||
|
||||
const MAPS = Object.freeze(Object.fromEntries(Object.keys(LEGACY_MAPS).map((id) => [
|
||||
id,
|
||||
Object.freeze(Object.assign({}, LEGACY_MAPS[id], VISUAL_LAYOUT[id]))
|
||||
])));
|
||||
|
||||
function tiledPoint(name, type, x, y, properties) {
|
||||
return Object.freeze({
|
||||
name, type, x, y, point: true,
|
||||
properties: Object.freeze(Object.assign({}, properties || {}))
|
||||
});
|
||||
}
|
||||
|
||||
function tiledLayer(name, objects) {
|
||||
return Object.freeze({ type: "objectgroup", name, visible: true, objects: Object.freeze(objects) });
|
||||
}
|
||||
|
||||
/*
|
||||
* Tiled-compatible object-layer view of every authored region. Runtime
|
||||
* collision reads these named layers, and the remaining layers give map
|
||||
* editing/export tools a single inspectable contract for game objects.
|
||||
*/
|
||||
const MAP_LAYERS = Object.freeze(Object.fromEntries(Object.entries(MAPS).map(([regionId, map]) => [
|
||||
regionId,
|
||||
Object.freeze([
|
||||
tiledLayer("Collision", map.obstacles),
|
||||
tiledLayer("Dynamic Collision", map.dynamicObstacles || []),
|
||||
tiledLayer("Exits", (map.exits || []).map((item) => Object.freeze(Object.assign({ type: "exit" }, item)))),
|
||||
tiledLayer("NPCs", (map.npcs || []).map(([id, x, y]) => tiledPoint(id, "npc", x, y))),
|
||||
tiledLayer("Enemies", (map.enemies || []).map((item) => tiledPoint(item.type, "enemy", item.x, item.y, item))),
|
||||
tiledLayer("Puzzles", map.puzzle ? map.puzzle.objects.map(([id, x, y]) =>
|
||||
tiledPoint(id, "puzzle", x, y, { puzzle: map.puzzle.id })) : []),
|
||||
tiledLayer("Items", (map.pickups || []).map(([id, x, y]) => tiledPoint(id, "item", x, y)))
|
||||
])
|
||||
])));
|
||||
|
||||
const MAP_IDS = Object.freeze(Object.keys(MAPS));
|
||||
const QUEST_IDS = Object.freeze(Object.keys(QUESTS));
|
||||
const ITEM_IDS = Object.freeze(Object.keys(ITEMS));
|
||||
const ENEMY_IDS = Object.freeze(Object.keys(ENEMIES));
|
||||
const NPC_IDS = Object.freeze(Object.keys(NPCS));
|
||||
|
||||
return Object.freeze({
|
||||
WIDTH, HEIGHT, ITEMS, QUESTS, ENEMIES, NPCS, MAPS, MAP_LAYERS,
|
||||
MAP_IDS, QUEST_IDS, ITEM_IDS, ENEMY_IDS, NPC_IDS
|
||||
});
|
||||
}));
|
||||
@@ -1,417 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const Data = window.PrincessLimaData;
|
||||
const State = window.PrincessLimaState;
|
||||
const Systems = window.PrincessLimaSystems;
|
||||
const Scenes = window.PrincessLimaScenes;
|
||||
const UI = window.PrincessLimaUI;
|
||||
const Audio = window.PrincessLimaAudio;
|
||||
const Intro = window.PrincessLimaIntro;
|
||||
|
||||
const controller = {
|
||||
root: null,
|
||||
game: null,
|
||||
scene: null,
|
||||
state: null,
|
||||
audio: null,
|
||||
intro: null,
|
||||
ui: null,
|
||||
locked: true,
|
||||
transitioning: false,
|
||||
lastSaveAt: 0,
|
||||
systemReducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
getState: () => controller.state,
|
||||
reducedMotion: () => controller.state && controller.state.settings.reducedMotion !== null
|
||||
? controller.state.settings.reducedMotion : controller.systemReducedMotion,
|
||||
debugCollision: false,
|
||||
devWarn: (message) => {
|
||||
if (["localhost", "127.0.0.1"].includes(window.location.hostname)) console.warn(`[Princess Lima RPG] ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init, { once: true });
|
||||
|
||||
function init() {
|
||||
const root = document.querySelector("[data-princess-lima-rpg]");
|
||||
if (!root || !Data || !State || !Systems || !Scenes || !UI || !Audio || !Intro || !window.PrincessLimaMaps || !window.Phaser) return gracefulFailure();
|
||||
controller.root = root;
|
||||
controller.state = loadState();
|
||||
controller.audio = Audio.create(() => controller.state);
|
||||
controller.debugCollision = localDebug();
|
||||
controller.ui = UI.create(root, {
|
||||
getState: () => controller.state,
|
||||
onLock: (value) => { controller.locked = value; },
|
||||
onUseTonic: useTonic,
|
||||
onSetting: updateSetting,
|
||||
onFullscreen: toggleFullscreen,
|
||||
onReplayIntro: () => {
|
||||
controller.ui.close();
|
||||
startIntro(true);
|
||||
},
|
||||
onReset: resetSave,
|
||||
onRespawn: respawn
|
||||
});
|
||||
controller.intro = Intro.create(root, {
|
||||
onLock: (value) => { controller.locked = value; },
|
||||
onAudioUnlock: () => controller.audio.unlock(),
|
||||
onComplete: completeIntro,
|
||||
playVoice: (key) => controller.audio.playVoice(key),
|
||||
stopVoice: () => controller.audio.stopVoice(),
|
||||
playEffect: (key, volume) => controller.audio.play(key, volume),
|
||||
narrationEnabled: () => !controller.state || controller.state.settings.narrationEnabled,
|
||||
subtitlesEnabled: () => !controller.state || controller.state.settings.subtitles,
|
||||
soundEnabled: () => Boolean(controller.state && controller.state.settings.soundEnabled),
|
||||
toggleSound,
|
||||
status
|
||||
});
|
||||
bindInterface();
|
||||
startEngine();
|
||||
window.addEventListener("error", (event) => {
|
||||
controller.devWarn(event.message);
|
||||
status("The game recovered from an unexpected problem. Open the pause menu if controls do not respond.");
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) controller.audio.suspend();
|
||||
else controller.audio.resume();
|
||||
});
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(State.STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = State.parse(raw);
|
||||
if (!parsed) {
|
||||
queueStatus("The old save was invalid, so it was ignored safely.");
|
||||
return null;
|
||||
}
|
||||
const debugRegion = localRegionPreview();
|
||||
if (debugRegion) {
|
||||
const map = Data.MAPS[debugRegion];
|
||||
const requestedSpawn = new URLSearchParams(window.location.search).get("spawn");
|
||||
const spawn = map.spawns[requestedSpawn] ? [requestedSpawn, map.spawns[requestedSpawn]]
|
||||
: Object.entries(map.spawns)[0];
|
||||
return State.withPosition(parsed, debugRegion, spawn[0], spawn[1].x, spawn[1].y, "south");
|
||||
}
|
||||
const safe = Systems.nearestSafeSpawn(parsed, parsed.region, parsed.position.x, parsed.position.y);
|
||||
return State.withPosition(parsed, safe.region, safe.spawn, safe.x, safe.y, parsed.position.facing);
|
||||
} catch (_error) {
|
||||
queueStatus("Local saving is unavailable. You can still play this session.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function startEngine() {
|
||||
try {
|
||||
const classes = Scenes.createSceneClasses(controller);
|
||||
controller.game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
width: Data.WIDTH,
|
||||
height: Data.HEIGHT,
|
||||
parent: "princess-lima-game",
|
||||
pixelArt: true,
|
||||
roundPixels: true,
|
||||
backgroundColor: "#0a0b12",
|
||||
physics: { default: "arcade", arcade: { debug: localDebug(), gravity: { x: 0, y: 0 } } },
|
||||
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, width: Data.WIDTH, height: Data.HEIGHT },
|
||||
scene: classes,
|
||||
render: { antialias: false, pixelArt: true }
|
||||
});
|
||||
} catch (error) {
|
||||
controller.devWarn(error.message);
|
||||
gracefulFailure("The game engine could not start. You can return safely to the website.");
|
||||
}
|
||||
}
|
||||
|
||||
function ready() {
|
||||
controller.root.dataset.ready = "true";
|
||||
const continueButton = controller.root.querySelector("[data-lima-continue]");
|
||||
continueButton.disabled = !controller.state;
|
||||
continueButton.textContent = controller.state ? `Continue · Chapter ${controller.state.chapter}` : "Continue";
|
||||
controller.root.querySelector("[data-lima-loading]").hidden = true;
|
||||
controller.root.querySelector("[data-lima-menu]").hidden = false;
|
||||
flushQueuedStatus();
|
||||
}
|
||||
|
||||
function bindInterface() {
|
||||
const root = controller.root;
|
||||
root.querySelector("[data-lima-new]").addEventListener("click", () => openSetup());
|
||||
root.querySelector("[data-lima-continue]").addEventListener("click", () => {
|
||||
if (controller.state) {
|
||||
controller.audio.unlock();
|
||||
if (controller.state.introSeen) beginAdventure();
|
||||
else startIntro(false);
|
||||
}
|
||||
});
|
||||
root.querySelector("[data-lima-replay-intro]").addEventListener("click", () => startIntro(true));
|
||||
root.querySelector("[data-lima-menu-settings]").addEventListener("click", () => {
|
||||
if (controller.state) openPanel("settings");
|
||||
else controller.ui.show("settings-help", "Settings", "<p>Create a traveller to save audio and accessibility preferences. The introduction always includes subtitles and can be muted or skipped.</p>");
|
||||
});
|
||||
root.querySelector("[data-lima-credits]").addEventListener("click", () => controller.ui.show(
|
||||
"credits", "Credits",
|
||||
"<p>Designed and built for zainezq.com. Original fantasy artwork and locally generated audio. Powered by locally vendored Phaser.</p>"
|
||||
));
|
||||
root.querySelector("[data-lima-setup-form]").addEventListener("submit", submitSetup);
|
||||
root.querySelector("[data-lima-setup-cancel]").addEventListener("click", closeSetup);
|
||||
bindButton("[data-lima-pause]", () => openPanel("pause"));
|
||||
bindButton("[data-lima-inventory]", () => openPanel("inventory"));
|
||||
bindButton("[data-lima-quests]", () => openPanel("quests"));
|
||||
bindButton("[data-lima-sound]", toggleSound);
|
||||
bindButton("[data-lima-fullscreen]", toggleFullscreen);
|
||||
document.addEventListener("fullscreenchange", updateFullscreenButton);
|
||||
}
|
||||
|
||||
function bindButton(selector, handler) {
|
||||
controller.root.querySelectorAll(selector).forEach((button) => button.addEventListener("click", handler));
|
||||
}
|
||||
|
||||
function openSetup() {
|
||||
controller.root.querySelector("[data-lima-menu]").hidden = true;
|
||||
controller.root.querySelector("[data-lima-setup]").hidden = false;
|
||||
controller.root.querySelector("[data-lima-setup] input[name=name]").focus();
|
||||
}
|
||||
|
||||
function closeSetup() {
|
||||
controller.root.querySelector("[data-lima-setup]").hidden = true;
|
||||
controller.root.querySelector("[data-lima-menu]").hidden = false;
|
||||
}
|
||||
|
||||
function submitSetup(event) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const name = State.validName(form.elements.name.value);
|
||||
const appearance = form.elements.appearance.value;
|
||||
const error = controller.root.querySelector("[data-lima-setup-error]");
|
||||
if (!name || !State.APPEARANCES.includes(appearance)) {
|
||||
error.textContent = "Enter a name from 1 to 20 characters and choose an appearance.";
|
||||
return;
|
||||
}
|
||||
controller.state = State.fresh(name, appearance);
|
||||
save();
|
||||
closeSetup();
|
||||
startIntro(false);
|
||||
}
|
||||
|
||||
function beginAdventure() {
|
||||
controller.audio.unlock();
|
||||
controller.root.querySelector("[data-lima-menu]").hidden = true;
|
||||
controller.root.querySelector("[data-lima-intro]").hidden = true;
|
||||
controller.root.querySelector("[data-lima-hud]").hidden = false;
|
||||
controller.root.querySelector("[data-lima-status-stack]").hidden = false;
|
||||
controller.locked = false;
|
||||
controller.game.scene.start("LimaWorld", { region: controller.state.region });
|
||||
focusGame();
|
||||
}
|
||||
|
||||
function startIntro(replay) {
|
||||
controller.audio.unlock();
|
||||
controller.root.querySelector("[data-lima-menu]").hidden = true;
|
||||
controller.root.querySelector("[data-lima-hud]").hidden = true;
|
||||
controller.root.querySelector("[data-lima-status-stack]").hidden = true;
|
||||
controller.intro.start({ replay });
|
||||
}
|
||||
|
||||
function completeIntro(result) {
|
||||
if (controller.state && !result.replay) {
|
||||
controller.state.introSeen = true;
|
||||
controller.state = State.normalize(controller.state);
|
||||
save();
|
||||
beginAdventure();
|
||||
return;
|
||||
}
|
||||
controller.locked = true;
|
||||
controller.root.querySelector("[data-lima-menu]").hidden = false;
|
||||
controller.root.querySelector("[data-lima-menu] button").focus();
|
||||
}
|
||||
|
||||
function travel(region, spawn) {
|
||||
if (controller.transitioning || !Data.MAPS[region]) return;
|
||||
controller.transitioning = true;
|
||||
persistPosition(false);
|
||||
const point = Data.MAPS[region].spawns[spawn] || Object.values(Data.MAPS[region].spawns)[0];
|
||||
let next = State.withPosition(controller.state, region, spawn, point.x, point.y, "south");
|
||||
next = State.withCheckpoint(next, region, spawn, point.x, point.y);
|
||||
setState(next, `Travelling to ${Data.MAPS[region].name}…`);
|
||||
controller.audio.play("door");
|
||||
try {
|
||||
controller.scene.scene.restart({ region });
|
||||
} catch (error) {
|
||||
controller.devWarn(`Transition recovered: ${error.message}`);
|
||||
controller.transitioning = false;
|
||||
controller.game.scene.start("LimaWorld", { region });
|
||||
}
|
||||
}
|
||||
|
||||
function reloadRegion() {
|
||||
if (controller.scene) controller.scene.scene.restart({ region: controller.state.region });
|
||||
}
|
||||
|
||||
function persistPosition(checkpoint) {
|
||||
if (!controller.scene || !controller.scene.player || !controller.state) return;
|
||||
const safe = Systems.nearestSafeSpawn(controller.state, controller.scene.regionId, controller.scene.player.x, controller.scene.player.y);
|
||||
let next = State.withPosition(controller.state, safe.region, safe.spawn, safe.x, safe.y, controller.scene.facing);
|
||||
if (checkpoint) next = State.withCheckpoint(next, safe.region, safe.spawn, safe.x, safe.y);
|
||||
controller.state = next;
|
||||
save();
|
||||
}
|
||||
|
||||
function setState(next, message) {
|
||||
const normalized = State.normalize(next);
|
||||
if (!normalized) return;
|
||||
controller.state = normalized;
|
||||
save();
|
||||
updateHud();
|
||||
controller.audio.apply();
|
||||
if (message) status(message);
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!controller.state) return;
|
||||
try { localStorage.setItem(State.STORAGE_KEY, JSON.stringify(controller.state)); } catch (_error) { /* Session play remains available. */ }
|
||||
}
|
||||
|
||||
function resetSave() {
|
||||
try { localStorage.removeItem(State.STORAGE_KEY); } catch (_error) { /* Nothing else to clear. */ }
|
||||
controller.state = null;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function respawn() {
|
||||
controller.state = Systems.respawn(controller.state);
|
||||
save();
|
||||
controller.ui.close();
|
||||
controller.locked = false;
|
||||
controller.game.scene.start("LimaWorld", { region: controller.state.region });
|
||||
}
|
||||
|
||||
function useTonic() {
|
||||
if (!controller.state) return;
|
||||
const result = Systems.useItem(controller.state, "healing_tonic");
|
||||
if (!result.used) return status(controller.state.health >= controller.state.maxHealth ? "Health is already full." : "No Healing Tonics remain.");
|
||||
setState(result.state, `Healing Tonic restores ${result.amount} health.`);
|
||||
}
|
||||
|
||||
function updateSetting(key, value) {
|
||||
if (!controller.state || !(key in controller.state.settings)) return;
|
||||
controller.state.settings[key] = value;
|
||||
setState(controller.state);
|
||||
controller.root.classList.toggle("is-high-contrast", controller.state.settings.highContrast);
|
||||
}
|
||||
|
||||
function toggleSound() {
|
||||
if (!controller.state) return;
|
||||
controller.audio.unlock();
|
||||
controller.state.settings.soundEnabled = !controller.state.settings.soundEnabled;
|
||||
setState(controller.state, controller.state.settings.soundEnabled ? "Audio enabled." : "Audio muted.");
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
const shell = controller.root;
|
||||
try {
|
||||
if (!document.fullscreenElement) await shell.requestFullscreen();
|
||||
else await document.exitFullscreen();
|
||||
} catch (_error) {
|
||||
status("Fullscreen is not available in this browser.");
|
||||
}
|
||||
}
|
||||
|
||||
function updateFullscreenButton() {
|
||||
const button = controller.root.querySelector("[data-lima-fullscreen]");
|
||||
const active = Boolean(document.fullscreenElement);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
button.textContent = active ? "Exit Fullscreen" : "Fullscreen";
|
||||
if (!active) focusGame();
|
||||
}
|
||||
|
||||
function openDialogue(id, done) {
|
||||
controller.ui.openDialogue(id, done);
|
||||
}
|
||||
|
||||
function openPanel(kind) {
|
||||
if (controller.state) controller.ui.openPanel(kind, controller.state);
|
||||
}
|
||||
|
||||
function gameOver() {
|
||||
controller.locked = true;
|
||||
controller.ui.gameOver(controller.state);
|
||||
}
|
||||
|
||||
function openEnding() {
|
||||
controller.locked = true;
|
||||
controller.ui.ending(controller.state);
|
||||
}
|
||||
|
||||
function updateHud() {
|
||||
if (!controller.state || !controller.root) return;
|
||||
text("[data-lima-player]", controller.state.player.name);
|
||||
text("[data-lima-health]", `${Math.ceil(controller.state.health)} / ${controller.state.maxHealth}`);
|
||||
text("[data-lima-region]", Data.MAPS[controller.state.region].name);
|
||||
text("[data-lima-chapter]", `Chapter ${controller.state.chapter}`);
|
||||
text("[data-lima-objective]", Systems.currentObjective(controller.state));
|
||||
text("[data-lima-tonics]", `Tonic ×${Systems.quantity(controller.state, "healing_tonic")}`);
|
||||
text("[data-lima-weapon]", controller.state.equipment.weapon ? Data.ITEMS[controller.state.equipment.weapon].name : "Unarmed");
|
||||
text("[data-lima-armour]", controller.state.equipment.armour ? Data.ITEMS[controller.state.equipment.armour].name : "None");
|
||||
text("[data-lima-selected-item]", `Healing Tonic ×${Systems.quantity(controller.state, "healing_tonic")}`);
|
||||
text("[data-lima-currency]", String(Systems.quantity(controller.state, "moon_coin")));
|
||||
text("[data-lima-sound]", controller.state.settings.soundEnabled ? "Sound On" : "Sound Muted");
|
||||
const healthBar = controller.root.querySelector(".lima-hud__healthbar i");
|
||||
if (healthBar) healthBar.style.width = `${controller.state.health / controller.state.maxHealth * 100}%`;
|
||||
controller.root.classList.toggle("is-high-contrast", controller.state.settings.highContrast);
|
||||
}
|
||||
|
||||
function cycleItem() {
|
||||
status("Healing Tonic selected. Press Q to use it.");
|
||||
}
|
||||
|
||||
function prompt(message) {
|
||||
text("[data-lima-prompt]", message || "Explore the road ahead.");
|
||||
}
|
||||
|
||||
function status(message) {
|
||||
text("[data-lima-status]", message);
|
||||
}
|
||||
|
||||
function boss(name, health, maximum) {
|
||||
const hud = controller.root.querySelector("[data-lima-boss]");
|
||||
hud.hidden = false;
|
||||
text("[data-lima-boss-name]", name);
|
||||
hud.querySelector("i").style.width = `${Math.max(0, health / maximum) * 100}%`;
|
||||
}
|
||||
|
||||
function text(selector, value) {
|
||||
const node = controller.root && controller.root.querySelector(selector);
|
||||
if (node) node.textContent = value;
|
||||
}
|
||||
|
||||
function focusGame() {
|
||||
const game = controller.root.querySelector("[data-lima-game]");
|
||||
if (game) game.focus();
|
||||
}
|
||||
|
||||
function localDebug() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return ["localhost", "127.0.0.1"].includes(window.location.hostname) && params.get("collisionDebug") === "1";
|
||||
}
|
||||
|
||||
function localRegionPreview() {
|
||||
if (!["localhost", "127.0.0.1"].includes(window.location.hostname)) return null;
|
||||
const region = new URLSearchParams(window.location.search).get("region");
|
||||
return Data.MAPS[region] ? region : null;
|
||||
}
|
||||
|
||||
let queuedStatus = "";
|
||||
function queueStatus(message) { queuedStatus = message; }
|
||||
function flushQueuedStatus() { if (queuedStatus) status(queuedStatus); }
|
||||
|
||||
function gracefulFailure(message) {
|
||||
const loading = document.querySelector("[data-lima-loading]");
|
||||
if (loading) loading.innerHTML = `<strong>The adventure could not start.</strong><span>${message || "A required local game file is unavailable."}</span><a href="/">Exit to Website</a>`;
|
||||
}
|
||||
|
||||
Object.assign(controller, {
|
||||
ready, travel, reloadRegion, persistPosition, setState, updateHud, status, prompt, boss,
|
||||
openDialogue, openPanel, gameOver, openEnding, useTonic, toggleFullscreen, toggleSound, cycleItem
|
||||
});
|
||||
}());
|
||||
@@ -1,154 +0,0 @@
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
const BEATS = Object.freeze([
|
||||
Object.freeze({
|
||||
panel: 0, duration: 15000, voice: "intro-narration-1",
|
||||
subtitle: "Before shadow crossed the northern road, Princess Lima walked among her people—listening before she ruled, and helping before she asked."
|
||||
}),
|
||||
Object.freeze({
|
||||
panel: 1, duration: 14000, voice: "intro-narration-2", effect: "door",
|
||||
subtitle: "Then Lord Malrec descended from the Fortress of Shadows. His riders carried fear through the valleys, searching for the royal oath."
|
||||
}),
|
||||
Object.freeze({
|
||||
panel: 2, duration: 15000, voice: "intro-narration-3", effect: "attack",
|
||||
subtitle: "Lima stood between the riders and the village. Malrec could not bend her will, so he bound her in shadow and carried her beyond the mountains."
|
||||
}),
|
||||
Object.freeze({
|
||||
panel: 3, duration: 15000, voice: "intro-narration-4", effect: "quest",
|
||||
subtitle: "At dawn, a lone traveller reached the broken village. The road was dangerous, but every rescued life would become another light leading to Lima."
|
||||
}),
|
||||
Object.freeze({
|
||||
panel: 3, duration: 8000, voice: null, effect: "victory",
|
||||
subtitle: "RESCUE PRINCESS LIMA — Chapter One: The Broken Village"
|
||||
})
|
||||
]);
|
||||
|
||||
function create(container, actions) {
|
||||
const element = container.querySelector("[data-lima-intro]");
|
||||
const picture = element.querySelector("[data-lima-intro-picture]");
|
||||
const subtitle = element.querySelector("[data-lima-intro-subtitle]");
|
||||
const progress = element.querySelector("[data-lima-intro-progress]");
|
||||
const pauseButton = element.querySelector("[data-lima-intro-pause]");
|
||||
const muteButton = element.querySelector("[data-lima-intro-mute]");
|
||||
let beatIndex = 0;
|
||||
let elapsed = 0;
|
||||
let startedAt = 0;
|
||||
let timer = 0;
|
||||
let paused = false;
|
||||
let replay = false;
|
||||
let escapeStarted = 0;
|
||||
let running = false;
|
||||
|
||||
function start(options) {
|
||||
stopTimer();
|
||||
beatIndex = 0;
|
||||
elapsed = 0;
|
||||
paused = false;
|
||||
replay = Boolean(options && options.replay);
|
||||
running = true;
|
||||
element.hidden = false;
|
||||
container.classList.add("is-intro-running");
|
||||
actions.onLock(true);
|
||||
actions.onAudioUnlock();
|
||||
renderBeat();
|
||||
element.focus();
|
||||
}
|
||||
|
||||
function renderBeat() {
|
||||
const beat = BEATS[beatIndex];
|
||||
picture.dataset.panel = String(beat.panel);
|
||||
subtitle.textContent = beat.subtitle;
|
||||
subtitle.hidden = !actions.subtitlesEnabled();
|
||||
progress.style.width = `${beatIndex / BEATS.length * 100}%`;
|
||||
progress.parentElement.setAttribute("aria-valuenow", String(beatIndex + 1));
|
||||
if (beat.voice && actions.narrationEnabled()) actions.playVoice(beat.voice);
|
||||
if (beat.effect) actions.playEffect(beat.effect, 0.55);
|
||||
startedAt = Date.now();
|
||||
timer = window.setTimeout(next, beat.duration);
|
||||
pauseButton.textContent = "Pause";
|
||||
pauseButton.setAttribute("aria-pressed", "false");
|
||||
}
|
||||
|
||||
function next() {
|
||||
stopTimer();
|
||||
actions.stopVoice();
|
||||
beatIndex += 1;
|
||||
elapsed = 0;
|
||||
if (beatIndex >= BEATS.length) return finish(false);
|
||||
renderBeat();
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
if (!running) return;
|
||||
paused = !paused;
|
||||
if (paused) {
|
||||
elapsed += Date.now() - startedAt;
|
||||
stopTimer();
|
||||
actions.stopVoice();
|
||||
} else {
|
||||
const beat = BEATS[beatIndex];
|
||||
startedAt = Date.now();
|
||||
timer = window.setTimeout(next, Math.max(500, beat.duration - elapsed));
|
||||
if (beat.voice && actions.narrationEnabled()) actions.playVoice(beat.voice);
|
||||
}
|
||||
pauseButton.textContent = paused ? "Resume" : "Pause";
|
||||
pauseButton.setAttribute("aria-pressed", String(paused));
|
||||
element.classList.toggle("is-paused", paused);
|
||||
}
|
||||
|
||||
function finish(skipped) {
|
||||
if (!running) return;
|
||||
running = false;
|
||||
stopTimer();
|
||||
actions.stopVoice();
|
||||
progress.style.width = "100%";
|
||||
element.hidden = true;
|
||||
element.classList.remove("is-paused");
|
||||
container.classList.remove("is-intro-running");
|
||||
actions.onComplete({ replay, skipped });
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
timer = 0;
|
||||
}
|
||||
|
||||
function updateMute() {
|
||||
const enabled = actions.soundEnabled();
|
||||
muteButton.textContent = enabled ? "Mute" : "Enable sound";
|
||||
muteButton.setAttribute("aria-pressed", String(!enabled));
|
||||
}
|
||||
|
||||
pauseButton.addEventListener("click", togglePause);
|
||||
muteButton.addEventListener("click", () => {
|
||||
actions.toggleSound();
|
||||
updateMute();
|
||||
});
|
||||
element.querySelector("[data-lima-intro-skip]").addEventListener("click", () => finish(true));
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!running) return;
|
||||
if (event.key === "Escape" && !escapeStarted) {
|
||||
event.preventDefault();
|
||||
escapeStarted = Date.now();
|
||||
} else if (event.key.toLowerCase() === "p") {
|
||||
event.preventDefault();
|
||||
togglePause();
|
||||
}
|
||||
});
|
||||
document.addEventListener("keyup", (event) => {
|
||||
if (!running || event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
if (escapeStarted && Date.now() - escapeStarted >= 900) finish(true);
|
||||
else actions.status("Hold Escape for one second to skip the introduction.");
|
||||
escapeStarted = 0;
|
||||
});
|
||||
|
||||
return Object.freeze({ start, finish, togglePause, isRunning: () => running, updateMute, BEATS });
|
||||
}
|
||||
|
||||
const api = Object.freeze({ create, BEATS });
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
root.PrincessLimaIntro = api;
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this));
|
||||
@@ -1,38 +0,0 @@
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
const Data = root.PrincessLimaData;
|
||||
const REGION_FRAME = Object.freeze({
|
||||
village: 0,
|
||||
forest: 1,
|
||||
ruins: 2,
|
||||
mountain: 3,
|
||||
camp: 4,
|
||||
fortressExterior: 5,
|
||||
fortressInterior: 6,
|
||||
bossArena: 7,
|
||||
chamber: 8
|
||||
});
|
||||
|
||||
/*
|
||||
* The generated atlas is the finished map artwork, not a texture reference.
|
||||
* Keep it square and opaque. Collision is supplied separately by the region
|
||||
* data so production rendering never paints debug geometry over the image.
|
||||
*/
|
||||
function render(scene, regionId) {
|
||||
return scene.add.image(
|
||||
Data.WIDTH / 2,
|
||||
Data.HEIGHT / 2,
|
||||
"lima-region-atlas",
|
||||
REGION_FRAME[regionId]
|
||||
)
|
||||
.setDisplaySize(Data.WIDTH, Data.HEIGHT)
|
||||
.setOrigin(0.5)
|
||||
.setAlpha(1)
|
||||
.setDepth(0);
|
||||
}
|
||||
|
||||
const api = Object.freeze({ REGION_FRAME, render });
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
root.PrincessLimaMaps = api;
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this));
|
||||
@@ -1,610 +0,0 @@
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
const Data = root.PrincessLimaData;
|
||||
const Systems = root.PrincessLimaSystems;
|
||||
const State = root.PrincessLimaState;
|
||||
const Maps = root.PrincessLimaMaps;
|
||||
|
||||
function createSceneClasses(controller) {
|
||||
class BootScene extends Phaser.Scene {
|
||||
constructor() { super("LimaBoot"); }
|
||||
|
||||
preload() {
|
||||
this.load.image("lima-title", "/assets/images/play/princess-lima/title-landscape.png");
|
||||
this.load.spritesheet("lima-cast", "/assets/images/play/princess-lima/cast-atlas.png", { frameWidth: 314, frameHeight: 314 });
|
||||
this.load.spritesheet("lima-region-atlas", "/assets/images/play/princess-lima/regional-style-atlas.png", { frameWidth: 418, frameHeight: 418 });
|
||||
const audio = "/assets/audio/princess-lima/";
|
||||
[
|
||||
"village-theme", "forest-theme", "mountain-theme", "fortress-theme", "boss-theme", "victory-theme",
|
||||
"step", "attack", "hit", "damage", "defeat", "pickup", "quest", "puzzle", "door", "victory",
|
||||
"intro-narration-1", "intro-narration-2", "intro-narration-3", "intro-narration-4"
|
||||
].forEach((key) => this.load.audio(key, `${audio}${key}.wav`));
|
||||
this.load.on("loaderror", (file) => controller.devWarn(`Optional asset failed: ${file.key}`));
|
||||
}
|
||||
|
||||
create() {
|
||||
this.add.image(Data.WIDTH / 2, Data.HEIGHT / 2, "lima-title").setDisplaySize(Data.WIDTH, Data.HEIGHT);
|
||||
controller.audio.attach(this, "village");
|
||||
controller.ready();
|
||||
}
|
||||
}
|
||||
|
||||
class WorldScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super("LimaWorld");
|
||||
this.regionId = "village";
|
||||
this.facing = "east";
|
||||
this.lastAttack = 0;
|
||||
this.lastHit = 0;
|
||||
this.stepDistance = 0;
|
||||
this.previous = null;
|
||||
this.puzzleInput = [];
|
||||
this.enemySerial = 0;
|
||||
this.attacking = false;
|
||||
this.attackToken = 0;
|
||||
this.attackHits = new Set();
|
||||
}
|
||||
|
||||
init(data) {
|
||||
this.regionId = Data.MAPS[data && data.region] ? data.region : controller.getState().region;
|
||||
}
|
||||
|
||||
create() {
|
||||
controller.scene = this;
|
||||
controller.transitioning = false;
|
||||
this.map = Data.MAPS[this.regionId];
|
||||
this.physics.world.setBounds(0, 0, Data.WIDTH, Data.HEIGHT);
|
||||
this.drawMap();
|
||||
this.solids = this.physics.add.staticGroup();
|
||||
Systems.activeObstacles(controller.getState(), this.regionId).forEach((shape) => this.addSolid(shape));
|
||||
this.interactables = [];
|
||||
this.createExits();
|
||||
this.createPuzzle();
|
||||
this.createPickups();
|
||||
this.createNpcs();
|
||||
this.createPlayer();
|
||||
this.createEnemies();
|
||||
this.createInput();
|
||||
this.projectiles = this.physics.add.group();
|
||||
this.physics.add.collider(this.projectiles, this.solids, (projectile) => projectile.destroy());
|
||||
this.physics.add.overlap(this.player, this.projectiles, (_player, projectile) => {
|
||||
this.hurtPlayer(projectile.getData("damage") || 10, projectile.x, projectile.y);
|
||||
projectile.destroy();
|
||||
});
|
||||
this.cameras.main.setBounds(0, 0, Data.WIDTH, Data.HEIGHT);
|
||||
this.cameras.main.startFollow(this.player, true, controller.reducedMotion() ? 1 : 0.2, controller.reducedMotion() ? 1 : 0.2);
|
||||
this.cameras.main.setDeadzone(90, 60);
|
||||
controller.audio.attach(this, this.regionId);
|
||||
controller.updateHud();
|
||||
controller.status(`${this.map.name}. ${Systems.currentObjective(controller.getState())}`);
|
||||
controller.persistPosition(true);
|
||||
if (this.regionId === "bossArena" && !controller.getState().defeatedBosses.includes("malrec")) {
|
||||
controller.openDialogue("malrec", () => {
|
||||
let state = controller.getState();
|
||||
state.flags.boss_started = true;
|
||||
state = Systems.startQuest(state, "defeat_malrec");
|
||||
controller.setState(state, "The final battle begins.");
|
||||
this.refreshRequiredEnemies();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawMap() {
|
||||
this.cameras.main.setBackgroundColor(this.map.palette[0]);
|
||||
Maps.render(this, this.regionId);
|
||||
}
|
||||
|
||||
addSolid(shape) {
|
||||
let object;
|
||||
if (shape.shape === "circle") {
|
||||
object = this.add.circle(shape.x, shape.y, shape.radius, 0xffffff, 0);
|
||||
this.physics.add.existing(object, true);
|
||||
object.body.setCircle(shape.radius);
|
||||
} else {
|
||||
object = this.add.rectangle(shape.x + shape.width / 2, shape.y + shape.height / 2, shape.width, shape.height, 0xffffff, 0);
|
||||
this.physics.add.existing(object, true);
|
||||
}
|
||||
this.solids.add(object);
|
||||
}
|
||||
|
||||
createPlayer() {
|
||||
const state = controller.getState();
|
||||
const saved = state.region === this.regionId ? state.position : { x: this.map.spawns[Object.keys(this.map.spawns)[0]].x, y: this.map.spawns[Object.keys(this.map.spawns)[0]].y };
|
||||
const safe = Systems.nearestSafeSpawn(state, this.regionId, saved.x, saved.y);
|
||||
this.facing = state.position.facing || "east";
|
||||
this.player = this.physics.add.sprite(safe.x, safe.y, "lima-cast", playerFrame(this.facing)).setScale(0.3).setDepth(100);
|
||||
this.player.body.setSize(70, 42).setOffset(122, 215);
|
||||
this.player.setCollideWorldBounds(true).setMaxVelocity(176, 176);
|
||||
this.physics.add.collider(this.player, this.solids);
|
||||
this.physics.add.collider(this.player, this.npcGroup);
|
||||
this.previous = { x: this.player.x, y: this.player.y };
|
||||
}
|
||||
|
||||
createNpcs() {
|
||||
this.npcGroup = this.physics.add.staticGroup();
|
||||
(this.map.npcs || []).forEach(([id, x, y]) => {
|
||||
const npc = this.npcGroup.create(x, y, "lima-cast", Data.NPCS[id].frame).setScale(0.29).setDepth(y + 40);
|
||||
npc.refreshBody();
|
||||
npc.body.setSize(70, 42).setOffset(122, 215);
|
||||
this.interactables.push({ type: "npc", id, x, y, sprite: npc });
|
||||
});
|
||||
}
|
||||
|
||||
createEnemies() {
|
||||
this.enemies = this.physics.add.group();
|
||||
this.physics.add.collider(this.enemies, this.solids);
|
||||
this.physics.add.collider(this.enemies, this.enemies);
|
||||
this.physics.add.collider(this.enemies, this.npcGroup);
|
||||
this.physics.add.overlap(this.player, this.enemies, (_player, enemyObject) => {
|
||||
this.hurtPlayer(enemyObject.getData("spec").damage, enemyObject.x, enemyObject.y);
|
||||
});
|
||||
(this.map.enemies || []).forEach((spawn) => {
|
||||
if (spawn.boss && controller.getState().defeatedBosses.includes(spawn.type)) return;
|
||||
this.spawnEnemy(spawn);
|
||||
});
|
||||
}
|
||||
|
||||
spawnEnemy(spawn) {
|
||||
const spec = Data.ENEMIES[spawn.type];
|
||||
const enemyObject = this.physics.add.sprite(spawn.x, spawn.y, "lima-cast", spec.frame)
|
||||
.setScale(spec.boss ? 0.34 : 0.25).setDepth(spawn.y + 30);
|
||||
enemyObject.body.setSize(spec.boss ? 130 : 95, spec.boss ? 78 : 58).setOffset(spec.boss ? 92 : 110, spec.boss ? 200 : 205);
|
||||
enemyObject.setData({
|
||||
id: `${spawn.type}-${++this.enemySerial}`, type: spawn.type, spec, spawn,
|
||||
health: spec.health, homeX: spawn.x, homeY: spawn.y, nextAction: this.time.now + 900, phase: 1
|
||||
});
|
||||
this.enemies.add(enemyObject);
|
||||
this.applyEnemyRequirement(enemyObject);
|
||||
}
|
||||
|
||||
applyEnemyRequirement(enemyObject) {
|
||||
const requirement = enemyObject.getData("spawn").requires;
|
||||
const available = !requirement || controller.getState().flags[requirement]
|
||||
|| controller.getState().unlockedRoutes.includes(requirement);
|
||||
enemyObject.setVisible(available);
|
||||
enemyObject.body.enable = available;
|
||||
}
|
||||
|
||||
refreshRequiredEnemies() {
|
||||
this.enemies.getChildren().forEach((enemyObject) => this.applyEnemyRequirement(enemyObject));
|
||||
}
|
||||
|
||||
createExits() {
|
||||
(this.map.exits || []).forEach((item) => {
|
||||
const zone = this.add.zone(item.x + item.width / 2, item.y + item.height / 2, item.width, item.height);
|
||||
this.interactables.push({ type: "exit", id: item.id, x: item.x + item.width / 2, y: item.y + item.height / 2, data: item, sprite: zone });
|
||||
});
|
||||
}
|
||||
|
||||
createPuzzle() {
|
||||
const puzzle = this.map.puzzle;
|
||||
if (!puzzle || controller.getState().solvedPuzzles.includes(puzzle.id)) return;
|
||||
puzzle.objects.forEach(([id, x, y]) => {
|
||||
const node = this.add.zone(x, y, 38, 38);
|
||||
const marker = this.createInteractionMarker(x, y, `${readableId(id)} · E`);
|
||||
this.interactables.push({ type: "puzzle", id, puzzle, x, y, sprite: node, marker });
|
||||
});
|
||||
}
|
||||
|
||||
createPickups() {
|
||||
(this.map.pickups || []).forEach(([id, x, y], index) => {
|
||||
const chestId = `${this.regionId}-${id}-${index}`;
|
||||
if (controller.getState().openedChests.includes(chestId)) return;
|
||||
const item = this.add.zone(x, y, 34, 34);
|
||||
const marker = this.createInteractionMarker(x, y, `${Data.ITEMS[id].name} · E`);
|
||||
this.interactables.push({ type: "pickup", id, chestId, x, y, sprite: item, marker });
|
||||
});
|
||||
}
|
||||
|
||||
createInteractionMarker(x, y, label) {
|
||||
return this.add.text(x, y - 32, label, {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "13px",
|
||||
fontStyle: "bold",
|
||||
color: "#fff6ce",
|
||||
backgroundColor: "#111827",
|
||||
padding: { x: 7, y: 4 }
|
||||
}).setOrigin(0.5).setDepth(900).setVisible(false);
|
||||
}
|
||||
|
||||
createInput() {
|
||||
this.cursors = this.input.keyboard.createCursorKeys();
|
||||
this.keys = this.input.keyboard.addKeys({
|
||||
up: "W", down: "S", left: "A", right: "D", interact: "E", enter: "ENTER",
|
||||
item: "Q", pause: "ESC", menu: "M", inventory: "I", quests: "J", fullscreen: "F", cycle: "TAB"
|
||||
});
|
||||
this.input.keyboard.addCapture(["SPACE", "TAB", "UP", "DOWN", "LEFT", "RIGHT"]);
|
||||
}
|
||||
|
||||
update(time, delta) {
|
||||
if (!this.player) return;
|
||||
if (controller.locked || controller.transitioning) {
|
||||
this.player.setVelocity(0);
|
||||
return;
|
||||
}
|
||||
const traveled = Math.hypot(this.player.x - this.previous.x, this.player.y - this.previous.y);
|
||||
this.previous = { x: this.player.x, y: this.player.y };
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
if (this.cursors.left.isDown || this.keys.left.isDown) dx -= 1;
|
||||
if (this.cursors.right.isDown || this.keys.right.isDown) dx += 1;
|
||||
if (this.cursors.up.isDown || this.keys.up.isDown) dy -= 1;
|
||||
if (this.cursors.down.isDown || this.keys.down.isDown) dy += 1;
|
||||
if (this.attacking) dx = dy = 0;
|
||||
const velocity = Systems.approachVelocity(
|
||||
this.player.body.velocity.x, this.player.body.velocity.y, dx, dy, delta,
|
||||
Boolean(controller.getState().equipment.boots)
|
||||
);
|
||||
this.player.setVelocity(velocity.x, velocity.y);
|
||||
if (dx || dy) {
|
||||
this.facing = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south");
|
||||
this.player.setFrame(playerFrame(this.facing));
|
||||
const bob = controller.reducedMotion() ? 1 : 1 + Math.sin(time / 90) * 0.018;
|
||||
this.player.setScale(0.3, 0.3 * bob);
|
||||
this.stepDistance += traveled;
|
||||
if (this.stepDistance >= 48) {
|
||||
controller.audio.play("step", 0.45);
|
||||
this.stepDistance = 0;
|
||||
}
|
||||
} else {
|
||||
this.player.setScale(0.3);
|
||||
this.stepDistance = 0;
|
||||
}
|
||||
this.player.setDepth(this.player.y + 80);
|
||||
this.updateNearest();
|
||||
this.updateEnemies(time);
|
||||
this.handleKeys(time);
|
||||
if ((dx || dy) && time - controller.lastSaveAt > 900) {
|
||||
controller.lastSaveAt = time;
|
||||
controller.persistPosition(false);
|
||||
}
|
||||
}
|
||||
|
||||
updateNearest() {
|
||||
let best = Infinity;
|
||||
let nearest = null;
|
||||
this.interactables.forEach((item) => {
|
||||
const distance = Math.hypot(item.x - this.player.x, item.y - this.player.y);
|
||||
if (item.marker) item.marker.setVisible(distance <= 92);
|
||||
const radius = item.type === "exit" ? 70 : 54;
|
||||
if (distance <= radius && distance < best) {
|
||||
best = distance;
|
||||
nearest = item;
|
||||
}
|
||||
});
|
||||
this.nearest = nearest;
|
||||
controller.prompt(nearest ? promptFor(nearest) : "");
|
||||
}
|
||||
|
||||
handleKeys(time) {
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keys.interact) || Phaser.Input.Keyboard.JustDown(this.keys.enter)) this.interact();
|
||||
if (Phaser.Input.Keyboard.JustDown(this.cursors.space)) this.attack(time);
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keys.item)) controller.useTonic();
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keys.pause) || Phaser.Input.Keyboard.JustDown(this.keys.menu)) controller.openPanel("pause");
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keys.inventory)) controller.openPanel("inventory");
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keys.quests)) controller.openPanel("quests");
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keys.fullscreen)) controller.toggleFullscreen();
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keys.cycle)) controller.cycleItem();
|
||||
}
|
||||
|
||||
interact() {
|
||||
const item = this.nearest;
|
||||
if (!item) return controller.status("There is nothing close enough to interact with.");
|
||||
if (item.type === "exit") {
|
||||
if (item.data.requirement && !controller.getState().unlockedRoutes.includes(item.data.requirement)) {
|
||||
return controller.status(`The route is blocked. ${Systems.currentObjective(controller.getState())}`);
|
||||
}
|
||||
controller.travel(item.data.target, item.data.spawn);
|
||||
} else if (item.type === "npc") this.interactNpc(item);
|
||||
else if (item.type === "puzzle") this.activatePuzzle(item);
|
||||
else if (item.type === "pickup") this.collect(item);
|
||||
}
|
||||
|
||||
interactNpc(item) {
|
||||
const dx = item.x - this.player.x;
|
||||
const dy = item.y - this.player.y;
|
||||
this.facing = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south");
|
||||
this.player.setFrame(playerFrame(this.facing));
|
||||
controller.openDialogue(item.id, () => this.resolveNpc(item.id));
|
||||
}
|
||||
|
||||
resolveNpc(id) {
|
||||
let state = controller.getState();
|
||||
if (id === "elder" && !state.flags.aftermath_complete) {
|
||||
state = Systems.completeQuest(Systems.startQuest(state, "aftermath"), "aftermath");
|
||||
state = Systems.startQuest(state, "village_defence");
|
||||
controller.setState(state, "Wayfarer Sword acquired. The second raid has begun.");
|
||||
this.refreshRequiredEnemies();
|
||||
} else if (id === "nia") {
|
||||
state = Systems.startQuest(state, "healer_herbs");
|
||||
if (Systems.quantity(state, "silver_leaf") >= 2) {
|
||||
state = Systems.completeQuest(state, "healer_herbs");
|
||||
controller.setState(state, "Nia brews two healing tonics.");
|
||||
} else controller.setState(state, "Optional quest started: collect two Silver Leaves.");
|
||||
} else if (id === "tovin") {
|
||||
state = Systems.startQuest(state, "find_guide");
|
||||
controller.setState(state, "Wake the three standing stones from youngest tree to oldest.");
|
||||
} else if (id === "bram" && this.regionId === "mountain") {
|
||||
state = Systems.startQuest(state, "repair_bridge");
|
||||
controller.setState(state, "Restart the west and east bridge winches.");
|
||||
} else if (id === "elowen" && this.regionId === "camp") {
|
||||
state = Systems.startQuest(state, "free_scout");
|
||||
state = Systems.progressQuest(state, "free_scout", 1);
|
||||
controller.setState(state, "Elowen is free. Defeat Captain Veyr for his emblem.");
|
||||
} else if (id === "elowen" && this.regionId === "fortressInterior") {
|
||||
state = Systems.startQuest(state, "free_prisoners");
|
||||
state = Systems.progressQuest(state, "free_prisoners", 1);
|
||||
state = Systems.startQuest(state, "break_wards");
|
||||
controller.setState(state, "Elowen's captured ally is free. Disable the fortress wards.");
|
||||
} else if (id === "prisoner") {
|
||||
state = Systems.startQuest(state, "free_prisoners");
|
||||
state = Systems.progressQuest(state, "free_prisoners", 1);
|
||||
state = Systems.startQuest(state, "break_wards");
|
||||
controller.setState(state, "A prisoner is free. Disable the fortress wards.");
|
||||
} else if (id === "lima" && state.defeatedBosses.includes("malrec")) {
|
||||
state.rescued = true;
|
||||
state.story = "complete";
|
||||
controller.setState(State.normalize(state), "Princess Lima is safe.");
|
||||
controller.openEnding();
|
||||
}
|
||||
}
|
||||
|
||||
activatePuzzle(item) {
|
||||
const puzzle = item.puzzle;
|
||||
const state = controller.getState();
|
||||
if (state.solvedPuzzles.includes(puzzle.id)) return;
|
||||
if (puzzle.type === "set") {
|
||||
if (!this.puzzleInput.includes(item.id)) this.puzzleInput.push(item.id);
|
||||
if (item.marker) item.marker.setText(`✓ ${readableId(item.id)}`);
|
||||
if (this.puzzleInput.length === puzzle.sequence.length) this.finishPuzzle(puzzle.id);
|
||||
else controller.status(`${this.puzzleInput.length} / ${puzzle.sequence.length} mechanisms active.`);
|
||||
return;
|
||||
}
|
||||
this.puzzleInput.push(item.id);
|
||||
const valid = this.puzzleInput.every((value, index) => value === puzzle.sequence[index]);
|
||||
if (!valid) {
|
||||
this.puzzleInput = [];
|
||||
controller.status("The sequence resets. Look for the environmental clue and try again.");
|
||||
return;
|
||||
}
|
||||
if (item.marker) item.marker.setText(`✓ ${readableId(item.id)}`);
|
||||
if (this.puzzleInput.length === puzzle.sequence.length) this.finishPuzzle(puzzle.id);
|
||||
else controller.status(`${item.id} answers. ${this.puzzleInput.length} / ${puzzle.sequence.length}.`);
|
||||
}
|
||||
|
||||
finishPuzzle(id) {
|
||||
let state = Systems.solvePuzzle(controller.getState(), id);
|
||||
if (id === "sun_pedestals") {
|
||||
state.solvedPuzzles.push(id);
|
||||
state.flags.sun_veil_broken = true;
|
||||
state = State.normalize(state);
|
||||
}
|
||||
controller.setState(state, "Puzzle complete. A sealed route opens.");
|
||||
controller.audio.play("puzzle");
|
||||
if (id === "bridge_winches") controller.reloadRegion();
|
||||
}
|
||||
|
||||
collect(item) {
|
||||
let state = controller.getState();
|
||||
if (state.openedChests.includes(item.chestId)) return;
|
||||
state.openedChests.push(item.chestId);
|
||||
state = Systems.addItem(state, item.id, 1);
|
||||
controller.setState(state, `${Data.ITEMS[item.id].name} collected.`);
|
||||
controller.audio.play("pickup");
|
||||
item.sprite.destroy();
|
||||
if (item.marker) item.marker.destroy();
|
||||
this.interactables = this.interactables.filter((entry) => entry !== item);
|
||||
}
|
||||
|
||||
attack(time) {
|
||||
if (time - this.lastAttack < Systems.ATTACK_TIMING.cooldown || controller.locked || this.attacking) return;
|
||||
this.lastAttack = time;
|
||||
this.attacking = true;
|
||||
const token = ++this.attackToken;
|
||||
const facing = this.facing;
|
||||
this.attackHits = new Set();
|
||||
this.player.setVelocity(0);
|
||||
controller.audio.play("attack");
|
||||
const direction = faceVector(facing);
|
||||
const blade = this.add.rectangle(0, -22, 6, 42, 0xeaf6ff).setStrokeStyle(2, 0x5e6b78);
|
||||
const guard = this.add.rectangle(0, 1, 20, 6, 0xf2c467).setStrokeStyle(1, 0x5b3d25);
|
||||
const grip = this.add.rectangle(0, 11, 6, 19, 0x70432d);
|
||||
const weapon = this.add.container(
|
||||
this.player.x + direction.x * 19,
|
||||
this.player.y + direction.y * 16,
|
||||
[blade, guard, grip]
|
||||
).setDepth(690).setAngle(direction.angle + 5);
|
||||
const windupAngle = facing === "west" || facing === "north" ? -12 : 12;
|
||||
this.tweens.add({
|
||||
targets: this.player, angle: windupAngle, scaleX: 0.29, scaleY: 0.31,
|
||||
duration: Systems.ATTACK_TIMING.windup, ease: "Stepped"
|
||||
});
|
||||
this.tweens.add({
|
||||
targets: weapon,
|
||||
angle: direction.angle + 90,
|
||||
duration: Systems.ATTACK_TIMING.windup + Systems.ATTACK_TIMING.active,
|
||||
ease: "Cubic.Out"
|
||||
});
|
||||
this.time.delayedCall(Systems.ATTACK_TIMING.windup, () => {
|
||||
if (!this.player.active || token !== this.attackToken) return;
|
||||
this.player.setAngle(-windupAngle).setScale(0.32, 0.28);
|
||||
this.performAttackHit(facing, token);
|
||||
});
|
||||
this.time.delayedCall(Systems.ATTACK_TIMING.windup + Systems.ATTACK_TIMING.active, () => {
|
||||
if (!this.player.active || token !== this.attackToken) return;
|
||||
this.player.setAngle(windupAngle / 2).setScale(0.3);
|
||||
});
|
||||
this.time.delayedCall(
|
||||
Systems.ATTACK_TIMING.windup + Systems.ATTACK_TIMING.active + Systems.ATTACK_TIMING.recovery,
|
||||
() => {
|
||||
if (!this.player.active || token !== this.attackToken) return;
|
||||
this.player.setAngle(0).setScale(0.3).setFrame(playerFrame(this.facing));
|
||||
if (weapon.active) weapon.destroy();
|
||||
this.attacking = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
performAttackHit(facing, token) {
|
||||
const box = Systems.attackHitbox(facing, this.player.x, this.player.y);
|
||||
const direction = faceVector(facing);
|
||||
const hitbox = this.add.rectangle(box.x + box.width / 2, box.y + box.height / 2, box.width, box.height, 0xff4df3, controller.debugCollision ? 0.28 : 0);
|
||||
this.physics.add.existing(hitbox, true);
|
||||
const slash = this.add.graphics().setDepth(700);
|
||||
slash.lineStyle(9, 0xffefb0, 0.95).beginPath();
|
||||
slash.arc(
|
||||
this.player.x + direction.x * 30, this.player.y + direction.y * 24, 50,
|
||||
Phaser.Math.DegToRad(direction.angle - 58), Phaser.Math.DegToRad(direction.angle + 58), false
|
||||
).strokePath();
|
||||
slash.lineStyle(3, 0xd3f7ff, 0.9).strokePath();
|
||||
this.physics.overlap(hitbox, this.enemies, (_hit, enemyObject) => this.hitEnemy(enemyObject, direction, token));
|
||||
this.time.delayedCall(Systems.ATTACK_TIMING.active, () => {
|
||||
if (hitbox.active) hitbox.destroy();
|
||||
if (slash.active) slash.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
hitEnemy(enemyObject, direction, token) {
|
||||
if (!enemyObject.active || !enemyObject.visible) return;
|
||||
const enemyId = enemyObject.getData("id");
|
||||
if (this.attackHits.has(enemyId) || token !== this.attackToken) return;
|
||||
this.attackHits.add(enemyId);
|
||||
if (enemyObject.getData("type") === "malrec" && enemyObject.getData("phase") >= 3 && !controller.getState().flags.sun_veil_broken) {
|
||||
controller.status("Malrec's final veil holds. Activate both Sun Crystal pedestals.");
|
||||
return;
|
||||
}
|
||||
const health = enemyObject.getData("health") - controller.getState().attack;
|
||||
enemyObject.setData("health", health);
|
||||
const pushDistance = controller.reducedMotion() ? 20 : enemyObject.getData("spec").boss ? 24 : 42;
|
||||
const target = Systems.wallSafeKnockback(controller.getState(), this.regionId, enemyObject.x, enemyObject.y, direction.x, direction.y, pushDistance);
|
||||
const push = Systems.normalizedVector(target.x - enemyObject.x, target.y - enemyObject.y, controller.reducedMotion() ? 90 : 190);
|
||||
enemyObject.setData("stunnedUntil", this.time.now + 210);
|
||||
enemyObject.setVelocity(push.x, push.y).setTintFill(0xffe1b0);
|
||||
controller.audio.play("hit", enemyObject.getData("spec").boss ? 1 : 0.78);
|
||||
const impact = this.add.star(enemyObject.x, enemyObject.y - 15, 6, 5, 15, 0xfff1a8, 0.95).setDepth(750);
|
||||
this.time.delayedCall(70, () => { if (impact.active) impact.destroy(); });
|
||||
this.time.delayedCall(110, () => {
|
||||
if (enemyObject.active) {
|
||||
enemyObject.clearTint();
|
||||
enemyObject.setVelocity(0);
|
||||
}
|
||||
});
|
||||
const pause = enemyObject.getData("spec").boss ? 58 : 38;
|
||||
this.physics.world.pause();
|
||||
this.time.delayedCall(pause, () => { if (this.physics.world) this.physics.world.resume(); });
|
||||
if (enemyObject.getData("spec").boss && !controller.reducedMotion() && controller.getState().settings.screenShake) {
|
||||
this.cameras.main.shake(70, 0.0025);
|
||||
}
|
||||
if (health <= 0) this.defeatEnemy(enemyObject);
|
||||
}
|
||||
|
||||
defeatEnemy(enemyObject) {
|
||||
const type = enemyObject.getData("type");
|
||||
const spawn = enemyObject.getData("spawn");
|
||||
const wasBoss = enemyObject.getData("spec").boss;
|
||||
enemyObject.destroy();
|
||||
controller.audio.play("defeat");
|
||||
let state = controller.getState();
|
||||
if (spawn.quest === "village_defence") state = Systems.progressQuest(state, "village_defence", 1);
|
||||
if (wasBoss) state = Systems.recordBoss(state, type);
|
||||
controller.setState(state, wasBoss ? `${Data.ENEMIES[type].name} defeated. The route is open.` : `${Data.ENEMIES[type].name} defeated.`);
|
||||
if (type === "malrec") {
|
||||
controller.audio.play("victory");
|
||||
this.time.delayedCall(500, () => controller.travel("chamber", "door"));
|
||||
}
|
||||
}
|
||||
|
||||
updateEnemies(time) {
|
||||
this.enemies.getChildren().forEach((enemyObject) => {
|
||||
if (!enemyObject.active || !enemyObject.body.enable) return;
|
||||
if (time < (enemyObject.getData("stunnedUntil") || 0)) return;
|
||||
const spec = enemyObject.getData("spec");
|
||||
const distance = Math.hypot(enemyObject.x - this.player.x, enemyObject.y - this.player.y);
|
||||
const homeDistance = Math.hypot(enemyObject.x - enemyObject.getData("homeX"), enemyObject.y - enemyObject.getData("homeY"));
|
||||
if (distance > 270 || homeDistance > enemyObject.getData("spawn").leash) {
|
||||
this.physics.moveTo(enemyObject, enemyObject.getData("homeX"), enemyObject.getData("homeY"), spec.speed);
|
||||
return;
|
||||
}
|
||||
if (spec.behaviour === "wander" && time > enemyObject.getData("nextAction")) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
enemyObject.setVelocity(Math.cos(angle) * spec.speed, Math.sin(angle) * spec.speed);
|
||||
enemyObject.setData("nextAction", time + 650);
|
||||
} else if (["charge", "slam", "final"].includes(spec.behaviour)) this.updateBoss(enemyObject, time, distance);
|
||||
else this.physics.moveToObject(enemyObject, this.player, spec.speed);
|
||||
enemyObject.setDepth(enemyObject.y + 60);
|
||||
});
|
||||
}
|
||||
|
||||
updateBoss(enemyObject, time, distance) {
|
||||
const spec = enemyObject.getData("spec");
|
||||
const ratio = enemyObject.getData("health") / spec.health;
|
||||
const phase = Systems.bossPhase(enemyObject.getData("health"), spec.health, spec.phases || 1);
|
||||
enemyObject.setData("phase", phase);
|
||||
controller.boss(spec.name, enemyObject.getData("health"), spec.health);
|
||||
if (time < enemyObject.getData("nextAction")) return;
|
||||
enemyObject.setVelocity(0).setTint(0xf5c96e);
|
||||
const telegraph = controller.getState().settings.reducedMotion ? 780 : 560;
|
||||
enemyObject.setData("nextAction", time + 1500);
|
||||
this.time.delayedCall(telegraph, () => {
|
||||
if (!enemyObject.active) return;
|
||||
enemyObject.clearTint();
|
||||
if (spec.behaviour === "final" && phase >= 2) this.fireRadial(enemyObject, phase === 3 ? 8 : 5);
|
||||
else if (distance < 340) this.physics.moveToObject(enemyObject, this.player, spec.speed * 2.1);
|
||||
});
|
||||
}
|
||||
|
||||
fireRadial(enemyObject, count) {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const angle = Math.PI * 2 * index / count;
|
||||
const shot = this.add.circle(enemyObject.x, enemyObject.y, 8, 0x6f3d89).setStrokeStyle(2, 0xf1b4ff).setDepth(400);
|
||||
this.physics.add.existing(shot);
|
||||
shot.body.setVelocity(Math.cos(angle) * 150, Math.sin(angle) * 150);
|
||||
shot.setData("damage", 13);
|
||||
this.projectiles.add(shot);
|
||||
this.time.delayedCall(3600, () => { if (shot.active) shot.destroy(); });
|
||||
}
|
||||
}
|
||||
|
||||
hurtPlayer(amount, fromX, fromY) {
|
||||
const result = Systems.damage(controller.getState(), amount, this.time.now, this.lastHit);
|
||||
if (!result.hit) return;
|
||||
this.lastHit = this.time.now;
|
||||
controller.setState(result.state, `You take ${result.amount} damage.`);
|
||||
controller.audio.play("damage");
|
||||
const push = Systems.normalizedVector(this.player.x - fromX, this.player.y - fromY, 210);
|
||||
this.player.setVelocity(push.x, push.y).setTint(0xff8c8c);
|
||||
this.time.delayedCall(170, () => { if (this.player.active) this.player.clearTint(); });
|
||||
if (!controller.reducedMotion() && controller.getState().settings.screenShake) this.cameras.main.shake(100, 0.003);
|
||||
if (result.defeated) controller.gameOver();
|
||||
}
|
||||
}
|
||||
|
||||
return [BootScene, WorldScene];
|
||||
}
|
||||
|
||||
function playerFrame(facing) {
|
||||
return { south: 0, east: 1, north: 2, west: 3 }[facing] || 0;
|
||||
}
|
||||
|
||||
function faceVector(facing) {
|
||||
return {
|
||||
north: { x: 0, y: -1, angle: 270 }, south: { x: 0, y: 1, angle: 90 },
|
||||
west: { x: -1, y: 0, angle: 180 }, east: { x: 1, y: 0, angle: 0 }
|
||||
}[facing];
|
||||
}
|
||||
|
||||
function promptFor(item) {
|
||||
if (item.type === "exit") return `${item.data.label} · E / Enter`;
|
||||
if (item.type === "npc") return `Speak with ${Data.NPCS[item.id].name} · E / Enter`;
|
||||
if (item.type === "puzzle") return `Activate ${item.id} · E / Enter`;
|
||||
if (item.type === "pickup") return `Collect ${Data.ITEMS[item.id].name} · E / Enter`;
|
||||
return "Interact · E / Enter";
|
||||
}
|
||||
|
||||
function readableId(value) {
|
||||
return String(value).replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
root.PrincessLimaScenes = Object.freeze({ createSceneClasses, playerFrame });
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this));
|
||||
@@ -1,204 +0,0 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
const data = root.PrincessLimaData || (typeof require === "function" ? require("./princess-lima-data.js") : null);
|
||||
const api = factory(data);
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
root.PrincessLimaState = api;
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data) {
|
||||
"use strict";
|
||||
|
||||
const VERSION = 1;
|
||||
const STORAGE_KEY = "zxh_princess_lima_rpg_v1";
|
||||
const APPEARANCES = Object.freeze(["azure", "ember", "pine"]);
|
||||
const FACES = Object.freeze(["north", "south", "east", "west"]);
|
||||
const START = Object.freeze({
|
||||
region: "village",
|
||||
spawn: "start",
|
||||
x: Data.MAPS.village.spawns.start.x,
|
||||
y: Data.MAPS.village.spawns.start.y,
|
||||
facing: "north"
|
||||
});
|
||||
|
||||
function validName(value) {
|
||||
const name = String(value || "").trim().replace(/\s+/g, " ");
|
||||
return name.length >= 1 && name.length <= 20 ? name : null;
|
||||
}
|
||||
|
||||
function finite(value, fallback, min, max) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
|
||||
}
|
||||
|
||||
function unique(values, allowed) {
|
||||
return Array.isArray(values) ? Array.from(new Set(values.filter((id) => allowed.includes(id)))) : [];
|
||||
}
|
||||
|
||||
function questDefaults() {
|
||||
return Object.fromEntries(Data.QUEST_IDS.map((id) => [id, { status: "locked", count: 0, rewarded: false }]));
|
||||
}
|
||||
|
||||
function fresh(name, appearance) {
|
||||
return {
|
||||
version: VERSION,
|
||||
player: { name: validName(name) || "", appearance: APPEARANCES.includes(appearance) ? appearance : "azure" },
|
||||
health: 100,
|
||||
maxHealth: 100,
|
||||
attack: 1,
|
||||
defence: 0,
|
||||
region: START.region,
|
||||
position: { spawn: START.spawn, x: START.x, y: START.y, facing: START.facing },
|
||||
checkpoint: { region: START.region, spawn: START.spawn, x: START.x, y: START.y },
|
||||
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: [],
|
||||
unlockedRoutes: [],
|
||||
rescued: false,
|
||||
introSeen: false,
|
||||
playTimeSeconds: 0,
|
||||
settings: {
|
||||
soundEnabled: false,
|
||||
master: 0.8,
|
||||
music: 0.45,
|
||||
effects: 0.7,
|
||||
voice: 0.85,
|
||||
narrationEnabled: true,
|
||||
subtitles: true,
|
||||
reducedMotion: null,
|
||||
screenShake: true,
|
||||
highContrast: false,
|
||||
textSpeed: "normal"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePosition(regionId, candidate, fallback) {
|
||||
const region = Data.MAPS[regionId] || Data.MAPS.village;
|
||||
const named = region.spawns[candidate && candidate.spawn] || region.spawns[fallback.spawn] || Object.values(region.spawns)[0];
|
||||
return {
|
||||
spawn: String(candidate && candidate.spawn || fallback.spawn).slice(0, 32),
|
||||
x: finite(candidate && candidate.x, named.x, 24, Data.WIDTH - 24),
|
||||
y: finite(candidate && candidate.y, named.y, 24, Data.HEIGHT - 24),
|
||||
facing: candidate && FACES.includes(candidate.facing) ? candidate.facing : fallback.facing || "south"
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInventory(value) {
|
||||
const quantities = new Map();
|
||||
if (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.floor(finite(entry.quantity, 1, 1, cap))));
|
||||
});
|
||||
return Array.from(quantities, ([id, quantity]) => ({ id, quantity }));
|
||||
}
|
||||
|
||||
function normalizeQuests(value) {
|
||||
const quests = questDefaults();
|
||||
Data.QUEST_IDS.forEach((id) => {
|
||||
const source = value && value[id];
|
||||
if (!source) return;
|
||||
quests[id] = {
|
||||
status: ["locked", "active", "complete"].includes(source.status) ? source.status : "locked",
|
||||
count: Math.floor(finite(source.count, 0, 0, 99)),
|
||||
rewarded: source.rewarded === true
|
||||
};
|
||||
});
|
||||
return quests;
|
||||
}
|
||||
|
||||
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 region = Data.MAP_IDS.includes(candidate.region) ? candidate.region : START.region;
|
||||
const position = normalizePosition(region, candidate.position, START);
|
||||
const checkpointRegion = candidate.checkpoint && Data.MAP_IDS.includes(candidate.checkpoint.region)
|
||||
? candidate.checkpoint.region : START.region;
|
||||
const checkpointPosition = normalizePosition(checkpointRegion, candidate.checkpoint, START);
|
||||
const inventory = normalizeInventory(candidate.inventory);
|
||||
const has = (id) => inventory.some((entry) => entry.id === id);
|
||||
const equipment = {
|
||||
weapon: has("tempered_sword") ? "tempered_sword" : has("village_sword") ? "village_sword" : null,
|
||||
armour: has("buckler") ? "buckler" : null,
|
||||
boots: has("trail_boots") ? "trail_boots" : null,
|
||||
charm: has("forest_charm") ? "forest_charm" : null
|
||||
};
|
||||
const attack = equipment.weapon ? Data.ITEMS[equipment.weapon].attack : 1;
|
||||
const defence = equipment.armour ? Data.ITEMS[equipment.armour].defence : 0;
|
||||
const maxHealth = finite(candidate.maxHealth, 100, 100, 160);
|
||||
return {
|
||||
version: VERSION,
|
||||
player: { name, appearance },
|
||||
health: finite(candidate.health, maxHealth, 0, maxHealth),
|
||||
maxHealth,
|
||||
attack,
|
||||
defence,
|
||||
region,
|
||||
position,
|
||||
checkpoint: { region: checkpointRegion, spawn: checkpointPosition.spawn, x: checkpointPosition.x, y: checkpointPosition.y },
|
||||
chapter: Math.floor(finite(candidate.chapter, 1, 1, 4)),
|
||||
story: String(candidate.story || "arrival").slice(0, 48),
|
||||
quests: normalizeQuests(candidate.quests),
|
||||
inventory,
|
||||
equipment,
|
||||
flags: candidate.flags && typeof candidate.flags === "object" && !Array.isArray(candidate.flags)
|
||||
? Object.fromEntries(Object.entries(candidate.flags).filter(([key, val]) => /^[a-z0-9_-]{1,48}$/.test(key) && typeof val === "boolean").slice(0, 96))
|
||||
: {},
|
||||
solvedPuzzles: unique(candidate.solvedPuzzles, ["forest_stones", "ruin_braziers", "bridge_winches", "shadow_wards", "sun_pedestals"]),
|
||||
defeatedBosses: unique(candidate.defeatedBosses, ["briar_wolf", "stone_guardian", "captain", "malrec"]),
|
||||
openedChests: Array.isArray(candidate.openedChests) ? Array.from(new Set(candidate.openedChests.filter((id) => typeof id === "string"))).slice(0, 64) : [],
|
||||
unlockedRoutes: unique(candidate.unlockedRoutes, ["village_defended", "guide_found", "ruins_complete", "briar_defeated", "bridge_repaired", "guardian_defeated", "emblem_found", "wards_broken", "malrec_defeated"]),
|
||||
rescued: candidate.rescued === true,
|
||||
// Existing v1 saves predate the cinematic and must continue directly.
|
||||
introSeen: candidate.introSeen !== false,
|
||||
playTimeSeconds: Math.floor(finite(candidate.playTimeSeconds, 0, 0, 999999)),
|
||||
settings: {
|
||||
soundEnabled: candidate.settings && candidate.settings.soundEnabled === true,
|
||||
master: finite(candidate.settings && candidate.settings.master, 0.8, 0, 1),
|
||||
music: finite(candidate.settings && candidate.settings.music, 0.45, 0, 1),
|
||||
effects: finite(candidate.settings && candidate.settings.effects, 0.7, 0, 1),
|
||||
voice: finite(candidate.settings && candidate.settings.voice, 0.85, 0, 1),
|
||||
narrationEnabled: !(candidate.settings && candidate.settings.narrationEnabled === false),
|
||||
subtitles: !(candidate.settings && candidate.settings.subtitles === false),
|
||||
reducedMotion: candidate.settings && typeof candidate.settings.reducedMotion === "boolean" ? candidate.settings.reducedMotion : null,
|
||||
screenShake: !(candidate.settings && candidate.settings.screenShake === false),
|
||||
highContrast: candidate.settings && candidate.settings.highContrast === true,
|
||||
textSpeed: candidate.settings && ["slow", "normal", "fast", "instant"].includes(candidate.settings.textSpeed) ? candidate.settings.textSpeed : "normal"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parse(raw) {
|
||||
try { return raw ? normalize(JSON.parse(raw)) : null; } catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function withPosition(state, region, spawn, x, y, facing) {
|
||||
const next = normalize(state);
|
||||
if (!next || !Data.MAPS[region]) return next;
|
||||
next.region = region;
|
||||
next.position = normalizePosition(region, { spawn, x, y, facing }, START);
|
||||
return normalize(next);
|
||||
}
|
||||
|
||||
function withCheckpoint(state, region, spawn, x, y) {
|
||||
const next = normalize(state);
|
||||
if (!next || !Data.MAPS[region]) return next;
|
||||
const point = normalizePosition(region, { spawn, x, y, facing: "south" }, START);
|
||||
next.checkpoint = { region, spawn: point.spawn, x: point.x, y: point.y };
|
||||
return normalize(next);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
VERSION, STORAGE_KEY, APPEARANCES, FACES, START,
|
||||
validName, fresh, normalize, parse, normalizeInventory, normalizeQuests,
|
||||
normalizePosition, withPosition, withCheckpoint
|
||||
});
|
||||
}));
|
||||
@@ -1,251 +0,0 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
const data = root.PrincessLimaData || (typeof require === "function" ? require("./princess-lima-data.js") : null);
|
||||
const stateApi = root.PrincessLimaState || (typeof require === "function" ? require("./princess-lima-state.js") : null);
|
||||
const api = factory(data, stateApi);
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
root.PrincessLimaSystems = api;
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data, State) {
|
||||
"use strict";
|
||||
|
||||
function copy(state) {
|
||||
return State.normalize(JSON.parse(JSON.stringify(state)));
|
||||
}
|
||||
|
||||
function normalizedVector(x, y, speed) {
|
||||
const length = Math.hypot(Number(x) || 0, Number(y) || 0);
|
||||
return length ? { x: x / length * speed, y: y / length * speed } : { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
function approachVelocity(currentX, currentY, inputX, inputY, deltaMs, boots) {
|
||||
const target = normalizedVector(inputX, inputY, 176);
|
||||
const moving = Boolean(inputX || inputY);
|
||||
const rate = moving ? (boots ? 1900 : 1500) : 2300;
|
||||
const change = Math.min(50, Math.max(0, Number(deltaMs) || 0)) / 1000 * rate;
|
||||
function approach(value, goal) {
|
||||
return Math.abs(goal - value) <= change ? goal : value + Math.sign(goal - value) * change;
|
||||
}
|
||||
const velocity = { x: approach(currentX || 0, target.x), y: approach(currentY || 0, target.y) };
|
||||
const length = Math.hypot(velocity.x, velocity.y);
|
||||
return length > 176 ? normalizedVector(velocity.x, velocity.y, 176) : velocity;
|
||||
}
|
||||
|
||||
function pointInShape(x, y, shape, padding) {
|
||||
const pad = Number(padding) || 0;
|
||||
if (shape.shape === "circle") return Math.hypot(x - shape.x, y - shape.y) <= shape.radius + pad;
|
||||
return x >= shape.x - pad && x <= shape.x + shape.width + pad
|
||||
&& y >= shape.y - pad && y <= shape.y + shape.height + pad;
|
||||
}
|
||||
|
||||
function activeObstacles(state, regionId) {
|
||||
const map = Data.MAPS[regionId];
|
||||
if (!map) return [];
|
||||
const layers = Data.MAP_LAYERS[regionId] || [];
|
||||
const collision = layers.find((layer) => layer.name === "Collision");
|
||||
const dynamic = layers.find((layer) => layer.name === "Dynamic Collision");
|
||||
return (collision ? collision.objects : map.obstacles).concat(
|
||||
(dynamic ? dynamic.objects : map.dynamicObstacles || [])
|
||||
.filter((item) => !state.unlockedRoutes.includes(item.opensWith))
|
||||
);
|
||||
}
|
||||
|
||||
function isSafePosition(state, regionId, x, y) {
|
||||
if (!Data.MAPS[regionId] || x < 30 || y < 30 || x > Data.WIDTH - 30 || y > Data.HEIGHT - 30) return false;
|
||||
return !activeObstacles(state, regionId).some((shape) => pointInShape(x, y, shape, 14));
|
||||
}
|
||||
|
||||
function nearestSafeSpawn(state, regionId, x, y) {
|
||||
const map = Data.MAPS[regionId] || Data.MAPS.village;
|
||||
if (isSafePosition(state, regionId, x, y)) return { region: regionId, spawn: "saved", x, y };
|
||||
const candidates = Object.entries(map.spawns).filter(([, point]) => isSafePosition(state, regionId, point.x, point.y));
|
||||
const best = candidates.sort((a, b) =>
|
||||
Math.hypot(a[1].x - x, a[1].y - y) - Math.hypot(b[1].x - x, b[1].y - y))[0];
|
||||
return best ? { region: regionId, spawn: best[0], x: best[1].x, y: best[1].y }
|
||||
: { region: "village", spawn: "start", x: State.START.x, y: State.START.y };
|
||||
}
|
||||
|
||||
function quantity(state, id) {
|
||||
const found = state.inventory.find((entry) => entry.id === id);
|
||||
return found ? found.quantity : 0;
|
||||
}
|
||||
|
||||
function addItem(state, id, amount) {
|
||||
const next = copy(state);
|
||||
const item = Data.ITEMS[id];
|
||||
if (!next || !item) return next;
|
||||
const cap = item.stack || 1;
|
||||
const existing = next.inventory.find((entry) => entry.id === id);
|
||||
if (existing) existing.quantity = Math.min(cap, existing.quantity + Math.max(1, Math.floor(amount || 1)));
|
||||
else next.inventory.push({ id, quantity: Math.min(cap, Math.max(1, Math.floor(amount || 1))) });
|
||||
return State.normalize(next);
|
||||
}
|
||||
|
||||
function removeItem(state, id, amount) {
|
||||
const next = copy(state);
|
||||
const item = Data.ITEMS[id];
|
||||
if (!next || !item || item.protected) return { state: next, removed: false };
|
||||
const existing = next.inventory.find((entry) => entry.id === id);
|
||||
if (!existing || existing.quantity < amount) return { state: next, removed: false };
|
||||
existing.quantity -= amount;
|
||||
next.inventory = next.inventory.filter((entry) => entry.quantity > 0);
|
||||
return { state: State.normalize(next), removed: true };
|
||||
}
|
||||
|
||||
function useItem(state, id) {
|
||||
const item = Data.ITEMS[id];
|
||||
const next = copy(state);
|
||||
if (!next || !item || item.type !== "consumable" || quantity(next, id) < 1 || next.health >= next.maxHealth) {
|
||||
return { state: next, used: false, amount: 0 };
|
||||
}
|
||||
const amount = Math.min(next.maxHealth - next.health, item.heal);
|
||||
next.health += amount;
|
||||
const removed = removeItem(next, id, 1);
|
||||
return { state: removed.state, used: true, amount };
|
||||
}
|
||||
|
||||
function startQuest(state, id) {
|
||||
const next = copy(state);
|
||||
if (!next || !Data.QUESTS[id]) return next;
|
||||
if (next.quests[id].status === "locked") next.quests[id] = { status: "active", count: 0, rewarded: false };
|
||||
return State.normalize(next);
|
||||
}
|
||||
|
||||
function progressQuest(state, id, amount) {
|
||||
let next = startQuest(state, id);
|
||||
if (!next || 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) : State.normalize(next);
|
||||
}
|
||||
|
||||
function applyQuestConsequences(state, id) {
|
||||
const routes = {
|
||||
village_defence: "village_defended", find_guide: "guide_found", ruins_light: "ruins_complete",
|
||||
wolf_miniboss: "briar_defeated", repair_bridge: "bridge_repaired",
|
||||
stone_guardian: "guardian_defeated", free_scout: "emblem_found",
|
||||
break_wards: "wards_broken", defeat_malrec: "malrec_defeated"
|
||||
};
|
||||
if (routes[id] && !state.unlockedRoutes.includes(routes[id])) state.unlockedRoutes.push(routes[id]);
|
||||
if (id === "aftermath") state.flags.aftermath_complete = true;
|
||||
if (id === "find_guide") state.chapter = Math.max(state.chapter, 2);
|
||||
if (id === "repair_bridge") state.chapter = Math.max(state.chapter, 3);
|
||||
if (id === "free_scout") state.chapter = Math.max(state.chapter, 4);
|
||||
if (id === "defeat_malrec") state.story = "rescue";
|
||||
return state;
|
||||
}
|
||||
|
||||
function completeQuest(state, id) {
|
||||
let next = startQuest(state, id);
|
||||
if (!next || next.quests[id].status === "complete") return next;
|
||||
next.quests[id].status = "complete";
|
||||
if (!next.quests[id].rewarded) {
|
||||
(Data.QUESTS[id].reward || []).forEach(([itemId, amount]) => { next = addItem(next, itemId, amount); });
|
||||
next.quests[id].rewarded = true;
|
||||
}
|
||||
next = applyQuestConsequences(next, id);
|
||||
return State.normalize(next);
|
||||
}
|
||||
|
||||
function solvePuzzle(state, id) {
|
||||
const next = copy(state);
|
||||
if (!next || next.solvedPuzzles.includes(id)) return next;
|
||||
next.solvedPuzzles.push(id);
|
||||
if (id === "forest_stones") return completeQuest(next, "find_guide");
|
||||
if (id === "ruin_braziers") return completeQuest(next, "ruins_light");
|
||||
if (id === "bridge_winches") return completeQuest(next, "repair_bridge");
|
||||
if (id === "shadow_wards") return completeQuest(next, "break_wards");
|
||||
return State.normalize(next);
|
||||
}
|
||||
|
||||
function damage(state, amount, now, lastHitAt) {
|
||||
const next = copy(state);
|
||||
if (!next || Number(now) - Number(lastHitAt || 0) < 850) return { state: next, hit: false, defeated: false };
|
||||
const dealt = Math.max(1, Math.round(amount - next.defence));
|
||||
next.health = Math.max(0, next.health - dealt);
|
||||
return { state: State.normalize(next), hit: true, defeated: next.health <= 0, amount: dealt };
|
||||
}
|
||||
|
||||
function bossPhase(health, maximum, phases) {
|
||||
const count = Math.max(1, Math.floor(Number(phases) || 1));
|
||||
const ratio = Math.max(0, Number(health) || 0) / Math.max(1, Number(maximum) || 1);
|
||||
if (count === 1) return 1;
|
||||
if (count === 2) return ratio > 0.5 ? 1 : 2;
|
||||
return ratio > 0.66 ? 1 : ratio > 0.33 ? 2 : 3;
|
||||
}
|
||||
|
||||
const ATTACK_TIMING = Object.freeze({ windup: 85, active: 105, recovery: 135, cooldown: 360 });
|
||||
|
||||
function attackPhase(elapsed, timing) {
|
||||
const value = Math.max(0, Number(elapsed) || 0);
|
||||
const config = timing || ATTACK_TIMING;
|
||||
if (value < config.windup) return "windup";
|
||||
if (value < config.windup + config.active) return "active";
|
||||
if (value < config.windup + config.active + config.recovery) return "recovery";
|
||||
return "complete";
|
||||
}
|
||||
|
||||
function attackHitbox(facing, x, y) {
|
||||
const boxes = {
|
||||
north: { x: x - 25, y: y - 76, width: 50, height: 66, angle: 270 },
|
||||
south: { x: x - 25, y: y + 8, width: 50, height: 66, angle: 90 },
|
||||
west: { x: x - 76, y: y - 27, width: 66, height: 54, angle: 180 },
|
||||
east: { x: x + 10, y: y - 27, width: 66, height: 54, angle: 0 }
|
||||
};
|
||||
return boxes[facing] || boxes.south;
|
||||
}
|
||||
|
||||
function wallSafeKnockback(state, regionId, x, y, directionX, directionY, distance) {
|
||||
const push = normalizedVector(directionX, directionY, Math.max(0, Number(distance) || 0));
|
||||
const target = { x: x + push.x, y: y + push.y };
|
||||
if (isSafePosition(state, regionId, target.x, target.y)) return target;
|
||||
for (let scale = 0.75; scale >= 0; scale -= 0.25) {
|
||||
const candidate = { x: x + push.x * scale, y: y + push.y * scale };
|
||||
if (isSafePosition(state, regionId, candidate.x, candidate.y)) return candidate;
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function respawn(state) {
|
||||
let next = copy(state);
|
||||
if (!next) return next;
|
||||
const safe = nearestSafeSpawn(next, next.checkpoint.region, next.checkpoint.x, next.checkpoint.y);
|
||||
next.health = Math.max(50, Math.ceil(next.maxHealth * 0.65));
|
||||
next.region = safe.region;
|
||||
next.position = { spawn: safe.spawn, x: safe.x, y: safe.y, facing: "south" };
|
||||
return State.normalize(next);
|
||||
}
|
||||
|
||||
function recordBoss(state, id) {
|
||||
let next = copy(state);
|
||||
if (!next || !["briar_wolf", "stone_guardian", "captain", "malrec"].includes(id)) return next;
|
||||
if (!next.defeatedBosses.includes(id)) next.defeatedBosses.push(id);
|
||||
if (id === "briar_wolf") next = completeQuest(next, "wolf_miniboss");
|
||||
if (id === "stone_guardian") next = completeQuest(next, "stone_guardian");
|
||||
if (id === "captain") next = progressQuest(next, "free_scout", 1);
|
||||
if (id === "malrec") next = completeQuest(next, "defeat_malrec");
|
||||
return State.normalize(next);
|
||||
}
|
||||
|
||||
function currentObjective(state) {
|
||||
if (state.rescued) return "Princess Lima is safe. The road home is open.";
|
||||
const activeMain = Data.QUEST_IDS.find((id) => Data.QUESTS[id].main && state.quests[id].status === "active");
|
||||
if (activeMain) return Data.QUESTS[activeMain].description;
|
||||
if (!state.flags.aftermath_complete) return "Speak with Elder Corin in the village square.";
|
||||
if (!state.unlockedRoutes.includes("village_defended")) return "Defend the village from the second raid.";
|
||||
if (!state.unlockedRoutes.includes("guide_found")) return "Find Guide Tovin in the Whispering Woods.";
|
||||
if (!state.unlockedRoutes.includes("ruins_complete")) return "Explore the Sunken Ruins.";
|
||||
if (!state.unlockedRoutes.includes("briar_defeated")) return "Defeat the Briar Wolf and open the mountain trail.";
|
||||
if (!state.unlockedRoutes.includes("bridge_repaired")) return "Repair the bridge across the Mountain Pass.";
|
||||
if (!state.unlockedRoutes.includes("guardian_defeated")) return "Defeat the Stone Guardian.";
|
||||
if (!state.unlockedRoutes.includes("emblem_found")) return "Free Scout Elowen at Blackridge Camp.";
|
||||
if (!state.unlockedRoutes.includes("wards_broken")) return "Break the three wards inside the fortress.";
|
||||
if (!state.unlockedRoutes.includes("malrec_defeated")) return "Confront Lord Malrec in the throne room.";
|
||||
return "Find Princess Lima beyond the throne room.";
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
normalizedVector, approachVelocity, pointInShape, activeObstacles, isSafePosition, nearestSafeSpawn,
|
||||
quantity, addItem, removeItem, useItem, startQuest, progressQuest, completeQuest, solvePuzzle,
|
||||
damage, bossPhase, ATTACK_TIMING, attackPhase, attackHitbox, wallSafeKnockback,
|
||||
respawn, recordBoss, currentObjective
|
||||
});
|
||||
}));
|
||||
@@ -1,193 +0,0 @@
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
const Data = root.PrincessLimaData;
|
||||
const Systems = root.PrincessLimaSystems;
|
||||
|
||||
function create(container, actions) {
|
||||
const overlay = container.querySelector("[data-lima-overlay]");
|
||||
const panel = container.querySelector("[data-lima-panel]");
|
||||
const title = container.querySelector("[data-lima-panel-title]");
|
||||
const body = container.querySelector("[data-lima-panel-body]");
|
||||
const closeButton = container.querySelector("[data-lima-panel-close]");
|
||||
let returnFocus = null;
|
||||
let dialogue = null;
|
||||
let dialogueIndex = 0;
|
||||
|
||||
function lock(value) {
|
||||
actions.onLock(value);
|
||||
overlay.hidden = !value;
|
||||
container.classList.toggle("is-overlay-open", value);
|
||||
}
|
||||
|
||||
function show(kind, heading, html, closable) {
|
||||
returnFocus = document.activeElement;
|
||||
panel.dataset.kind = kind;
|
||||
title.textContent = heading;
|
||||
body.innerHTML = html;
|
||||
closeButton.hidden = closable === false;
|
||||
lock(true);
|
||||
const target = body.querySelector("button, input, select") || closeButton;
|
||||
target.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (dialogue) return advanceDialogue();
|
||||
lock(false);
|
||||
body.innerHTML = "";
|
||||
if (returnFocus && document.contains(returnFocus)) returnFocus.focus();
|
||||
else container.querySelector("[data-lima-game]").focus();
|
||||
}
|
||||
|
||||
function openDialogue(id, done) {
|
||||
const npc = Data.NPCS[id];
|
||||
if (!npc) return;
|
||||
dialogue = { id, lines: npc.dialogue.slice(), done };
|
||||
dialogueIndex = 0;
|
||||
renderDialogue();
|
||||
}
|
||||
|
||||
function renderDialogue() {
|
||||
const npc = Data.NPCS[dialogue.id];
|
||||
const line = dialogue.lines[dialogueIndex];
|
||||
show("dialogue", npc.name, `
|
||||
<div class="lima-dialogue">
|
||||
<div class="lima-dialogue__portrait lima-sprite lima-sprite--${npc.frame}" aria-hidden="true"></div>
|
||||
<p data-lima-dialogue-text>${escapeHtml(line)}</p>
|
||||
</div>
|
||||
<button type="button" class="lima-primary" data-lima-advance>${dialogueIndex + 1 < dialogue.lines.length ? "Continue" : "Finish"}</button>
|
||||
`, false);
|
||||
body.querySelector("[data-lima-advance]").addEventListener("click", advanceDialogue, { once: true });
|
||||
}
|
||||
|
||||
function advanceDialogue() {
|
||||
if (!dialogue) return;
|
||||
dialogueIndex += 1;
|
||||
if (dialogueIndex < dialogue.lines.length) return renderDialogue();
|
||||
const done = dialogue.done;
|
||||
dialogue = null;
|
||||
lock(false);
|
||||
body.innerHTML = "";
|
||||
container.querySelector("[data-lima-game]").focus();
|
||||
if (done) done();
|
||||
}
|
||||
|
||||
function openPanel(kind, state) {
|
||||
if (kind === "inventory") {
|
||||
const entries = state.inventory.length ? state.inventory.map((entry) => {
|
||||
const item = Data.ITEMS[entry.id];
|
||||
const equipped = Object.values(state.equipment).includes(entry.id) ? " · Equipped" : "";
|
||||
return `<li><strong>${escapeHtml(item.name)}</strong><span>×${entry.quantity}${equipped}</span><p>${escapeHtml(item.description)}</p></li>`;
|
||||
}).join("") : "<li>No items yet.</li>";
|
||||
show("inventory", "Inventory", `<ul class="lima-list">${entries}</ul><button type="button" data-lima-use-tonic>Use Healing Tonic</button>`);
|
||||
const use = body.querySelector("[data-lima-use-tonic]");
|
||||
use.addEventListener("click", () => { actions.onUseTonic(); openPanel("inventory", actions.getState()); });
|
||||
} else if (kind === "quests") {
|
||||
const quests = Data.QUEST_IDS.filter((id) => state.quests[id].status !== "locked").map((id) => {
|
||||
const quest = Data.QUESTS[id];
|
||||
const progress = state.quests[id];
|
||||
return `<li class="${progress.status === "complete" ? "is-complete" : ""}">
|
||||
<strong>${quest.main ? "Main · " : "Optional · "}${escapeHtml(quest.title)}</strong>
|
||||
<span>${progress.status === "complete" ? "Complete" : `${progress.count} / ${quest.target}`}</span>
|
||||
<p>${escapeHtml(quest.description)} · ${escapeHtml(quest.region)}</p>
|
||||
</li>`;
|
||||
}).join("");
|
||||
show("quests", "Quest Log", `<p class="lima-panel-lead">${escapeHtml(Systems.currentObjective(state))}</p><ul class="lima-list">${quests || "<li>No quests yet.</li>"}</ul>`);
|
||||
} else if (kind === "settings") {
|
||||
show("settings", "Settings", `
|
||||
<label class="lima-setting"><span>Master volume</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.master}" data-setting="master"></label>
|
||||
<label class="lima-setting"><span>Music</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.music}" data-setting="music"></label>
|
||||
<label class="lima-setting"><span>Effects</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.effects}" data-setting="effects"></label>
|
||||
<label class="lima-setting"><span>Narration</span><input type="range" min="0" max="1" step="0.05" value="${state.settings.voice}" data-setting="voice"></label>
|
||||
<label class="lima-check"><input type="checkbox" data-setting="narrationEnabled" ${state.settings.narrationEnabled ? "checked" : ""}> Spoken narration</label>
|
||||
<label class="lima-check"><input type="checkbox" data-setting="subtitles" ${state.settings.subtitles ? "checked" : ""}> Cinematic subtitles</label>
|
||||
<label class="lima-check"><input type="checkbox" data-setting="reducedMotion" ${state.settings.reducedMotion ? "checked" : ""}> Reduced motion</label>
|
||||
<label class="lima-check"><input type="checkbox" data-setting="screenShake" ${state.settings.screenShake ? "checked" : ""}> Screen shake</label>
|
||||
<label class="lima-check"><input type="checkbox" data-setting="highContrast" ${state.settings.highContrast ? "checked" : ""}> High-contrast interface</label>
|
||||
<label class="lima-setting"><span>Text speed</span><select data-setting="textSpeed">${["slow", "normal", "fast", "instant"].map((value) => `<option ${state.settings.textSpeed === value ? "selected" : ""}>${value}</option>`).join("")}</select></label>
|
||||
`);
|
||||
body.querySelectorAll("[data-setting]").forEach((control) => control.addEventListener("change", () => {
|
||||
const value = control.type === "checkbox" ? control.checked : control.type === "range" ? Number(control.value) : control.value;
|
||||
actions.onSetting(control.dataset.setting, value);
|
||||
}));
|
||||
} else if (kind === "pause") {
|
||||
show("pause", "Paused", `
|
||||
<p>${escapeHtml(Systems.currentObjective(state))}</p>
|
||||
<div class="lima-menu-stack">
|
||||
<button type="button" data-panel="inventory">Inventory</button>
|
||||
<button type="button" data-panel="quests">Quest Log</button>
|
||||
<button type="button" data-panel="settings">Settings & accessibility</button>
|
||||
<button type="button" data-lima-replay-intro-panel>Replay introduction</button>
|
||||
<button type="button" data-lima-fullscreen-panel>Toggle fullscreen</button>
|
||||
<button type="button" data-lima-reset-request>Reset save</button>
|
||||
<a href="/">Exit to Website</a>
|
||||
</div>
|
||||
`);
|
||||
body.querySelectorAll("[data-panel]").forEach((button) => button.addEventListener("click", () => openPanel(button.dataset.panel, actions.getState())));
|
||||
body.querySelector("[data-lima-replay-intro-panel]").addEventListener("click", actions.onReplayIntro);
|
||||
body.querySelector("[data-lima-fullscreen-panel]").addEventListener("click", actions.onFullscreen);
|
||||
body.querySelector("[data-lima-reset-request]").addEventListener("click", confirmReset);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmReset() {
|
||||
show("confirm", "Delete this adventure?", `
|
||||
<p>This permanently removes the Princess Lima save on this device.</p>
|
||||
<div class="lima-confirm"><button type="button" class="lima-danger" data-confirm-reset>Delete save</button><button type="button" data-cancel-reset>Keep progress</button></div>
|
||||
`, false);
|
||||
body.querySelector("[data-confirm-reset]").addEventListener("click", actions.onReset, { once: true });
|
||||
body.querySelector("[data-cancel-reset]").addEventListener("click", () => openPanel("pause", actions.getState()), { once: true });
|
||||
}
|
||||
|
||||
function gameOver(state) {
|
||||
show("defeat", "The road is not finished", `
|
||||
<p>You awaken at the latest safe checkpoint with your quests and important items intact.</p>
|
||||
<button type="button" class="lima-primary" data-respawn>Return to checkpoint</button>
|
||||
`, false);
|
||||
body.querySelector("[data-respawn]").addEventListener("click", actions.onRespawn, { once: true });
|
||||
}
|
||||
|
||||
function ending(state) {
|
||||
show("ending", "Princess Lima Rescued", `
|
||||
<p>At dawn, the roads reopen. The villages ring their bells, the forest paths quiet, and the mountain fires become beacons instead of warnings.</p>
|
||||
<p><strong>${escapeHtml(state.player.name)}</strong> is offered a place at the royal table—and chooses first to walk the repaired road home with Lima.</p>
|
||||
<p class="lima-ending-note">The kingdom remains explorable from your final save.</p>
|
||||
<button type="button" class="lima-primary" data-ending-continue>Continue exploring</button>
|
||||
<a class="lima-button-link" href="/">Exit to Website</a>
|
||||
`, false);
|
||||
body.querySelector("[data-ending-continue]").addEventListener("click", close, { once: true });
|
||||
}
|
||||
|
||||
closeButton.addEventListener("click", close);
|
||||
overlay.addEventListener("click", (event) => { if (event.target === overlay && !dialogue) close(); });
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !overlay.hidden && !dialogue) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
if ((event.key === "Enter" || event.key === " ") && dialogue && !overlay.hidden) {
|
||||
event.preventDefault();
|
||||
advanceDialogue();
|
||||
}
|
||||
if (!overlay.hidden && !dialogue && ["ArrowDown", "ArrowUp"].includes(event.key)) {
|
||||
const controls = Array.from(panel.querySelectorAll("button:not([hidden]), a[href], input, select"))
|
||||
.filter((control) => !control.disabled && control.offsetParent !== null);
|
||||
if (!controls.length) return;
|
||||
event.preventDefault();
|
||||
const current = controls.indexOf(document.activeElement);
|
||||
const change = event.key === "ArrowDown" ? 1 : -1;
|
||||
controls[(current + change + controls.length) % controls.length].focus();
|
||||
}
|
||||
});
|
||||
|
||||
return Object.freeze({ show, close, openDialogue, openPanel, gameOver, ending, confirmReset });
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[character]));
|
||||
}
|
||||
|
||||
root.PrincessLimaUI = Object.freeze({ create });
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this));
|
||||
@@ -1,234 +0,0 @@
|
||||
(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: 52, damage: 13, speed: 60, awareness: 520, attackRange: 190, cooldown: 1600, telegraph: 760 }
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
}));
|
||||
@@ -1,278 +0,0 @@
|
||||
(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
|
||||
});
|
||||
}());
|
||||
@@ -1,82 +0,0 @@
|
||||
(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));
|
||||
@@ -1,717 +0,0 @@
|
||||
(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();
|
||||
this.sunAidUsed = false;
|
||||
}
|
||||
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") {
|
||||
if (this.sunAidUsed) return controller.status("The Sun Crystal is gathering its light again.");
|
||||
const malrec = this.enemies.getChildren().find((enemy) => enemy.active && enemy.getData("actorId") === "malrec");
|
||||
if (!malrec) return controller.status("The Sun Crystal is warm, but its moment has not come.");
|
||||
this.sunAidUsed = true;
|
||||
controller.state.flags.sun_veil_broken = true;
|
||||
controller.state.health = Math.min(controller.state.maxHealth, controller.state.health + 25);
|
||||
malrec.setData("health", Math.max(1, malrec.getData("health") - 12));
|
||||
malrec.setData("stunnedUntil", this.time.now + 2100);
|
||||
malrec.setData("state", "hurt");
|
||||
malrec.setVelocity(0).setTint(0xffe39a).setTintMode(Phaser.TintModes.FILL);
|
||||
this.projectiles.forEach((projectile) => projectile.active && projectile.destroy());
|
||||
this.projectiles = [];
|
||||
this.time.delayedCall(350, () => malrec.active && malrec.clearTint());
|
||||
controller.save();
|
||||
controller.updateHud();
|
||||
this.cameras.main.flash(200, 255, 230, 150);
|
||||
controller.status("Lima channels the Sun Crystal: Malrec's veil breaks, and your strength returns.");
|
||||
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 + 1 : 1;
|
||||
const preparedReduction = id === "malrec"
|
||||
? (controller.state.flags.defences_disabled ? 2 : 0) + (controller.state.flags.prisoners_freed ? 2 : 0)
|
||||
: 0;
|
||||
const damage = Math.max(8, spec.damage - preparedReduction);
|
||||
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) * (id === "malrec" ? 0.3 : 0.22);
|
||||
this.spawnProjectile(enemy.x, enemy.y, angle, damage, id === "malrec" ? 150 : 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));
|
||||
@@ -1,243 +0,0 @@
|
||||
(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
|
||||
});
|
||||
}));
|
||||
@@ -1,217 +0,0 @@
|
||||
(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
|
||||
});
|
||||
}));
|
||||
@@ -1,239 +0,0 @@
|
||||
(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));
|
||||
Reference in New Issue
Block a user