Files
org_web/assets/scripts/pages/princess-lima-state.js
gitea-actions 15b626ee0d
All checks were successful
Build Org Website / build (push) Successful in 38s
Rebuild Princess Lima maps around the new visual atlas
2026-07-30 12:48:17 +01:00

205 lines
9.1 KiB
JavaScript
Executable File

(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
});
}));