Files
org_web/assets/scripts/pages/princess-lima-v2-scenes.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

718 lines
40 KiB
JavaScript
Executable File

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