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,71 +0,0 @@
|
||||
// Served as .js so strict production MIME checks accept this native ES module.
|
||||
const ROOT = "/assets/games/two-rivers/data";
|
||||
const FILES = {
|
||||
assets: "assets.json",
|
||||
characters: "characters.json",
|
||||
locations: "locations.json",
|
||||
items: "items.json",
|
||||
quests: "quests.json",
|
||||
achievements: "achievements.json",
|
||||
endings: "endings.json",
|
||||
dialogue: ["dialogue/story.json", "dialogue/scenes.json", "dialogue/chapters.json"]
|
||||
};
|
||||
|
||||
export async function loadContent(onProgress = () => {}) {
|
||||
const content = {};
|
||||
const entries = Object.entries(FILES);
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const [key, file] = entries[index];
|
||||
const files = Array.isArray(file) ? file : [file];
|
||||
const payloads = [];
|
||||
for (const source of files) {
|
||||
const response = await fetch(`${ROOT}/${source}`, {cache: "no-cache"});
|
||||
if (!response.ok) throw new Error(`Could not load ${source} (${response.status})`);
|
||||
payloads.push(await response.json());
|
||||
}
|
||||
content[key] = Array.isArray(file) ? Object.assign({}, ...payloads) : payloads[0];
|
||||
onProgress(Math.round(((index + 1) / entries.length) * 100), files.at(-1));
|
||||
}
|
||||
const errors = validateContent(content);
|
||||
if (errors.length) throw new Error(`Content validation failed: ${errors.join("; ")}`);
|
||||
return Object.freeze(content);
|
||||
}
|
||||
|
||||
export function validateContent(content) {
|
||||
const errors = [];
|
||||
["assets", "characters", "locations", "items", "quests", "achievements", "endings", "dialogue"].forEach((key) => {
|
||||
if (!content[key] || typeof content[key] !== "object") errors.push(`missing ${key}`);
|
||||
});
|
||||
if (errors.length) return errors;
|
||||
Object.entries(content.locations).forEach(([id, location]) => {
|
||||
if (!location.name || !Array.isArray(location.hotspots)) errors.push(`invalid location ${id}`);
|
||||
if (!content.assets.images.some((asset) => asset.id === location.background)) errors.push(`unknown background ${id}/${location.background}`);
|
||||
location.hotspots?.forEach((spot) => {
|
||||
if (!spot.id || typeof spot.x !== "number" || spot.x < 0 || spot.x > 1) errors.push(`invalid hotspot ${id}/${spot.id || "unknown"}`);
|
||||
if (spot.dialogue && !content.dialogue[spot.dialogue]) errors.push(`unknown dialogue ${spot.dialogue}`);
|
||||
if (spot.goto && !content.locations[spot.goto]) errors.push(`unknown location ${spot.goto}`);
|
||||
});
|
||||
});
|
||||
const effectTypes = new Set(["relationship", "flag", "item", "achievement", "unlockCG", "bias", "quest", "ending", "goto", "discoverGift"]);
|
||||
Object.entries(content.dialogue).forEach(([dialogueId, dialogue]) => {
|
||||
if (!dialogue.nodes?.[dialogue.start]) errors.push(`invalid start ${dialogueId}`);
|
||||
Object.entries(dialogue.nodes || {}).forEach(([nodeId, node]) => {
|
||||
if (!node.text || !node.speaker) errors.push(`invalid node ${dialogueId}/${nodeId}`);
|
||||
if (!content.characters[node.speaker]) errors.push(`unknown speaker ${dialogueId}/${nodeId}/${node.speaker}`);
|
||||
if (node.next && !dialogue.nodes[node.next]) errors.push(`unknown next ${dialogueId}/${nodeId}`);
|
||||
if (node.conditions && !Array.isArray(node.conditions)) errors.push(`invalid conditions ${dialogueId}/${nodeId}`);
|
||||
node.choices?.forEach((choice) => {
|
||||
if (!dialogue.nodes[choice.next]) errors.push(`unknown choice next ${dialogueId}/${nodeId}`);
|
||||
if (choice.conditions && !Array.isArray(choice.conditions)) errors.push(`invalid choice conditions ${dialogueId}/${nodeId}`);
|
||||
choice.effects?.forEach((effect) => {
|
||||
if (!effectTypes.has(effect.type)) errors.push(`invalid effect ${effect.type}`);
|
||||
});
|
||||
});
|
||||
node.effects?.forEach((effect) => {
|
||||
if (!effectTypes.has(effect.type)) errors.push(`invalid effect ${effect.type}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
if (Object.keys(content.endings).length !== 4) errors.push("exactly four endings required");
|
||||
return errors;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import {normalizeState} from "./state.js";
|
||||
|
||||
export const SAVE_KEY = "zxh_two_rivers_saves_v1";
|
||||
export const SETTINGS_KEY = "zxh_two_rivers_settings_v1";
|
||||
export const AGE_KEY = "zxh_two_rivers_age_gate_v1";
|
||||
|
||||
function checksum(value) {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(16);
|
||||
}
|
||||
|
||||
export function createSaveService(storage = window.localStorage) {
|
||||
let recovery = null;
|
||||
|
||||
const parseEnvelope = (raw, source) => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const envelope = JSON.parse(raw);
|
||||
if (!envelope || typeof envelope !== "object") throw new Error("Save data is not an object");
|
||||
if (envelope.version !== 1) throw new Error(`Unknown save version ${String(envelope.version)}`);
|
||||
if (!envelope.slots || typeof envelope.slots !== "object") throw new Error("Save slots are missing");
|
||||
return envelope;
|
||||
} catch (error) {
|
||||
recovery = {source, message: error.message};
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readEnvelope = () => {
|
||||
recovery = null;
|
||||
const primaryRaw = storage.getItem(SAVE_KEY);
|
||||
if (!primaryRaw) {
|
||||
const pending = parseEnvelope(storage.getItem(`${SAVE_KEY}:pending`), "pending");
|
||||
if (pending) {
|
||||
recovery = {source: "pending", message: "Recovered an interrupted save write."};
|
||||
return pending;
|
||||
}
|
||||
return {version: 1, slots: {}};
|
||||
}
|
||||
const primary = parseEnvelope(primaryRaw, "primary");
|
||||
if (primary) return primary;
|
||||
const primaryIssue = recovery;
|
||||
const backup = parseEnvelope(storage.getItem(`${SAVE_KEY}:backup`), "backup");
|
||||
if (backup) {
|
||||
recovery = {source: "backup", message: `Recovered the previous valid save. ${primaryIssue?.message || ""}`.trim()};
|
||||
return backup;
|
||||
}
|
||||
recovery = primaryIssue || {source: "primary", message: "Save data could not be read."};
|
||||
return {version: 1, slots: {}};
|
||||
};
|
||||
|
||||
const writeEnvelope = (envelope) => {
|
||||
const previous = storage.getItem(SAVE_KEY);
|
||||
if (previous) storage.setItem(`${SAVE_KEY}:backup`, previous);
|
||||
storage.setItem(`${SAVE_KEY}:pending`, JSON.stringify(envelope));
|
||||
storage.setItem(SAVE_KEY, JSON.stringify(envelope));
|
||||
storage.removeItem(`${SAVE_KEY}:pending`);
|
||||
};
|
||||
|
||||
return {
|
||||
list() {
|
||||
return readEnvelope().slots;
|
||||
},
|
||||
recoveryInfo() {
|
||||
readEnvelope();
|
||||
return recovery ? {...recovery} : null;
|
||||
},
|
||||
save(slot, state) {
|
||||
const normalized = normalizeState(state);
|
||||
if (!normalized) throw new Error("Refused invalid save state");
|
||||
const envelope = readEnvelope();
|
||||
const payload = JSON.stringify(normalized);
|
||||
envelope.slots[slot] = {
|
||||
version: 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
checksum: checksum(payload),
|
||||
location: normalized.location,
|
||||
playTimeSeconds: normalized.playTimeSeconds,
|
||||
state: normalized
|
||||
};
|
||||
writeEnvelope(envelope);
|
||||
return envelope.slots[slot];
|
||||
},
|
||||
load(slot) {
|
||||
const record = readEnvelope().slots[slot];
|
||||
if (!record?.state) return null;
|
||||
const normalized = normalizeState(record.state);
|
||||
if (!normalized || checksum(JSON.stringify(normalized)) !== record.checksum) return null;
|
||||
return normalized;
|
||||
},
|
||||
hasSave() {
|
||||
return Boolean(readEnvelope().slots.auto);
|
||||
},
|
||||
settings(value) {
|
||||
if (value) storage.setItem(SETTINGS_KEY, JSON.stringify(value));
|
||||
try { return JSON.parse(storage.getItem(SETTINGS_KEY) || "{}"); } catch (_error) { return {}; }
|
||||
},
|
||||
ageConfirmed() {
|
||||
return storage.getItem(AGE_KEY) === "yes";
|
||||
},
|
||||
confirmAge() {
|
||||
storage.setItem(AGE_KEY, "yes");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
// Native ES module; the .js suffix matches the production server's JavaScript MIME mapping.
|
||||
export const RELATIONSHIPS = ["trust", "respect", "comfort", "humour", "adventure", "romance", "familyApproval"];
|
||||
export const SAVE_VERSION = 1;
|
||||
|
||||
const clamp = (value, min = 0, max = 100) => Math.max(min, Math.min(max, Number(value) || 0));
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
export function freshState(settings = {}) {
|
||||
return normalizeState({
|
||||
version: SAVE_VERSION,
|
||||
location: "courtyard",
|
||||
checkpoint: "courtyard",
|
||||
position: 0.14,
|
||||
relationships: {
|
||||
trust: 24, respect: 28, comfort: 18, humour: 20,
|
||||
adventure: 22, romance: 12, familyApproval: 20
|
||||
},
|
||||
inventory: [],
|
||||
gifts: [],
|
||||
discoveredGifts: [],
|
||||
flags: {},
|
||||
choices: [],
|
||||
quests: {
|
||||
garland: {status: "active", count: 0, rewarded: false},
|
||||
letters: {status: "locked", count: 0, rewarded: false}
|
||||
},
|
||||
puzzles: {},
|
||||
minigames: {},
|
||||
unlockedCGs: [],
|
||||
achievements: [],
|
||||
unlockedEndings: [],
|
||||
readNodes: [],
|
||||
dialogueHistory: [],
|
||||
dialogue: null,
|
||||
endingBias: {},
|
||||
finalChoice: null,
|
||||
currentEnding: null,
|
||||
minigameCheckpoint: null,
|
||||
playTimeSeconds: 0,
|
||||
settings: {
|
||||
sound: false,
|
||||
masterVolume: 0.65,
|
||||
musicVolume: 0.55,
|
||||
ambienceVolume: 0.35,
|
||||
sfxVolume: 0.7,
|
||||
textSpeed: "normal",
|
||||
autoDelay: 1800,
|
||||
reducedMotion: matchMediaSafe("(prefers-reduced-motion: reduce)"),
|
||||
highContrast: false,
|
||||
assistedMinigames: false,
|
||||
effectsQuality: "full",
|
||||
...settings
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function matchMediaSafe(query) {
|
||||
return typeof window !== "undefined" && window.matchMedia ? window.matchMedia(query).matches : false;
|
||||
}
|
||||
|
||||
export function normalizeState(candidate) {
|
||||
if (!candidate || typeof candidate !== "object") return null;
|
||||
const base = {
|
||||
version: SAVE_VERSION,
|
||||
location: String(candidate.location || "courtyard"),
|
||||
checkpoint: String(candidate.checkpoint || candidate.location || "courtyard"),
|
||||
position: clamp(candidate.position ?? 0.14, 0.04, 0.96),
|
||||
relationships: {},
|
||||
inventory: uniqueStrings(candidate.inventory, 80),
|
||||
gifts: uniqueStrings(candidate.gifts, 6),
|
||||
discoveredGifts: uniqueStrings(candidate.discoveredGifts, 6),
|
||||
flags: cleanObject(candidate.flags),
|
||||
choices: uniqueStrings(candidate.choices, 160),
|
||||
quests: candidate.quests && typeof candidate.quests === "object" ? clone(candidate.quests) : {},
|
||||
puzzles: cleanObject(candidate.puzzles),
|
||||
minigames: candidate.minigames && typeof candidate.minigames === "object" ? clone(candidate.minigames) : {},
|
||||
unlockedCGs: uniqueStrings(candidate.unlockedCGs, 16),
|
||||
achievements: uniqueStrings(candidate.achievements, 24),
|
||||
unlockedEndings: uniqueStrings(candidate.unlockedEndings, 4),
|
||||
readNodes: uniqueStrings(candidate.readNodes, 400),
|
||||
dialogueHistory: normalizeHistory(candidate.dialogueHistory),
|
||||
dialogue: candidate.dialogue && typeof candidate.dialogue === "object" ? clone(candidate.dialogue) : null,
|
||||
endingBias: candidate.endingBias && typeof candidate.endingBias === "object" ? clone(candidate.endingBias) : {},
|
||||
finalChoice: typeof candidate.finalChoice === "string" ? candidate.finalChoice : null,
|
||||
currentEnding: typeof candidate.currentEnding === "string" ? candidate.currentEnding : null,
|
||||
minigameCheckpoint: typeof candidate.minigameCheckpoint === "string" ? candidate.minigameCheckpoint : null,
|
||||
playTimeSeconds: clamp(candidate.playTimeSeconds, 0, 999999),
|
||||
settings: {
|
||||
sound: Boolean(candidate.settings?.sound),
|
||||
masterVolume: clamp(candidate.settings?.masterVolume ?? 0.65, 0, 1),
|
||||
musicVolume: clamp(candidate.settings?.musicVolume ?? 0.55, 0, 1),
|
||||
ambienceVolume: clamp(candidate.settings?.ambienceVolume ?? 0.35, 0, 1),
|
||||
sfxVolume: clamp(candidate.settings?.sfxVolume ?? 0.7, 0, 1),
|
||||
textSpeed: ["slow", "normal", "fast", "instant"].includes(candidate.settings?.textSpeed) ? candidate.settings.textSpeed : "normal",
|
||||
autoDelay: clamp(candidate.settings?.autoDelay ?? 1800, 800, 5000),
|
||||
reducedMotion: Boolean(candidate.settings?.reducedMotion),
|
||||
highContrast: Boolean(candidate.settings?.highContrast),
|
||||
assistedMinigames: Boolean(candidate.settings?.assistedMinigames),
|
||||
effectsQuality: ["full", "reduced", "minimal"].includes(candidate.settings?.effectsQuality) ? candidate.settings.effectsQuality : "full"
|
||||
}
|
||||
};
|
||||
RELATIONSHIPS.forEach((key) => { base.relationships[key] = clamp(candidate.relationships?.[key] ?? 20); });
|
||||
["garland", "letters"].forEach((id) => {
|
||||
const value = base.quests[id] || {};
|
||||
base.quests[id] = {
|
||||
status: ["locked", "active", "complete"].includes(value.status) ? value.status : (id === "garland" ? "active" : "locked"),
|
||||
count: clamp(value.count, 0, id === "garland" ? 5 : 3),
|
||||
rewarded: Boolean(value.rewarded)
|
||||
};
|
||||
});
|
||||
return base;
|
||||
}
|
||||
|
||||
function uniqueStrings(value, cap) {
|
||||
return Array.isArray(value)
|
||||
? [...new Set(value.filter((item) => typeof item === "string" && /^[a-z0-9_:-]{1,80}$/i.test(item)))].slice(0, cap)
|
||||
: [];
|
||||
}
|
||||
|
||||
function cleanObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter(([key, item]) =>
|
||||
/^[a-z0-9_:-]{1,80}$/i.test(key) && ["boolean", "number", "string"].includes(typeof item)).slice(0, 200));
|
||||
}
|
||||
|
||||
function normalizeHistory(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((entry) =>
|
||||
entry && typeof entry === "object" &&
|
||||
typeof entry.speaker === "string" && typeof entry.text === "string")
|
||||
.slice(-200)
|
||||
.map((entry) => ({
|
||||
speaker: entry.speaker.slice(0, 80),
|
||||
text: entry.text.slice(0, 1200)
|
||||
}));
|
||||
}
|
||||
|
||||
export function createStore(initial) {
|
||||
let state = normalizeState(initial) || freshState();
|
||||
const listeners = new Set();
|
||||
return {
|
||||
get: () => clone(state),
|
||||
dispatch(command) {
|
||||
state = reduce(state, command);
|
||||
listeners.forEach((listener) => listener(clone(state), command));
|
||||
return clone(state);
|
||||
},
|
||||
replace(next) {
|
||||
const normalized = normalizeState(next);
|
||||
if (!normalized) throw new Error("Invalid game state");
|
||||
state = normalized;
|
||||
listeners.forEach((listener) => listener(clone(state), {type: "replace"}));
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function reduce(current, command) {
|
||||
const state = clone(current);
|
||||
if (!command || typeof command.type !== "string") return normalizeState(state);
|
||||
switch (command.type) {
|
||||
case "relationship":
|
||||
if (RELATIONSHIPS.includes(command.key)) {
|
||||
state.relationships[command.key] = clamp(state.relationships[command.key] + Number(command.amount || 0));
|
||||
if (command.key === "trust" && state.relationships.trust >= 75 && !state.achievements.includes("open_book")) state.achievements.push("open_book");
|
||||
if (command.key === "familyApproval" && state.relationships.familyApproval >= 75 && !state.achievements.includes("royal_favourite")) state.achievements.push("royal_favourite");
|
||||
}
|
||||
break;
|
||||
case "flag":
|
||||
if (/^[a-z0-9_:-]+$/i.test(command.id)) state.flags[command.id] = command.value !== false;
|
||||
break;
|
||||
case "goto":
|
||||
state.location = command.location;
|
||||
state.checkpoint = command.location;
|
||||
state.position = clamp(command.position ?? 0.08, 0.04, 0.96);
|
||||
state.dialogue = null;
|
||||
break;
|
||||
case "position":
|
||||
state.position = clamp(command.value, 0.04, 0.96);
|
||||
break;
|
||||
case "item":
|
||||
if (!state.inventory.includes(command.id)) state.inventory.push(command.id);
|
||||
break;
|
||||
case "choice":
|
||||
if (!state.choices.includes(command.id)) state.choices.push(command.id);
|
||||
break;
|
||||
case "read":
|
||||
if (!state.readNodes.includes(command.id)) state.readNodes.push(command.id);
|
||||
break;
|
||||
case "history":
|
||||
state.dialogueHistory.push({speaker: String(command.speaker || "").slice(0, 80), text: String(command.text || "").slice(0, 1200)});
|
||||
state.dialogueHistory = state.dialogueHistory.slice(-200);
|
||||
break;
|
||||
case "dialogue":
|
||||
state.dialogue = command.value ? clone(command.value) : null;
|
||||
break;
|
||||
case "questStart":
|
||||
if (state.quests[command.id] && state.quests[command.id].status === "locked") state.quests[command.id].status = "active";
|
||||
break;
|
||||
case "questProgress":
|
||||
progressQuest(state, command.id, command.item, command.target, command.reward);
|
||||
break;
|
||||
case "puzzle":
|
||||
state.puzzles[command.id] = true;
|
||||
state.flags[`${command.id}_solved`] = true;
|
||||
break;
|
||||
case "minigameStart":
|
||||
state.minigameCheckpoint = command.id;
|
||||
break;
|
||||
case "minigame":
|
||||
{
|
||||
const previous = state.minigames[command.id];
|
||||
const score = clamp(command.score, 0, 999);
|
||||
state.minigames[command.id] = {
|
||||
complete: true,
|
||||
score: Math.max(previous?.score || 0, score),
|
||||
perfect: Boolean(previous?.perfect || command.perfect),
|
||||
attempts: clamp((previous?.attempts || 0) + 1, 1, 99)
|
||||
};
|
||||
state.minigameCheckpoint = null;
|
||||
}
|
||||
break;
|
||||
case "gift":
|
||||
if (!state.gifts.includes(command.id)) state.gifts.push(command.id);
|
||||
break;
|
||||
case "discoverGift":
|
||||
if (!state.discoveredGifts.includes(command.id)) state.discoveredGifts.push(command.id);
|
||||
break;
|
||||
case "achievement":
|
||||
if (!state.achievements.includes(command.id)) state.achievements.push(command.id);
|
||||
break;
|
||||
case "unlockCG":
|
||||
if (!state.unlockedCGs.includes(command.id)) state.unlockedCGs.push(command.id);
|
||||
break;
|
||||
case "bias":
|
||||
state.endingBias[command.id] = clamp((state.endingBias[command.id] || 0) + Number(command.amount || 0), 0, 50);
|
||||
state.finalChoice = command.id;
|
||||
break;
|
||||
case "ending":
|
||||
state.currentEnding = command.id;
|
||||
if (!state.unlockedEndings.includes(command.id)) state.unlockedEndings.push(command.id);
|
||||
break;
|
||||
case "setting":
|
||||
if (Object.hasOwn(state.settings, command.key)) state.settings[command.key] = command.value;
|
||||
break;
|
||||
case "tick":
|
||||
state.playTimeSeconds = clamp(state.playTimeSeconds + Number(command.seconds || 0), 0, 999999);
|
||||
break;
|
||||
}
|
||||
return normalizeState(state);
|
||||
}
|
||||
|
||||
function progressQuest(state, id, item, target, reward = {}) {
|
||||
const quest = state.quests[id];
|
||||
if (!quest || quest.status === "complete" || state.inventory.includes(item)) return;
|
||||
state.inventory.push(item);
|
||||
if (quest.status === "locked") quest.status = "active";
|
||||
quest.count = Math.min(target, quest.count + 1);
|
||||
if (quest.count < target) return;
|
||||
quest.status = "complete";
|
||||
if (quest.rewarded) return;
|
||||
quest.rewarded = true;
|
||||
Object.entries(reward).forEach(([key, amount]) => {
|
||||
if (RELATIONSHIPS.includes(key)) state.relationships[key] = clamp(state.relationships[key] + Number(amount));
|
||||
});
|
||||
if (reward.cg && !state.unlockedCGs.includes(reward.cg)) state.unlockedCGs.push(reward.cg);
|
||||
const achievement = id === "garland" ? "garland_for_all" : "letters_home";
|
||||
if (!state.achievements.includes(achievement)) state.achievements.push(achievement);
|
||||
}
|
||||
|
||||
export function applyEffects(store, effects = []) {
|
||||
effects.forEach((effect) => {
|
||||
if (effect.type === "relationship") store.dispatch(effect);
|
||||
else if (effect.type === "flag") store.dispatch(effect);
|
||||
else if (effect.type === "item") store.dispatch(effect);
|
||||
else if (effect.type === "achievement") store.dispatch(effect);
|
||||
else if (effect.type === "unlockCG") store.dispatch(effect);
|
||||
else if (effect.type === "bias") store.dispatch(effect);
|
||||
else if (effect.type === "goto") store.dispatch({type: "goto", location: effect.location, position: effect.position});
|
||||
else if (effect.type === "discoverGift") store.dispatch(effect);
|
||||
else if (effect.type === "quest" && effect.action === "start") store.dispatch({type: "questStart", id: effect.id});
|
||||
});
|
||||
}
|
||||
|
||||
export function relationshipStage(value) {
|
||||
if (value >= 75) return "Deep";
|
||||
if (value >= 50) return "Warm";
|
||||
if (value >= 25) return "Growing";
|
||||
return "New";
|
||||
}
|
||||
|
||||
export function resolveEnding(state, endings) {
|
||||
const order = ["scholar", "laughter", "road", "families"];
|
||||
const scores = Object.fromEntries(order.map((id) => {
|
||||
const weighted = Object.entries(endings[id].weights).reduce((sum, [key, weight]) => sum + state.relationships[key] * weight, 0);
|
||||
return [id, weighted + Number(state.endingBias[id] || 0)];
|
||||
}));
|
||||
return order.sort((a, b) =>
|
||||
scores[b] - scores[a] ||
|
||||
Number(Boolean(state.endingBias[b])) - Number(Boolean(state.endingBias[a])) ||
|
||||
state.relationships.romance - state.relationships.romance ||
|
||||
state.relationships.trust - state.relationships.trust ||
|
||||
order.indexOf(a) - order.indexOf(b))[0];
|
||||
}
|
||||
|
||||
export function requirementMet(state, requirement) {
|
||||
const [kind, id] = requirement.split(":");
|
||||
if (kind === "flag") return Boolean(state.flags[id]);
|
||||
if (kind === "dialogue") return Boolean(state.flags[`dialogue_${id}`]);
|
||||
if (kind === "minigame") return Boolean(state.minigames[id]?.complete);
|
||||
if (kind === "item") return state.inventory.includes(id);
|
||||
if (kind === "quest") return state.quests[id]?.status === "complete";
|
||||
if (kind === "puzzle") return Boolean(state.puzzles[id]);
|
||||
return false;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.querySelector("[data-two-rivers]");
|
||||
if (!root) return;
|
||||
|
||||
var button = root.querySelector("[data-tr-age-confirm]");
|
||||
var ageScreen = root.querySelector("[data-tr-age]");
|
||||
var loading = root.querySelector("[data-tr-loading]");
|
||||
var loadingCopy = root.querySelector("[data-tr-loading-copy]");
|
||||
var AGE_KEY = "zxh_two_rivers_age_gate_v1";
|
||||
|
||||
if (!button || !ageScreen || !loading) return;
|
||||
|
||||
button.addEventListener("click", function () {
|
||||
root.dataset.ageConfirmed = "true";
|
||||
try {
|
||||
localStorage.setItem(AGE_KEY, "true");
|
||||
} catch (_error) {
|
||||
// The module bootstrap can still use the in-page marker when storage is unavailable.
|
||||
}
|
||||
|
||||
ageScreen.hidden = true;
|
||||
loading.hidden = false;
|
||||
if (loadingCopy) loadingCopy.textContent = "Opening the festival…";
|
||||
|
||||
window.setTimeout(function () {
|
||||
if (!loading.hidden && !root.querySelector("[data-tr-menu]:not([hidden])")) {
|
||||
loading.innerHTML = "<div><strong>The festival files did not finish loading.</strong><p>Refresh once to fetch the latest game files. If the problem continues, the deployment is incomplete.</p><button type=\"button\" data-tr-gate-retry>Refresh</button><a href=\"/play/play.html\">Return to the playroom</a></div>";
|
||||
var retry = loading.querySelector("[data-tr-gate-retry]");
|
||||
if (retry) retry.addEventListener("click", function () { location.reload(); });
|
||||
}
|
||||
}, 12000);
|
||||
}, {once: true});
|
||||
}());
|
||||
@@ -1,283 +0,0 @@
|
||||
import {loadContent} from "./core/content.js";
|
||||
import {createStore, freshState} from "./core/state.js";
|
||||
import {createSaveService} from "./core/save.js";
|
||||
import {AudioDirector} from "./systems/audio.js";
|
||||
import {Interface} from "./ui/interface.js";
|
||||
import {createScenes} from "./scenes/game-scenes.js";
|
||||
|
||||
const root = document.querySelector("[data-two-rivers]");
|
||||
|
||||
if (root) {
|
||||
start().catch((error) => fatal(error));
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!window.Phaser || !window.gsap || !window.Howl) throw new Error("A required local game library did not load.");
|
||||
const saves = createSaveService();
|
||||
const loadingEl = root.querySelector("[data-tr-loading]");
|
||||
const progress = root.querySelector("[data-tr-progress]");
|
||||
const loadingCopy = root.querySelector("[data-tr-loading-copy]");
|
||||
loadingEl.hidden = false;
|
||||
const content = await loadContent((value, file) => {
|
||||
progress.value = value * 0.4;
|
||||
loadingCopy.textContent = `Reading ${file.replace(".json", "").replaceAll("_", " ")}…`;
|
||||
});
|
||||
const savedSettings = saves.settings();
|
||||
const store = createStore(freshState(savedSettings));
|
||||
const audio = new AudioDirector(content.assets.audio, () => store.get().settings);
|
||||
const ui = new Interface(root, store, content, saves, audio);
|
||||
let game;
|
||||
let ready = false;
|
||||
let paused = false;
|
||||
let lastTick = performance.now();
|
||||
let recoveryShown = false;
|
||||
|
||||
const controller = {
|
||||
content, store, saves, audio, ui,
|
||||
touchDirection: 0,
|
||||
loading(value, copy) {
|
||||
progress.value = 40 + value * 0.6;
|
||||
loadingCopy.textContent = copy;
|
||||
},
|
||||
assetError(src) {
|
||||
ui.toast(`An artwork could not be loaded: ${src.split("/").pop()}`);
|
||||
},
|
||||
locationLoading(active, copy = "Painting the city…") {
|
||||
loadingEl.hidden = !active;
|
||||
if (active) {
|
||||
progress.removeAttribute("value");
|
||||
loadingCopy.textContent = copy;
|
||||
} else {
|
||||
progress.value = 100;
|
||||
}
|
||||
},
|
||||
ready() {
|
||||
ready = true;
|
||||
loadingEl.hidden = true;
|
||||
showGateOrMenu();
|
||||
},
|
||||
uiBusy() {
|
||||
return !ui.dialogueEl.hidden || !ui.overlay.hidden || paused;
|
||||
},
|
||||
locationReady(location) {
|
||||
paused = false;
|
||||
root.classList.add("tr-exploring");
|
||||
root.querySelector("[data-tr-hud]").hidden = false;
|
||||
root.querySelector("[data-tr-chapter]").textContent = location.act;
|
||||
root.querySelector("[data-tr-location]").textContent = location.name;
|
||||
root.querySelector("[data-tr-objective]").textContent = location.objective;
|
||||
audio.playLocation(location.music, location.ambience);
|
||||
controller.autosave();
|
||||
controller.interaction(null);
|
||||
root.querySelector("#two-rivers-game").focus();
|
||||
checkOrientation();
|
||||
},
|
||||
interaction(label) {
|
||||
const element = root.querySelector("[data-tr-interact]");
|
||||
element.hidden = !label;
|
||||
if (label) element.querySelector("span").textContent = label;
|
||||
},
|
||||
dialogue(id, done) { ui.startDialogue(id, done); },
|
||||
puzzle(id, done) { id === "flower_sequence" ? ui.flowerSequence(done) : ui.cipher(done); },
|
||||
gifts(done) { ui.gifts(done); },
|
||||
minigame(type, done) {
|
||||
controller.pendingMiniDone = done;
|
||||
store.dispatch({type: "minigameStart", id: type});
|
||||
controller.autosave();
|
||||
game.scene.pause("Story");
|
||||
game.scene.start(type[0].toUpperCase() + type.slice(1));
|
||||
},
|
||||
runMiniOverlay(type, sceneDone) {
|
||||
ui.minigame(type, () => {
|
||||
sceneDone();
|
||||
game.scene.resume("Story");
|
||||
controller.pendingMiniDone?.();
|
||||
controller.pendingMiniDone = null;
|
||||
});
|
||||
},
|
||||
goto(location) {
|
||||
store.dispatch({type: "goto", location, position: content.locations[location].spawn});
|
||||
controller.autosave();
|
||||
game.scene.stop("Story");
|
||||
game.scene.start("Story");
|
||||
},
|
||||
ending() {
|
||||
game.scene.stop("Story");
|
||||
game.scene.start("Ending");
|
||||
},
|
||||
runEnding() {
|
||||
ui.ending(() => {
|
||||
root.classList.remove("tr-exploring");
|
||||
root.querySelector("[data-tr-hud]").hidden = true;
|
||||
checkOrientation();
|
||||
game.scene.stop("Ending");
|
||||
showMenu();
|
||||
});
|
||||
},
|
||||
openJournal() {
|
||||
if (!ready || ui.dialogue || !ui.overlay.hidden) return;
|
||||
paused = true;
|
||||
ui.onResume = () => { paused = false; ui.onResume = null; };
|
||||
ui.journal();
|
||||
},
|
||||
pause() {
|
||||
if (!ready || ui.dialogue || !ui.overlay.hidden) return;
|
||||
paused = true;
|
||||
ui.onResume = () => { paused = false; ui.onResume = null; };
|
||||
ui.openPanel("Moonlit menu", `
|
||||
<div class="tr-actions">
|
||||
<button type="button" class="tr-primary" data-resume>Return to the story</button>
|
||||
<button type="button" data-pause-journal>Journal</button>
|
||||
<button type="button" data-pause-save>Save slots</button>
|
||||
<button type="button" data-pause-settings>Settings</button>
|
||||
<button type="button" data-pause-title>Return to title</button>
|
||||
</div>`);
|
||||
ui.panelBody.querySelector("[data-resume]").addEventListener("click", () => ui.closePanel());
|
||||
ui.panelBody.querySelector("[data-pause-journal]").addEventListener("click", () => ui.journal());
|
||||
ui.panelBody.querySelector("[data-pause-save]").addEventListener("click", () => ui.savesPanel(loadState));
|
||||
ui.panelBody.querySelector("[data-pause-settings]").addEventListener("click", () => ui.settings());
|
||||
ui.panelBody.querySelector("[data-pause-title]").addEventListener("click", () => {
|
||||
ui.overlay.hidden = true;
|
||||
game.scene.stop("Story");
|
||||
root.querySelector("[data-tr-hud]").hidden = true;
|
||||
checkOrientation();
|
||||
showMenu();
|
||||
});
|
||||
},
|
||||
toast(text) { ui.toast(text); },
|
||||
autosave() { saves.save("auto", store.get()); }
|
||||
};
|
||||
|
||||
const scenes = createScenes(controller);
|
||||
game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
parent: "two-rivers-game",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
transparent: true,
|
||||
render: {antialias: true, roundPixels: false, powerPreference: "high-performance"},
|
||||
scale: {mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH},
|
||||
audio: {noAudio: true},
|
||||
scene: scenes
|
||||
});
|
||||
|
||||
store.subscribe((state, command) => {
|
||||
root.classList.toggle("tr-high-contrast", state.settings.highContrast);
|
||||
if (command.type === "relationship") ui.toast(`${ui.observation(command.key, state.relationships[command.key])}`);
|
||||
});
|
||||
|
||||
function showGateOrMenu() {
|
||||
const age = root.querySelector("[data-tr-age]");
|
||||
if (saves.ageConfirmed() || root.dataset.ageConfirmed === "true") {
|
||||
age.hidden = true;
|
||||
showMenu();
|
||||
} else {
|
||||
age.hidden = false;
|
||||
root.querySelector("[data-tr-age-confirm]").focus();
|
||||
}
|
||||
}
|
||||
|
||||
function showMenu() {
|
||||
paused = false;
|
||||
root.classList.remove("tr-exploring");
|
||||
const menu = root.querySelector("[data-tr-menu]");
|
||||
menu.hidden = false;
|
||||
const continueButton = root.querySelector("[data-tr-continue]");
|
||||
continueButton.disabled = !saves.hasSave();
|
||||
window.gsap.fromTo(".tr-menu__copy > *", {y: 18, opacity: 0}, {
|
||||
y: 0, opacity: 1, duration: store.get().settings.reducedMotion ? 0 : 0.55, stagger: 0.08, ease: "power2.out"
|
||||
});
|
||||
const recovery = saves.recoveryInfo();
|
||||
if (recovery && !recoveryShown) {
|
||||
recoveryShown = true;
|
||||
window.setTimeout(() => ui.recovery(recovery), 120);
|
||||
}
|
||||
}
|
||||
|
||||
function hideMenu() {
|
||||
root.querySelector("[data-tr-menu]").hidden = true;
|
||||
}
|
||||
|
||||
function newGame() {
|
||||
audio.unlock();
|
||||
const settings = store.get().settings;
|
||||
store.replace(freshState(settings));
|
||||
hideMenu();
|
||||
game.scene.stop("Story");
|
||||
game.scene.start("Story");
|
||||
window.setTimeout(() => controller.dialogue("arrival", () => {
|
||||
store.dispatch({type: "flag", id: "done_lima_arrival"});
|
||||
controller.autosave();
|
||||
}), 420);
|
||||
}
|
||||
|
||||
function loadState(state) {
|
||||
audio.unlock();
|
||||
store.replace(state);
|
||||
hideMenu();
|
||||
ui.overlay.hidden = true;
|
||||
game.scene.stop("Story");
|
||||
game.scene.start("Story");
|
||||
if (state.dialogue?.id) window.setTimeout(() => controller.dialogue(state.dialogue.id, () => {}), 350);
|
||||
else if (state.minigameCheckpoint) window.setTimeout(() => controller.minigame(state.minigameCheckpoint, () => {}), 500);
|
||||
}
|
||||
|
||||
root.querySelector("[data-tr-age-confirm]").addEventListener("click", () => {
|
||||
saves.confirmAge();
|
||||
root.querySelector("[data-tr-age]").hidden = true;
|
||||
loadingEl.hidden = true;
|
||||
audio.unlock();
|
||||
showMenu();
|
||||
});
|
||||
root.querySelector("[data-tr-new]").addEventListener("click", newGame);
|
||||
root.querySelector("[data-tr-continue]").addEventListener("click", () => {
|
||||
const state = saves.load("auto");
|
||||
if (state) loadState(state);
|
||||
else ui.toast("No valid autosave was found.");
|
||||
});
|
||||
root.querySelector("[data-tr-journal]").addEventListener("click", () => controller.openJournal());
|
||||
root.querySelector("[data-tr-save]").addEventListener("click", () => ui.savesPanel(loadState));
|
||||
root.querySelector("[data-tr-pause]").addEventListener("click", () => controller.pause());
|
||||
root.querySelector("[data-tr-interact-button]").addEventListener("click", () => game.scene.getScene("Story")?.interact());
|
||||
root.querySelector("[data-tr-touch-interact]").addEventListener("click", () => game.scene.getScene("Story")?.interact());
|
||||
root.querySelector("[data-tr-slots]").addEventListener("click", () => ui.savesPanel(loadState));
|
||||
root.querySelector("[data-tr-gallery]").addEventListener("click", () => ui.gallery());
|
||||
root.querySelector("[data-tr-achievements]")?.addEventListener("click", () => ui.achievements());
|
||||
root.querySelector("[data-tr-settings]").addEventListener("click", () => ui.settings());
|
||||
root.querySelector("[data-tr-credits]").addEventListener("click", () => ui.credits());
|
||||
|
||||
const bindDirection = (selector, direction) => {
|
||||
const button = root.querySelector(selector);
|
||||
["pointerdown", "touchstart"].forEach((eventName) => button.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
controller.touchDirection = direction;
|
||||
}, {passive: false}));
|
||||
["pointerup", "pointercancel", "pointerleave", "touchend"].forEach((eventName) => button.addEventListener(eventName, () => {
|
||||
controller.touchDirection = 0;
|
||||
}));
|
||||
};
|
||||
bindDirection("[data-tr-left]", -1);
|
||||
bindDirection("[data-tr-right]", 1);
|
||||
if (matchMedia("(pointer: coarse)").matches) root.querySelector("[data-tr-touch]").hidden = false;
|
||||
|
||||
const checkOrientation = () => {
|
||||
root.querySelector("[data-tr-rotate]").hidden = !(innerWidth < 700 && innerHeight > innerWidth && !root.querySelector("[data-tr-hud]").hidden);
|
||||
};
|
||||
addEventListener("resize", checkOrientation);
|
||||
checkOrientation();
|
||||
|
||||
window.setInterval(() => {
|
||||
const now = performance.now();
|
||||
if (!document.hidden && ready && !root.querySelector("[data-tr-hud]").hidden) {
|
||||
store.dispatch({type: "tick", seconds: Math.round((now - lastTick) / 1000)});
|
||||
}
|
||||
lastTick = now;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function fatal(error) {
|
||||
console.error(error);
|
||||
const loading = root.querySelector("[data-tr-loading]");
|
||||
loading.hidden = false;
|
||||
loading.innerHTML = `<div><strong>The festival could not begin.</strong><p>${String(error.message || error)}</p><button type="button" onclick="location.reload()">Try again</button><a href="/play/play.html">Return to playroom</a></div>`;
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import {requirementMet} from "../core/state.js";
|
||||
|
||||
const WIDTH = 1280;
|
||||
const HEIGHT = 720;
|
||||
|
||||
export function createScenes(controller) {
|
||||
class BootScene extends Phaser.Scene {
|
||||
constructor() { super("Boot"); }
|
||||
create() {
|
||||
this.scene.start("Preload");
|
||||
}
|
||||
}
|
||||
|
||||
class PreloadScene extends Phaser.Scene {
|
||||
constructor() { super("Preload"); }
|
||||
preload() {
|
||||
const images = controller.content.assets.images.filter((record) =>
|
||||
record.type === "character" || record.id === "courtyard");
|
||||
images.forEach((record) => this.load.image(record.id, record.url));
|
||||
this.load.on("progress", (value) => controller.loading(Math.round(value * 100), "Painting the city…"));
|
||||
this.load.on("loaderror", (file) => controller.assetError(file.src));
|
||||
}
|
||||
create() {
|
||||
controller.ready();
|
||||
this.scene.sleep();
|
||||
}
|
||||
}
|
||||
|
||||
class StoryScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super("Story");
|
||||
this.locked = false;
|
||||
this.near = null;
|
||||
this.move = 0;
|
||||
this.velocity = 0;
|
||||
}
|
||||
create() {
|
||||
this.location = controller.content.locations[controller.store.get().location];
|
||||
const bundle = [this.location.background, ...(this.location.actors || []).map((actor) => actor.id)];
|
||||
const missing = bundle.filter((id) => !this.textures.exists(id));
|
||||
if (missing.length) {
|
||||
controller.locationLoading(true, `Painting ${this.location.name}…`);
|
||||
missing.forEach((id) => {
|
||||
const record = controller.content.assets.images.find((asset) => asset.id === id);
|
||||
if (record) this.load.image(record.id, record.url);
|
||||
});
|
||||
this.load.once("complete", () => {
|
||||
controller.locationLoading(false);
|
||||
this.createWorld();
|
||||
});
|
||||
this.load.once("loaderror", (file) => controller.assetError(file.src));
|
||||
this.load.start();
|
||||
return;
|
||||
}
|
||||
this.createWorld();
|
||||
}
|
||||
createWorld() {
|
||||
this.background = this.add.image(WIDTH / 2, HEIGHT / 2, this.location.background).setDisplaySize(WIDTH, HEIGHT);
|
||||
this.background.setTint(this.location.background === "pavilion" ? 0xc9d5ff : 0xffffff);
|
||||
const nightAlpha = this.location.act.startsWith("Act III") ? 0.2 : this.location.name === "Moon Garden" ? 0.1 : 0;
|
||||
if (nightAlpha) this.add.rectangle(WIDTH / 2, HEIGHT / 2, WIDTH, HEIGHT, 0x18315f, nightAlpha).setDepth(2);
|
||||
this.player = this.add.image(WIDTH * controller.store.get().position, 577, "z").setOrigin(0.5, 1).setDisplaySize(142, 300).setDepth(5);
|
||||
this.companion = this.add.image(Math.max(160, this.player.x - 128), 577, "lima").setOrigin(0.5, 1).setDisplaySize(146, 305).setDepth(4);
|
||||
this.location.actors?.forEach((actor, index) => {
|
||||
this.add.image(WIDTH * actor.x, 577, actor.id)
|
||||
.setOrigin(0.5, 1)
|
||||
.setDisplaySize(118, 270)
|
||||
.setDepth(3 + (index % 2) * 0.1);
|
||||
});
|
||||
this.hotspots = this.location.hotspots.map((definition) => ({
|
||||
definition,
|
||||
x: WIDTH * definition.x
|
||||
}));
|
||||
this.cursors = this.input.keyboard.createCursorKeys();
|
||||
this.keys = this.input.keyboard.addKeys("W,A,S,D,E,ENTER,J,ESC");
|
||||
this.input.on("pointerdown", (pointer) => {
|
||||
if (this.locked || controller.uiBusy()) return;
|
||||
const target = Phaser.Math.Clamp(pointer.x, 56, WIDTH - 56);
|
||||
if (Math.abs(target - this.player.x) < 48) this.interact();
|
||||
else this.walkTarget = target;
|
||||
});
|
||||
this.input.keyboard.on("keydown-E", () => this.interact());
|
||||
this.input.keyboard.on("keydown-ENTER", () => this.interact());
|
||||
this.input.keyboard.on("keydown-J", () => controller.openJournal());
|
||||
this.input.keyboard.on("keydown-ESC", () => controller.pause());
|
||||
controller.locationReady(this.location);
|
||||
this.createForeground();
|
||||
this.createAtmosphere();
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
controller.store.dispatch({type: "position", value: this.player.x / WIDTH});
|
||||
});
|
||||
}
|
||||
createAtmosphere() {
|
||||
if (controller.store.get().settings.effectsQuality === "minimal" || controller.store.get().settings.reducedMotion) return;
|
||||
const texture = this.make.graphics({x: 0, y: 0, add: false});
|
||||
texture.fillStyle(0xffe8a3, 1);
|
||||
texture.fillCircle(5, 5, 4);
|
||||
texture.generateTexture("tr-firefly", 10, 10);
|
||||
texture.destroy();
|
||||
const particles = this.add.particles(0, 0, "tr-firefly", {
|
||||
x: {min: 0, max: WIDTH}, y: {min: 120, max: 580},
|
||||
lifespan: {min: 2800, max: 5200}, speedX: {min: -6, max: 6}, speedY: {min: -7, max: 2},
|
||||
alpha: {start: 0, ease: "Sine.easeInOut", yoyo: true, end: 0.8},
|
||||
scale: {start: 0.25, end: 0.7}, frequency: controller.store.get().settings.effectsQuality === "full" ? 420 : 850,
|
||||
blendMode: "ADD"
|
||||
}).setDepth(3);
|
||||
if (!particles) return;
|
||||
}
|
||||
createForeground() {
|
||||
const foreground = this.add.graphics().setDepth(8);
|
||||
foreground.fillStyle(this.location.name === "Royal Library" ? 0x16110d : 0x071c17, 0.72);
|
||||
foreground.fillEllipse(30, 710, 250, 170);
|
||||
foreground.fillEllipse(1245, 710, 290, 190);
|
||||
foreground.fillStyle(0x102d25, 0.58);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
foreground.fillEllipse(index < 4 ? 42 + index * 25 : 1160 + index * 18, 620 + (index % 3) * 34, 44, 115);
|
||||
}
|
||||
}
|
||||
update(_time, delta) {
|
||||
if (!this.player || !this.cursors || this.locked || controller.uiBusy()) return;
|
||||
const left = this.cursors.left.isDown || this.keys.A.isDown;
|
||||
const right = this.cursors.right.isDown || this.keys.D.isDown;
|
||||
if (left || right) {
|
||||
this.walkTarget = null;
|
||||
this.move = left ? -1 : 1;
|
||||
} else if (this.walkTarget != null) {
|
||||
const distance = this.walkTarget - this.player.x;
|
||||
this.move = Math.abs(distance) < 7 ? 0 : Math.sign(distance);
|
||||
if (!this.move) this.walkTarget = null;
|
||||
} else this.move = controller.touchDirection;
|
||||
const targetVelocity = this.move * 0.31;
|
||||
this.velocity = Phaser.Math.Linear(this.velocity, targetVelocity, Math.min(1, delta / (this.move ? 130 : 90)));
|
||||
if (Math.abs(this.velocity) < 0.008 && !this.move) this.velocity = 0;
|
||||
this.player.x = Phaser.Math.Clamp(this.player.x + this.velocity * delta, 52, WIDTH - 52);
|
||||
if (this.velocity) this.player.scaleX = Math.abs(this.player.scaleX) * (this.velocity < 0 ? -1 : 1);
|
||||
const companionTarget = this.player.x - 105 * (this.velocity < 0 ? -1 : 1);
|
||||
this.companion.x = Phaser.Math.Linear(this.companion.x, companionTarget, Math.min(1, delta / 260));
|
||||
this.companion.scaleX = Math.abs(this.companion.scaleX) * (this.player.x < this.companion.x ? -1 : 1);
|
||||
const bob = this.velocity ? Math.sin(performance.now() / 115) * 3 : Math.sin(performance.now() / 600) * 1.1;
|
||||
this.player.y = 577 + bob;
|
||||
this.companion.y = 577 - bob * 0.55;
|
||||
if (this.velocity && _time > (this.nextFootstep || 0)) {
|
||||
controller.audio.sfx("footstep");
|
||||
this.nextFootstep = _time + 390;
|
||||
}
|
||||
if (!controller.store.get().settings.reducedMotion) this.background.x = WIDTH / 2 - (this.player.x - WIDTH / 2) * 0.025;
|
||||
this.updateNearest();
|
||||
}
|
||||
updateNearest() {
|
||||
const state = controller.store.get();
|
||||
const candidates = this.hotspots.filter(({definition, x}) =>
|
||||
(!definition.once || !state.flags[`done_${definition.id}`]) &&
|
||||
(definition.requires || []).every((requirement) => requirementMet(state, requirement)) &&
|
||||
Math.abs(x - this.player.x) < 92);
|
||||
const priority = (definition) => {
|
||||
if (Number.isFinite(definition.priority)) return definition.priority;
|
||||
if (definition.dialogue || definition.puzzle || definition.minigame) return 0;
|
||||
if (definition.gift) return 1;
|
||||
if (definition.collect) return 2;
|
||||
return 3;
|
||||
};
|
||||
candidates.sort((a, b) => priority(a.definition) - priority(b.definition) || Math.abs(a.x - this.player.x) - Math.abs(b.x - this.player.x));
|
||||
const near = candidates[0] || null;
|
||||
if (near?.definition.id !== this.near?.definition.id) {
|
||||
this.near = near;
|
||||
controller.interaction(near?.definition.label || null);
|
||||
}
|
||||
}
|
||||
interact() {
|
||||
if (!this.near || this.locked || controller.uiBusy()) return;
|
||||
const definition = this.near.definition;
|
||||
this.locked = true;
|
||||
controller.interaction(null);
|
||||
const release = () => {
|
||||
if (definition.once) controller.store.dispatch({type: "flag", id: `done_${definition.id}`});
|
||||
this.locked = false;
|
||||
this.updateNearest();
|
||||
};
|
||||
if (definition.dialogue) controller.dialogue(definition.dialogue, (ending) => {
|
||||
if (ending) {
|
||||
controller.ending();
|
||||
return;
|
||||
}
|
||||
release();
|
||||
});
|
||||
else if (definition.goto) controller.goto(definition.goto);
|
||||
else if (definition.puzzle) controller.puzzle(definition.puzzle, release);
|
||||
else if (definition.minigame) controller.minigame(definition.minigame, release);
|
||||
else if (definition.gift) controller.gifts(release);
|
||||
else if (definition.collect) {
|
||||
const quest = controller.content.quests[definition.quest];
|
||||
const before = controller.store.get().quests[definition.quest].status;
|
||||
controller.store.dispatch({
|
||||
type: "questProgress", id: definition.quest, item: definition.collect,
|
||||
target: quest.target, reward: quest.reward
|
||||
});
|
||||
controller.audio.sfx("pickup");
|
||||
const after = controller.store.get().quests[definition.quest].status;
|
||||
controller.toast(after === "complete" && before !== "complete" ? `${quest.name} complete.` : `${definition.label.replace("Gather ", "").replace("Recover ", "")} added to the journal.`);
|
||||
controller.autosave();
|
||||
release();
|
||||
} else release();
|
||||
}
|
||||
}
|
||||
|
||||
class ArcheryScene extends Phaser.Scene {
|
||||
constructor() { super("Archery"); }
|
||||
create() { controller.runMiniOverlay("archery", () => this.scene.stop()); }
|
||||
}
|
||||
class CookingScene extends Phaser.Scene {
|
||||
constructor() { super("Cooking"); }
|
||||
create() { controller.runMiniOverlay("cooking", () => this.scene.stop()); }
|
||||
}
|
||||
class DanceScene extends Phaser.Scene {
|
||||
constructor() { super("Dance"); }
|
||||
create() { controller.runMiniOverlay("dance", () => this.scene.stop()); }
|
||||
}
|
||||
class EndingScene extends Phaser.Scene {
|
||||
constructor() { super("Ending"); }
|
||||
create() { controller.runEnding(); }
|
||||
}
|
||||
|
||||
return [BootScene, PreloadScene, StoryScene, ArcheryScene, CookingScene, DanceScene, EndingScene];
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// Native ES module served with the production JavaScript MIME type.
|
||||
export class AudioDirector {
|
||||
constructor(records, getSettings) {
|
||||
this.getSettings = getSettings;
|
||||
this.records = records;
|
||||
this.sounds = new Map();
|
||||
this.music = null;
|
||||
this.ambience = null;
|
||||
this.unlocked = false;
|
||||
records.forEach((record) => {
|
||||
this.sounds.set(record.id, new window.Howl({
|
||||
src: record.urls || [record.url],
|
||||
loop: record.type !== "sfx",
|
||||
preload: record.type === "sfx",
|
||||
volume: 0
|
||||
}));
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) window.Howler?.mute(true);
|
||||
else if (this.getSettings().sound) window.Howler?.mute(false);
|
||||
});
|
||||
}
|
||||
|
||||
unlock() {
|
||||
this.unlocked = true;
|
||||
window.Howler?.ctx?.resume?.();
|
||||
this.sync();
|
||||
}
|
||||
|
||||
sync() {
|
||||
const settings = this.getSettings();
|
||||
window.Howler.volume(settings.masterVolume);
|
||||
window.Howler.mute(!settings.sound);
|
||||
if (this.music) this.sounds.get(this.music)?.volume(settings.musicVolume);
|
||||
if (this.ambience) this.sounds.get(this.ambience)?.volume(settings.ambienceVolume * 0.35);
|
||||
}
|
||||
|
||||
playLocation(music, ambience) {
|
||||
if (!this.unlocked) return;
|
||||
this.crossfade("music", music);
|
||||
this.crossfade("ambience", ambience);
|
||||
}
|
||||
|
||||
crossfade(channel, nextId) {
|
||||
const currentId = this[channel];
|
||||
if (currentId === nextId) return;
|
||||
const settings = this.getSettings();
|
||||
const target = channel === "music" ? settings.musicVolume : settings.ambienceVolume * 0.35;
|
||||
if (currentId && this.sounds.has(currentId)) {
|
||||
const old = this.sounds.get(currentId);
|
||||
old.fade(old.volume(), 0, 500);
|
||||
window.setTimeout(() => old.stop(), 520);
|
||||
}
|
||||
this[channel] = nextId;
|
||||
const next = this.sounds.get(nextId);
|
||||
if (next) {
|
||||
next.volume(0);
|
||||
if (!next.playing()) next.play();
|
||||
next.fade(0, target, 700);
|
||||
}
|
||||
}
|
||||
|
||||
sfx(id) {
|
||||
if (!this.unlocked || !this.getSettings().sound) return;
|
||||
const sound = this.sounds.get(id);
|
||||
if (sound) {
|
||||
sound.volume(this.getSettings().sfxVolume);
|
||||
sound.play();
|
||||
}
|
||||
}
|
||||
|
||||
duck(active) {
|
||||
if (!this.music) return;
|
||||
const sound = this.sounds.get(this.music);
|
||||
const volume = this.getSettings().musicVolume * (active ? 0.45 : 1);
|
||||
sound?.fade(sound.volume(), volume, 250);
|
||||
}
|
||||
}
|
||||
@@ -1,659 +0,0 @@
|
||||
import {applyEffects, relationshipStage, RELATIONSHIPS, requirementMet, resolveEnding} from "../core/state.js";
|
||||
|
||||
const LABELS = {
|
||||
trust: "Trust", respect: "Respect", comfort: "Comfort", humour: "Humour",
|
||||
adventure: "Adventure", romance: "Romance", familyApproval: "Family approval"
|
||||
};
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (match) =>
|
||||
({"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"}[match]));
|
||||
|
||||
export class Interface {
|
||||
constructor(root, store, content, saves, audio) {
|
||||
this.root = root;
|
||||
this.store = store;
|
||||
this.content = content;
|
||||
this.saves = saves;
|
||||
this.audio = audio;
|
||||
this.overlay = root.querySelector("[data-tr-overlay]");
|
||||
this.panel = root.querySelector("[data-tr-panel]");
|
||||
this.panelTitle = root.querySelector("[data-tr-panel-title]");
|
||||
this.panelKicker = root.querySelector("[data-tr-panel-kicker]");
|
||||
this.panelBody = root.querySelector("[data-tr-panel-body]");
|
||||
this.dialogueEl = root.querySelector("[data-tr-dialogue]");
|
||||
this.dialogue = null;
|
||||
this.typing = null;
|
||||
this.fullText = "";
|
||||
this.displayed = "";
|
||||
this.auto = false;
|
||||
this.autoTimer = null;
|
||||
this.previousFocus = null;
|
||||
this.onDialogueDone = null;
|
||||
this.onResume = null;
|
||||
root.querySelector("[data-tr-close]").addEventListener("click", () => this.closePanel());
|
||||
root.querySelector("[data-tr-advance]").addEventListener("click", () => this.advance());
|
||||
root.querySelector("[data-tr-auto]").addEventListener("click", (event) => this.toggleAuto(event.currentTarget));
|
||||
root.querySelector("[data-tr-history]").addEventListener("click", () => this.showHistory());
|
||||
root.querySelector("[data-tr-skip]").addEventListener("click", () => this.skipRead());
|
||||
root.addEventListener("keydown", (event) => this.trapFocus(event));
|
||||
}
|
||||
|
||||
announce(text) {
|
||||
this.root.querySelector("[data-tr-live]").textContent = text;
|
||||
}
|
||||
|
||||
toast(text) {
|
||||
const toast = document.createElement("p");
|
||||
toast.textContent = text;
|
||||
this.root.querySelector("[data-tr-toasts]").appendChild(toast);
|
||||
window.gsap.fromTo(toast, {y: 18, opacity: 0}, {y: 0, opacity: 1, duration: this.store.get().settings.reducedMotion ? 0 : 0.35});
|
||||
window.setTimeout(() => window.gsap.to(toast, {opacity: 0, duration: 0.25, onComplete: () => toast.remove()}), 2600);
|
||||
}
|
||||
|
||||
openPanel(title, html, kicker = "Two Rivers at Moonrise", closable = true) {
|
||||
this.previousFocus = document.activeElement;
|
||||
this.panel.onkeydown = null;
|
||||
this.panelTitle.textContent = title;
|
||||
this.panelKicker.textContent = kicker;
|
||||
this.panelBody.innerHTML = html;
|
||||
this.root.querySelector("[data-tr-close]").hidden = !closable;
|
||||
this.overlay.hidden = false;
|
||||
window.gsap.fromTo(this.panel, {scale: 0.97, opacity: 0}, {scale: 1, opacity: 1, duration: this.store.get().settings.reducedMotion ? 0 : 0.25});
|
||||
requestAnimationFrame(() => this.panelBody.querySelector("button, input, select, a")?.focus());
|
||||
}
|
||||
|
||||
trapFocus(event) {
|
||||
if (event.key !== "Tab") return;
|
||||
const region = !this.overlay.hidden ? this.panel : !this.dialogueEl.hidden ? this.dialogueEl : null;
|
||||
if (!region) return;
|
||||
const focusable = [...region.querySelectorAll("button:not([disabled]):not([hidden]), input:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex='-1'])")]
|
||||
.filter((element) => element.getClientRects().length);
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
closePanel() {
|
||||
if (this.root.querySelector("[data-tr-close]").hidden) return;
|
||||
this.overlay.hidden = true;
|
||||
this.panelBody.innerHTML = "";
|
||||
this.previousFocus?.focus?.();
|
||||
this.onResume?.();
|
||||
}
|
||||
|
||||
journal() {
|
||||
const state = this.store.get();
|
||||
const relationships = RELATIONSHIPS.map((key) =>
|
||||
`<li><span>${LABELS[key]}</span><strong>${relationshipStage(state.relationships[key])}</strong><small>${this.observation(key, state.relationships[key])}</small></li>`).join("");
|
||||
const quests = Object.entries(this.content.quests).map(([id, quest]) => {
|
||||
const progress = state.quests[id];
|
||||
return `<li><span>${escapeHtml(quest.name)}</span><strong>${progress.status === "complete" ? "Complete" : `${progress.count}/${quest.target}`}</strong><small>${escapeHtml(quest.description)}</small></li>`;
|
||||
}).join("");
|
||||
const clues = state.inventory.map((id) => `<span class="tr-chip">${escapeHtml(id.replaceAll("_", " "))}</span>`).join("");
|
||||
const gifts = this.content.items.gifts.filter((gift) => state.discoveredGifts.includes(gift.id))
|
||||
.map((gift) => `<li><span>${escapeHtml(gift.name)}</span><small>${escapeHtml(gift.hint)}</small></li>`).join("");
|
||||
this.openPanel("Z's Journal", `
|
||||
<div class="tr-tabs">
|
||||
<section><h3>What is growing</h3><ul class="tr-ledger">${relationships}</ul></section>
|
||||
<section><h3>Festival errands</h3><ul class="tr-ledger">${quests}</ul></section>
|
||||
<section><h3>Clues and keepsakes</h3><div class="tr-chips">${clues || "<p>The pockets are, for once, empty.</p>"}</div></section>
|
||||
<section><h3>Gift ideas noticed</h3><ul class="tr-ledger">${gifts || "<li><small>Listen closely; Lima reveals what she values.</small></li>"}</ul></section>
|
||||
</div>`, "Private observations · numbers are deliberately omitted");
|
||||
}
|
||||
|
||||
observation(key, value) {
|
||||
const stage = relationshipStage(value);
|
||||
const copy = {
|
||||
trust: {New: "Careful truths.", Growing: "Candour is becoming easy.", Warm: "She trusts your judgement.", Deep: "Nothing important is hidden."},
|
||||
respect: {New: "Testing each other's measure.", Growing: "Ideas receive real attention.", Warm: "Disagreement feels generous.", Deep: "A partnership of equals."},
|
||||
comfort: {New: "Formal edges remain.", Growing: "Silences feel less guarded.", Warm: "Nearness feels natural.", Deep: "Home has become a person."},
|
||||
humour: {New: "A smile, quickly hidden.", Growing: "Private jokes are forming.", Warm: "Dignity is frequently endangered.", Deep: "Laughter needs no explanation."},
|
||||
adventure: {New: "A cautious first step.", Growing: "Curiosity is shared.", Warm: "The road calls to both.", Deep: "Every horizon looks possible."},
|
||||
romance: {New: "An inconvenient spark.", Growing: "Glances linger.", Warm: "Neither is pretending now.", Deep: "Moonrise has chosen sides."},
|
||||
familyApproval: {New: "The family is observing.", Growing: "Warmth replaces ceremony.", Warm: "A place is being made.", Deep: "Two tables are becoming one."}
|
||||
};
|
||||
return copy[key][stage];
|
||||
}
|
||||
|
||||
settings() {
|
||||
const settings = this.store.get().settings;
|
||||
const checked = (key) => settings[key] ? "checked" : "";
|
||||
this.openPanel("Settings", `
|
||||
<form class="tr-settings" data-settings-form>
|
||||
<label><span>Sound</span><input type="checkbox" name="sound" ${checked("sound")}></label>
|
||||
<label><span>Master volume</span><input type="range" name="masterVolume" min="0" max="1" step=".05" value="${settings.masterVolume}"></label>
|
||||
<label><span>Music</span><input type="range" name="musicVolume" min="0" max="1" step=".05" value="${settings.musicVolume}"></label>
|
||||
<label><span>Ambience</span><input type="range" name="ambienceVolume" min="0" max="1" step=".05" value="${settings.ambienceVolume}"></label>
|
||||
<label><span>Effects</span><input type="range" name="sfxVolume" min="0" max="1" step=".05" value="${settings.sfxVolume}"></label>
|
||||
<label><span>Text speed</span><select name="textSpeed">${["slow", "normal", "fast", "instant"].map((value) => `<option ${settings.textSpeed === value ? "selected" : ""}>${value}</option>`).join("")}</select></label>
|
||||
<label><span>Reduced motion</span><input type="checkbox" name="reducedMotion" ${checked("reducedMotion")}></label>
|
||||
<label><span>High contrast</span><input type="checkbox" name="highContrast" ${checked("highContrast")}></label>
|
||||
<label><span>Assisted minigames</span><input type="checkbox" name="assistedMinigames" ${checked("assistedMinigames")}></label>
|
||||
<label><span>Effects quality</span><select name="effectsQuality">${["full", "reduced", "minimal"].map((value) => `<option ${settings.effectsQuality === value ? "selected" : ""}>${value}</option>`).join("")}</select></label>
|
||||
</form>`);
|
||||
this.panelBody.querySelectorAll("input, select").forEach((control) => control.addEventListener("change", () => {
|
||||
const value = control.type === "checkbox" ? control.checked : control.type === "range" ? Number(control.value) : control.value;
|
||||
this.store.dispatch({type: "setting", key: control.name, value});
|
||||
this.saves.settings(this.store.get().settings);
|
||||
this.root.classList.toggle("tr-high-contrast", this.store.get().settings.highContrast);
|
||||
this.audio.sync();
|
||||
}));
|
||||
}
|
||||
|
||||
credits() {
|
||||
this.openPanel("Credits", `
|
||||
<p class="tr-panel-lede">An original, locally hosted romantic adventure created for zainezq.com.</p>
|
||||
<ul class="tr-ledger">
|
||||
<li><span>Story and direction</span><strong>Two Rivers at Moonrise</strong></li>
|
||||
<li><span>Engine</span><strong>Phaser 3.90.0</strong></li>
|
||||
<li><span>Interface</span><strong>Tailwind CSS 4</strong></li>
|
||||
<li><span>Motion</span><strong>GSAP 3.13.0</strong></li>
|
||||
<li><span>Audio</span><strong>Howler.js 2.2.4</strong></li>
|
||||
<li><span>Artwork</span><strong>Original AI-assisted painterly assets</strong></li>
|
||||
</ul>
|
||||
<p>Bengali and Pakistani influences are treated as distinct sources of clothing, food, architecture, and visual language inside a wholly fictional fantasy setting.</p>`);
|
||||
}
|
||||
|
||||
gallery() {
|
||||
const state = this.store.get();
|
||||
const cards = ["garland", "library", "scholar", "laughter", "road", "families"].map((id) => {
|
||||
const unlocked = state.unlockedCGs.includes(id) || state.unlockedEndings.includes(id);
|
||||
return `<figure class="${unlocked ? "" : "is-locked"}">${unlocked ? `<img src="/assets/games/two-rivers/images/cg/${id}.webp" alt="${escapeHtml(id)}">` : "<div>?</div>"}<figcaption>${escapeHtml(id.replaceAll("_", " "))}</figcaption></figure>`;
|
||||
}).join("");
|
||||
this.openPanel("Moonlit Gallery", `<div class="tr-gallery">${cards}</div>`, `${state.unlockedCGs.length + state.unlockedEndings.length} memories found`);
|
||||
}
|
||||
|
||||
achievements() {
|
||||
const state = this.store.get();
|
||||
const rows = Object.entries(this.content.achievements).map(([id, description]) =>
|
||||
`<li><span>${state.achievements.includes(id) ? "✦" : "◇"} ${escapeHtml(id.replaceAll("_", " "))}</span><small>${state.achievements.includes(id) ? escapeHtml(description) : "Not yet discovered"}</small></li>`).join("");
|
||||
this.openPanel("Achievements", `<ul class="tr-ledger">${rows}</ul>`, `${state.achievements.length}/${Object.keys(this.content.achievements).length} earned`);
|
||||
}
|
||||
|
||||
recovery(info) {
|
||||
this.openPanel("Save recovery", `
|
||||
<p class="tr-panel-lede">${escapeHtml(info.message)}</p>
|
||||
<p>Your current game can still begin safely. A recovery copy was used when available; existing Princess Lima saves were not touched.</p>
|
||||
<button type="button" class="tr-primary" data-recovery-close>Continue</button>`, "A save needed attention", false);
|
||||
this.panelBody.querySelector("[data-recovery-close]").addEventListener("click", () => {
|
||||
this.overlay.hidden = true;
|
||||
this.panelBody.innerHTML = "";
|
||||
});
|
||||
}
|
||||
|
||||
savesPanel(onLoad) {
|
||||
const slots = this.saves.list();
|
||||
const state = this.store.get();
|
||||
this.openPanel("Save slots", `<div class="tr-save-slots">${["slot1", "slot2", "slot3"].map((slot, index) => {
|
||||
const record = slots[slot];
|
||||
return `<article><div><span>Slot ${index + 1}</span><strong>${record ? escapeHtml(this.content.locations[record.location]?.name || record.location) : "Empty"}</strong><small>${record ? new Date(record.timestamp).toLocaleString() : "A fresh page"}</small></div>
|
||||
<button type="button" data-save-slot="${slot}">Save</button>
|
||||
<button type="button" data-load-slot="${slot}" ${record ? "" : "disabled"}>Load</button></article>`;
|
||||
}).join("")}</div>`);
|
||||
this.panelBody.querySelectorAll("[data-save-slot]").forEach((button) => button.addEventListener("click", () => {
|
||||
this.saves.save(button.dataset.saveSlot, state);
|
||||
this.toast("Story saved.");
|
||||
this.savesPanel(onLoad);
|
||||
}));
|
||||
this.panelBody.querySelectorAll("[data-load-slot]").forEach((button) => button.addEventListener("click", () => {
|
||||
const loaded = this.saves.load(button.dataset.loadSlot);
|
||||
if (loaded) {
|
||||
this.overlay.hidden = true;
|
||||
onLoad(loaded);
|
||||
} else this.toast("That save could not be recovered.");
|
||||
}));
|
||||
}
|
||||
|
||||
startDialogue(id, done) {
|
||||
const definition = this.content.dialogue[id];
|
||||
if (!definition) return done?.();
|
||||
this.dialogue = {id, node: this.store.get().dialogue?.id === id ? this.store.get().dialogue.node : definition.start};
|
||||
this.previousFocus = document.activeElement;
|
||||
this.onDialogueDone = done;
|
||||
this.dialogueEl.hidden = false;
|
||||
this.audio.duck(true);
|
||||
this.renderDialogue();
|
||||
}
|
||||
|
||||
renderDialogue() {
|
||||
window.clearTimeout(this.autoTimer);
|
||||
const definition = this.content.dialogue[this.dialogue.id];
|
||||
let node = definition.nodes[this.dialogue.node];
|
||||
while (node?.conditions && !node.conditions.every((condition) => requirementMet(this.store.get(), condition))) {
|
||||
this.dialogue.node = node.else || node.next;
|
||||
node = definition.nodes[this.dialogue.node];
|
||||
}
|
||||
if (!node) return this.finishDialogue();
|
||||
this.audio.sfx("page-turn");
|
||||
const character = this.content.characters[node.speaker] || {name: node.speaker};
|
||||
const nodeKey = `${this.dialogue.id}:${this.dialogue.node}`;
|
||||
this.store.dispatch({type: "read", id: nodeKey});
|
||||
this.store.dispatch({type: "dialogue", value: this.dialogue});
|
||||
this.store.dispatch({type: "history", speaker: character.name, text: node.text});
|
||||
this.root.querySelector("[data-tr-speaker]").textContent = character.name;
|
||||
const portrait = this.root.querySelector("[data-tr-portrait]");
|
||||
const portraitId = this.content.assets.images.some((asset) => ["character", "npc"].includes(asset.type) && asset.id === node.speaker) ? node.speaker : (node.speaker === "z" ? "z" : "lima");
|
||||
portrait.style.backgroundImage = `url('/assets/games/two-rivers/images/characters/${portraitId}.webp')`;
|
||||
portrait.setAttribute("aria-label", `${character.name}, ${node.expression || "speaking"}`);
|
||||
const choices = this.root.querySelector("[data-tr-choices]");
|
||||
choices.innerHTML = "";
|
||||
this.typeLine(node.text);
|
||||
const availableChoices = node.choices?.filter((choice) =>
|
||||
!choice.conditions || choice.conditions.every((condition) => requirementMet(this.store.get(), condition)));
|
||||
this.activeChoices = availableChoices || null;
|
||||
this.root.querySelector("[data-tr-advance]").hidden = Boolean(availableChoices?.length);
|
||||
if (availableChoices?.length) {
|
||||
availableChoices.forEach((choice, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.innerHTML = `<span>${index + 1}</span>${escapeHtml(choice.text)}`;
|
||||
button.addEventListener("click", () => this.choose(index));
|
||||
choices.appendChild(button);
|
||||
});
|
||||
}
|
||||
this.announce(`${character.name}: ${node.text}`);
|
||||
window.gsap.fromTo(this.dialogueEl, {y: 20, opacity: 0}, {y: 0, opacity: 1, duration: this.store.get().settings.reducedMotion ? 0 : 0.28});
|
||||
requestAnimationFrame(() => (availableChoices?.length ? choices.querySelector("button") : this.root.querySelector("[data-tr-advance]"))?.focus());
|
||||
}
|
||||
|
||||
typeLine(text) {
|
||||
window.clearInterval(this.typing);
|
||||
this.fullText = text;
|
||||
this.displayed = "";
|
||||
const target = this.root.querySelector("[data-tr-line]");
|
||||
const speed = {slow: 38, normal: 24, fast: 10, instant: 0}[this.store.get().settings.textSpeed];
|
||||
if (!speed || this.store.get().settings.reducedMotion) {
|
||||
target.textContent = text;
|
||||
this.displayed = text;
|
||||
return;
|
||||
}
|
||||
let index = 0;
|
||||
target.textContent = "";
|
||||
this.typing = window.setInterval(() => {
|
||||
index += 1;
|
||||
this.displayed = text.slice(0, index);
|
||||
target.textContent = this.displayed;
|
||||
if (index >= text.length) {
|
||||
window.clearInterval(this.typing);
|
||||
this.queueAuto();
|
||||
}
|
||||
}, speed);
|
||||
}
|
||||
|
||||
completeLine() {
|
||||
if (this.displayed === this.fullText) return false;
|
||||
window.clearInterval(this.typing);
|
||||
this.displayed = this.fullText;
|
||||
this.root.querySelector("[data-tr-line]").textContent = this.fullText;
|
||||
this.queueAuto();
|
||||
return true;
|
||||
}
|
||||
|
||||
advance() {
|
||||
if (!this.dialogue || this.completeLine()) return;
|
||||
const definition = this.content.dialogue[this.dialogue.id];
|
||||
const node = definition.nodes[this.dialogue.node];
|
||||
if (this.activeChoices?.length) return;
|
||||
applyEffects(this.store, node.effects);
|
||||
if (node.effects?.some((effect) => effect.type === "ending")) {
|
||||
this.finishDialogue(true);
|
||||
return;
|
||||
}
|
||||
if (node.next) {
|
||||
this.dialogue.node = node.next;
|
||||
this.renderDialogue();
|
||||
} else this.finishDialogue();
|
||||
}
|
||||
|
||||
choose(index) {
|
||||
const definition = this.content.dialogue[this.dialogue.id];
|
||||
const node = definition.nodes[this.dialogue.node];
|
||||
const choice = this.activeChoices?.[index];
|
||||
if (!choice) return;
|
||||
const choiceId = `${this.dialogue.id}:${this.dialogue.node}:${choice.id || choice.next || index}`;
|
||||
if (!this.store.get().choices.includes(choiceId)) {
|
||||
applyEffects(this.store, choice.effects);
|
||||
this.store.dispatch({type: "choice", id: choiceId});
|
||||
}
|
||||
this.audio.sfx("ui");
|
||||
this.dialogue.node = choice.next;
|
||||
this.saves.save("auto", this.store.get());
|
||||
this.renderDialogue();
|
||||
}
|
||||
|
||||
finishDialogue(ending = false) {
|
||||
window.clearInterval(this.typing);
|
||||
window.clearTimeout(this.autoTimer);
|
||||
const id = this.dialogue?.id;
|
||||
this.dialogue = null;
|
||||
this.dialogueEl.hidden = true;
|
||||
this.store.dispatch({type: "dialogue", value: null});
|
||||
if (id) this.store.dispatch({type: "flag", id: `dialogue_${id}`});
|
||||
this.audio.duck(false);
|
||||
this.previousFocus?.focus?.();
|
||||
this.saves.save("auto", this.store.get());
|
||||
const done = this.onDialogueDone;
|
||||
this.onDialogueDone = null;
|
||||
done?.(ending);
|
||||
}
|
||||
|
||||
toggleAuto(button) {
|
||||
this.auto = !this.auto;
|
||||
button.setAttribute("aria-pressed", String(this.auto));
|
||||
button.classList.toggle("is-active", this.auto);
|
||||
if (this.auto) this.queueAuto();
|
||||
else window.clearTimeout(this.autoTimer);
|
||||
}
|
||||
|
||||
queueAuto() {
|
||||
if (!this.auto || !this.dialogue) return;
|
||||
const node = this.content.dialogue[this.dialogue.id].nodes[this.dialogue.node];
|
||||
if (this.activeChoices?.length) return;
|
||||
window.clearTimeout(this.autoTimer);
|
||||
this.autoTimer = window.setTimeout(() => this.advance(), this.store.get().settings.autoDelay);
|
||||
}
|
||||
|
||||
skipRead() {
|
||||
if (!this.dialogue) return;
|
||||
const key = `${this.dialogue.id}:${this.dialogue.node}`;
|
||||
const node = this.content.dialogue[this.dialogue.id].nodes[this.dialogue.node];
|
||||
if (this.store.get().readNodes.includes(key) && !node.choices) {
|
||||
this.completeLine();
|
||||
this.advance();
|
||||
} else this.toast("Only previously read lines can be skipped.");
|
||||
}
|
||||
|
||||
showHistory() {
|
||||
const list = this.store.get().dialogueHistory.map((entry) => `<li><strong>${escapeHtml(entry.speaker)}</strong><p>${escapeHtml(entry.text)}</p></li>`).join("");
|
||||
this.openPanel("Conversation history", `<ol class="tr-history">${list}</ol>`);
|
||||
}
|
||||
|
||||
cipher(done) {
|
||||
const answer = ["Dawn", "Rain", "Lantern", "Moon"];
|
||||
let progress = [];
|
||||
const render = () => {
|
||||
this.openPanel("The lantern cipher", `
|
||||
<p class="tr-panel-lede">The indigo knot named an order. Touch the carved shelf emblems from first light to moonrise.</p>
|
||||
<div class="tr-puzzle-sequence">${answer.map((word) => `<button type="button" data-symbol="${word}">${word}</button>`).join("")}</div>
|
||||
<p data-puzzle-progress>${progress.length ? progress.join(" · ") : "The lantern waits."}</p>
|
||||
${this.store.get().settings.assistedMinigames ? "<small>Assisted hint: Dawn → Rain → Lantern → Moon</small>" : ""}`, "Library puzzle", false);
|
||||
this.panelBody.querySelectorAll("[data-symbol]").forEach((button) => button.addEventListener("click", () => {
|
||||
const value = button.dataset.symbol;
|
||||
if (value === answer[progress.length]) {
|
||||
progress.push(value);
|
||||
this.audio.sfx("puzzle");
|
||||
if (progress.length === answer.length) {
|
||||
this.store.dispatch({type: "puzzle", id: "cipher"});
|
||||
this.store.dispatch({type: "achievement", id: "lantern_reader"});
|
||||
this.store.dispatch({type: "item", id: "lantern_rubbing"});
|
||||
this.overlay.hidden = true;
|
||||
this.toast("The shelves answer with a hidden garden map.");
|
||||
this.saves.save("auto", this.store.get());
|
||||
done();
|
||||
} else render();
|
||||
} else {
|
||||
progress = [];
|
||||
this.toast("The lanterns dim. Begin again.");
|
||||
render();
|
||||
}
|
||||
}));
|
||||
};
|
||||
render();
|
||||
}
|
||||
|
||||
flowerSequence(done) {
|
||||
const answer = ["Jasmine", "Tuberose", "Marigold", "Bakul"];
|
||||
const symbols = ["Bakul", "Jasmine", "Marigold", "Tuberose"];
|
||||
let progress = [];
|
||||
const render = () => {
|
||||
this.openPanel("The founders' flower mosaic", `
|
||||
<p class="tr-panel-lede">Four carved blooms follow the old festival day: welcome at dawn, fragrance at dusk, fire at the feast, memory at moonrise.</p>
|
||||
<div class="tr-puzzle-sequence">${symbols.map((flower) => `<button type="button" data-flower="${flower}">${flower}</button>`).join("")}</div>
|
||||
<p data-puzzle-progress>${progress.length ? progress.join(" · ") : "The stone petals wait beneath your hand."}</p>
|
||||
${this.store.get().settings.assistedMinigames ? "<small>Assisted hint: Jasmine → Tuberose → Marigold → Bakul</small>" : ""}`,
|
||||
"Moon garden puzzle · untimed", false);
|
||||
this.panelBody.querySelectorAll("[data-flower]").forEach((button) => button.addEventListener("click", () => {
|
||||
const value = button.dataset.flower;
|
||||
if (value === answer[progress.length]) {
|
||||
progress.push(value);
|
||||
this.audio.sfx("puzzle");
|
||||
if (progress.length === answer.length) {
|
||||
this.store.dispatch({type: "puzzle", id: "flower_sequence"});
|
||||
this.store.dispatch({type: "item", id: "anwara_note"});
|
||||
this.overlay.hidden = true;
|
||||
this.toast("The mosaic opens around a note and an archer's brass token.");
|
||||
this.saves.save("auto", this.store.get());
|
||||
done();
|
||||
} else render();
|
||||
} else {
|
||||
progress = [];
|
||||
this.toast("The carved vine returns to its beginning.");
|
||||
render();
|
||||
}
|
||||
}));
|
||||
};
|
||||
render();
|
||||
}
|
||||
|
||||
gifts(done) {
|
||||
const discovered = this.store.get().discoveredGifts;
|
||||
const available = this.content.items.gifts.filter((gift) => discovered.includes(gift.id));
|
||||
const cards = available.map((gift) =>
|
||||
`<button type="button" class="tr-gift" data-gift="${gift.id}"><img src="/assets/games/two-rivers/images/items/${gift.id}.webp" alt=""><strong>${escapeHtml(gift.name)}</strong><small>${escapeHtml(gift.hint)}</small></button>`).join("");
|
||||
this.openPanel("A gift for Lima", `<p class="tr-panel-lede">Choose from the ideas Z noticed in conversation. No sincere gift is punished, and only one gift is exchanged in this act.</p><div class="tr-gifts">${cards}</div>`, "Old marketplace", false);
|
||||
this.panelBody.querySelectorAll("[data-gift]").forEach((button) => button.addEventListener("click", () => {
|
||||
const gift = this.content.items.gifts.find((item) => item.id === button.dataset.gift);
|
||||
if (!this.store.get().gifts.includes(gift.id)) {
|
||||
Object.entries(gift.effects).forEach(([key, amount]) => this.store.dispatch({type: "relationship", key, amount}));
|
||||
this.store.dispatch({type: "gift", id: gift.id});
|
||||
this.store.dispatch({type: "achievement", id: "thoughtful_gift"});
|
||||
}
|
||||
this.overlay.hidden = true;
|
||||
this.toast(`Lima accepts the ${gift.name.toLowerCase()} with a smile that lingers.`);
|
||||
this.saves.save("auto", this.store.get());
|
||||
done();
|
||||
}));
|
||||
}
|
||||
|
||||
minigame(type, done) {
|
||||
const instructions = {
|
||||
archery: {
|
||||
title: "Moonlit Archery",
|
||||
copy: "Five arrows, one moving sight. Press Space or tap Loose when the glint reaches the gold centre. Your result changes the banter, never the route.",
|
||||
input: "Keyboard: Space · Pointer/touch: Loose button · Pause is available between shots."
|
||||
},
|
||||
cooking: {
|
||||
title: "The Festival Kitchen",
|
||||
copy: "Choose four ingredients in the order hidden by Nadia's rhyme: foundation, gold, brightness, green. There is no timer.",
|
||||
input: "Keyboard: number keys 1–4 · Pointer/touch: ingredient buttons."
|
||||
},
|
||||
dance: {
|
||||
title: "Dance at Moonrise",
|
||||
copy: "Follow twelve directional cues. A missed step earns different dialogue, not failure, and the sequence is untimed.",
|
||||
input: "Keyboard: arrow keys · Pointer/touch: direction buttons."
|
||||
}
|
||||
}[type];
|
||||
this.openPanel(instructions.title, `
|
||||
<p class="tr-panel-lede">${escapeHtml(instructions.copy)}</p>
|
||||
<p>${escapeHtml(instructions.input)}</p>
|
||||
${this.store.get().settings.assistedMinigames ? "<p><strong>Assisted mode is on:</strong> cues move more slowly or reveal the next correct input.</p>" : ""}
|
||||
<button type="button" class="tr-primary" data-mini-begin>Begin</button>`, "Practice instructions · pause safely · story progress is guaranteed", false);
|
||||
this.panelBody.querySelector("[data-mini-begin]").addEventListener("click", () => {
|
||||
if (type === "archery") this.archery(done);
|
||||
else if (type === "cooking") this.cooking(done);
|
||||
else this.dance(done);
|
||||
});
|
||||
}
|
||||
|
||||
archery(done) {
|
||||
const firstCompletion = !this.store.get().minigames.archery?.complete;
|
||||
let shots = 0;
|
||||
let score = 0;
|
||||
let direction = 1;
|
||||
let position = 0;
|
||||
const assisted = this.store.get().settings.assistedMinigames;
|
||||
this.openPanel("Moonlit Archery", `
|
||||
<p class="tr-panel-lede">Loose five arrows when the moving glint crosses the gold centre. Press Space or tap Loose.</p>
|
||||
<div class="tr-archery"><i data-archer-mark></i><b></b></div>
|
||||
<p data-mini-score>Arrows 0/5 · Score 0</p>
|
||||
<div class="tr-actions tr-actions--row">
|
||||
<button type="button" class="tr-primary" data-mini-action>Loose</button>
|
||||
<button type="button" data-mini-pause>Pause</button>
|
||||
</div>`, "Practice is included · the story always continues", false);
|
||||
const marker = this.panelBody.querySelector("[data-archer-mark]");
|
||||
const output = this.panelBody.querySelector("[data-mini-score]");
|
||||
let frame;
|
||||
let paused = false;
|
||||
const animate = () => {
|
||||
if (!paused) position += direction * (assisted ? 0.6 : 1.15);
|
||||
if (position >= 100 || position <= 0) direction *= -1;
|
||||
position = Math.max(0, Math.min(100, position));
|
||||
marker.style.left = `${position}%`;
|
||||
frame = requestAnimationFrame(animate);
|
||||
};
|
||||
const shoot = () => {
|
||||
const accuracy = Math.max(0, 20 - Math.abs(50 - position));
|
||||
score += Math.round(accuracy);
|
||||
shots += 1;
|
||||
this.audio.sfx("archery");
|
||||
output.textContent = `Arrows ${shots}/5 · Score ${score}`;
|
||||
if (shots === 5) {
|
||||
cancelAnimationFrame(frame);
|
||||
const bullseyes = Math.round(score / 20);
|
||||
this.store.dispatch({type: "minigame", id: "archery", score, perfect: score >= 88});
|
||||
if (firstCompletion) {
|
||||
this.store.dispatch({type: "relationship", key: "adventure", amount: 4 + bullseyes});
|
||||
if (score >= 78) this.store.dispatch({type: "achievement", id: "true_aim"});
|
||||
}
|
||||
this.overlay.hidden = true;
|
||||
this.toast(score >= 70 ? "Lima bows with exaggerated solemnity. “Acceptable.”" : "Lima grins. “The target survived. Barely.”");
|
||||
this.saves.save("auto", this.store.get());
|
||||
done();
|
||||
}
|
||||
};
|
||||
this.panelBody.querySelector("[data-mini-action]").addEventListener("click", shoot);
|
||||
this.panelBody.querySelector("[data-mini-pause]").addEventListener("click", (event) => {
|
||||
paused = !paused;
|
||||
event.currentTarget.textContent = paused ? "Resume" : "Pause";
|
||||
this.panelBody.querySelector("[data-mini-action]").disabled = paused;
|
||||
this.announce(paused ? "Archery paused." : "Archery resumed.");
|
||||
});
|
||||
this.panel.onkeydown = (event) => { if (event.code === "Space") { event.preventDefault(); shoot(); } };
|
||||
animate();
|
||||
}
|
||||
|
||||
cooking(done) {
|
||||
const firstCompletion = !this.store.get().minigames.cooking?.complete;
|
||||
const recipe = ["Rice", "Saffron", "Citrus", "Pistachio"];
|
||||
let index = 0;
|
||||
let mistakes = 0;
|
||||
const labels = [...recipe].sort(() => 0.5 - Math.random());
|
||||
const render = () => {
|
||||
this.openPanel("The festival kitchen", `
|
||||
<p class="tr-panel-lede">Build Rayhan's fragrant rice in the order hidden in Nadia's rhyme: foundation, gold, brightness, green.</p>
|
||||
<div class="tr-cooking">${labels.map((label) => `<button type="button" data-ingredient="${label}" ${recipe.slice(0, index).includes(label) ? "disabled" : ""}>${label}</button>`).join("")}</div>
|
||||
<p>${index}/4 prepared · ${mistakes} ${mistakes === 1 ? "correction" : "corrections"}</p>
|
||||
${this.store.get().settings.assistedMinigames ? "<small>Assisted order: Rice, Saffron, Citrus, Pistachio.</small>" : ""}`, "Cooperative cooking", false);
|
||||
this.panelBody.querySelectorAll("[data-ingredient]").forEach((button) => button.addEventListener("click", () => {
|
||||
if (button.dataset.ingredient === recipe[index]) {
|
||||
index += 1;
|
||||
this.audio.sfx("cooking");
|
||||
if (index === recipe.length) {
|
||||
this.store.dispatch({type: "minigame", id: "cooking", score: Math.max(0, 100 - mistakes * 20), perfect: mistakes === 0});
|
||||
if (firstCompletion) {
|
||||
this.store.dispatch({type: "relationship", key: "comfort", amount: mistakes === 0 ? 7 : 4});
|
||||
this.store.dispatch({type: "relationship", key: "humour", amount: mistakes ? 5 : 2});
|
||||
if (!mistakes) this.store.dispatch({type: "achievement", id: "kitchen_conspiracy"});
|
||||
}
|
||||
this.overlay.hidden = true;
|
||||
this.toast(mistakes ? "The dish is delicious. The flour on Lima's cheek is a separate triumph." : "Rayhan declares the dish—and the teamwork—suspiciously perfect.");
|
||||
this.saves.save("auto", this.store.get());
|
||||
done();
|
||||
} else render();
|
||||
} else {
|
||||
mistakes += 1;
|
||||
this.toast("Lima catches your wrist. “Bold. Incorrect, but bold.”");
|
||||
render();
|
||||
}
|
||||
}));
|
||||
this.panel.onkeydown = (event) => {
|
||||
const number = Number(event.key);
|
||||
if (number >= 1 && number <= 4) this.panelBody.querySelectorAll("[data-ingredient]")[number - 1]?.click();
|
||||
};
|
||||
};
|
||||
render();
|
||||
}
|
||||
|
||||
dance(done) {
|
||||
const firstCompletion = !this.store.get().minigames.dance?.complete;
|
||||
const pattern = ["←", "↑", "→", "↓", "←", "→", "↑", "↓", "→", "←", "↓", "↑"];
|
||||
let index = 0;
|
||||
let score = 0;
|
||||
const assisted = this.store.get().settings.assistedMinigames;
|
||||
const render = () => {
|
||||
this.openPanel("Dance at moonrise", `
|
||||
<p class="tr-panel-lede">Follow Lima's sequence. Grace is welcome; recovery is irresistible.</p>
|
||||
<div class="tr-dance-cue">${pattern[index]}</div>
|
||||
<div class="tr-dance">${["←", "↑", "↓", "→"].map((arrow) => `<button type="button" data-step="${arrow}">${arrow}</button>`).join("")}</div>
|
||||
<p>Step ${index + 1}/${pattern.length} · Rhythm ${score}</p>
|
||||
${assisted ? `<small>Assisted: choose ${pattern[index]}</small>` : ""}`, "Festival dance", false);
|
||||
this.panelBody.querySelectorAll("[data-step]").forEach((button) => button.addEventListener("click", () => {
|
||||
if (button.dataset.step === pattern[index]) score += 10;
|
||||
else score += 3;
|
||||
index += 1;
|
||||
this.audio.sfx("dance");
|
||||
if (index >= pattern.length) {
|
||||
this.store.dispatch({type: "minigame", id: "dance", score, perfect: score === 120});
|
||||
if (firstCompletion) {
|
||||
this.store.dispatch({type: "relationship", key: "romance", amount: score >= 90 ? 8 : 5});
|
||||
this.store.dispatch({type: "relationship", key: "comfort", amount: score >= 90 ? 4 : 6});
|
||||
this.store.dispatch({type: "achievement", id: "two_left_feet"});
|
||||
}
|
||||
this.overlay.hidden = true;
|
||||
this.toast(score >= 90 ? "For one breath, the entire pavilion follows your rhythm." : "You miss a turn. Lima catches you—and does not let go.");
|
||||
this.saves.save("auto", this.store.get());
|
||||
done();
|
||||
} else render();
|
||||
}));
|
||||
this.panel.onkeydown = (event) => {
|
||||
const arrows = {ArrowLeft: "←", ArrowUp: "↑", ArrowDown: "↓", ArrowRight: "→"};
|
||||
if (arrows[event.key]) {
|
||||
event.preventDefault();
|
||||
this.panelBody.querySelector(`[data-step="${arrows[event.key]}"]`)?.click();
|
||||
}
|
||||
};
|
||||
};
|
||||
render();
|
||||
}
|
||||
|
||||
ending(done) {
|
||||
const state = this.store.get();
|
||||
const id = resolveEnding(state, this.content.endings);
|
||||
const ending = this.content.endings[id];
|
||||
this.store.dispatch({type: "ending", id});
|
||||
this.store.dispatch({type: "unlockCG", id});
|
||||
if (this.store.get().unlockedEndings.length === 4) this.store.dispatch({type: "achievement", id: "every_current"});
|
||||
this.saves.save("auto", this.store.get());
|
||||
this.audio.playLocation("ending", "water");
|
||||
this.openPanel(ending.title, `
|
||||
<figure class="tr-ending">
|
||||
<img src="/assets/games/two-rivers/images/cg/${id}.webp" alt="Z and Lima together after the Festival of Two Rivers">
|
||||
<figcaption><p>${escapeHtml(ending.epilogue)}</p><blockquote>“Tomorrow?” Z asks.<br>“Obviously,” Lima says. “We have several footnotes left to ruin.”</blockquote></figcaption>
|
||||
</figure>
|
||||
<div class="tr-actions tr-actions--row">
|
||||
<button type="button" class="tr-primary" data-ending-menu>Return to title</button>
|
||||
<button type="button" data-ending-gallery>View gallery</button>
|
||||
</div>`, "A romantic ending · the story continues", false);
|
||||
this.panelBody.querySelector("[data-ending-menu]").addEventListener("click", () => {
|
||||
this.overlay.hidden = true;
|
||||
done();
|
||||
});
|
||||
this.panelBody.querySelector("[data-ending-gallery]").addEventListener("click", () => this.gallery());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
25
assets/scripts/vendor/easystar-0.4.4.min.js
vendored
25
assets/scripts/vendor/easystar-0.4.4.min.js
vendored
File diff suppressed because one or more lines are too long
1
assets/scripts/vendor/phaser-4.1.0.min.js
vendored
1
assets/scripts/vendor/phaser-4.1.0.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user