218 lines
11 KiB
JavaScript
Executable File
218 lines
11 KiB
JavaScript
Executable File
(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
|
|
});
|
|
}));
|