246 lines
12 KiB
JavaScript
Executable File
246 lines
12 KiB
JavaScript
Executable File
(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 [];
|
|
return map.obstacles.concat((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
|
|
});
|
|
}));
|