Files
org_web/assets/scripts/pages/princess-lima-v2-systems.js
gitea-actions 563d4b63cc
All checks were successful
Build Org Website / build (push) Successful in 37s
Tune Princess Lima v2 boss balance and Sun Crystal aid
2026-07-30 15:36:59 +01:00

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