Files
org_web/assets/scripts/pages/princess-lima-scenes.js
gitea-actions a6502b168d
All checks were successful
Build Org Website / build (push) Successful in 38s
Polish Princess Lima map data and interaction feedback
2026-07-30 13:58:25 +01:00

611 lines
29 KiB
JavaScript
Executable File

(function (root) {
"use strict";
const Data = root.PrincessLimaData;
const Systems = root.PrincessLimaSystems;
const State = root.PrincessLimaState;
const Maps = root.PrincessLimaMaps;
function createSceneClasses(controller) {
class BootScene extends Phaser.Scene {
constructor() { super("LimaBoot"); }
preload() {
this.load.image("lima-title", "/assets/images/play/princess-lima/title-landscape.png");
this.load.spritesheet("lima-cast", "/assets/images/play/princess-lima/cast-atlas.png", { frameWidth: 314, frameHeight: 314 });
this.load.spritesheet("lima-region-atlas", "/assets/images/play/princess-lima/regional-style-atlas.png", { frameWidth: 418, frameHeight: 418 });
const audio = "/assets/audio/princess-lima/";
[
"village-theme", "forest-theme", "mountain-theme", "fortress-theme", "boss-theme", "victory-theme",
"step", "attack", "hit", "damage", "defeat", "pickup", "quest", "puzzle", "door", "victory",
"intro-narration-1", "intro-narration-2", "intro-narration-3", "intro-narration-4"
].forEach((key) => this.load.audio(key, `${audio}${key}.wav`));
this.load.on("loaderror", (file) => controller.devWarn(`Optional asset failed: ${file.key}`));
}
create() {
this.add.image(Data.WIDTH / 2, Data.HEIGHT / 2, "lima-title").setDisplaySize(Data.WIDTH, Data.HEIGHT);
controller.audio.attach(this, "village");
controller.ready();
}
}
class WorldScene extends Phaser.Scene {
constructor() {
super("LimaWorld");
this.regionId = "village";
this.facing = "east";
this.lastAttack = 0;
this.lastHit = 0;
this.stepDistance = 0;
this.previous = null;
this.puzzleInput = [];
this.enemySerial = 0;
this.attacking = false;
this.attackToken = 0;
this.attackHits = new Set();
}
init(data) {
this.regionId = Data.MAPS[data && data.region] ? data.region : controller.getState().region;
}
create() {
controller.scene = this;
controller.transitioning = false;
this.map = Data.MAPS[this.regionId];
this.physics.world.setBounds(0, 0, Data.WIDTH, Data.HEIGHT);
this.drawMap();
this.solids = this.physics.add.staticGroup();
Systems.activeObstacles(controller.getState(), this.regionId).forEach((shape) => this.addSolid(shape));
this.interactables = [];
this.createExits();
this.createPuzzle();
this.createPickups();
this.createNpcs();
this.createPlayer();
this.createEnemies();
this.createInput();
this.projectiles = this.physics.add.group();
this.physics.add.collider(this.projectiles, this.solids, (projectile) => projectile.destroy());
this.physics.add.overlap(this.player, this.projectiles, (_player, projectile) => {
this.hurtPlayer(projectile.getData("damage") || 10, projectile.x, projectile.y);
projectile.destroy();
});
this.cameras.main.setBounds(0, 0, Data.WIDTH, Data.HEIGHT);
this.cameras.main.startFollow(this.player, true, controller.reducedMotion() ? 1 : 0.2, controller.reducedMotion() ? 1 : 0.2);
this.cameras.main.setDeadzone(90, 60);
controller.audio.attach(this, this.regionId);
controller.updateHud();
controller.status(`${this.map.name}. ${Systems.currentObjective(controller.getState())}`);
controller.persistPosition(true);
if (this.regionId === "bossArena" && !controller.getState().defeatedBosses.includes("malrec")) {
controller.openDialogue("malrec", () => {
let state = controller.getState();
state.flags.boss_started = true;
state = Systems.startQuest(state, "defeat_malrec");
controller.setState(state, "The final battle begins.");
this.refreshRequiredEnemies();
});
}
}
drawMap() {
this.cameras.main.setBackgroundColor(this.map.palette[0]);
Maps.render(this, this.regionId);
}
addSolid(shape) {
let object;
if (shape.shape === "circle") {
object = this.add.circle(shape.x, shape.y, shape.radius, 0xffffff, 0);
this.physics.add.existing(object, true);
object.body.setCircle(shape.radius);
} else {
object = this.add.rectangle(shape.x + shape.width / 2, shape.y + shape.height / 2, shape.width, shape.height, 0xffffff, 0);
this.physics.add.existing(object, true);
}
this.solids.add(object);
}
createPlayer() {
const state = controller.getState();
const saved = state.region === this.regionId ? state.position : { x: this.map.spawns[Object.keys(this.map.spawns)[0]].x, y: this.map.spawns[Object.keys(this.map.spawns)[0]].y };
const safe = Systems.nearestSafeSpawn(state, this.regionId, saved.x, saved.y);
this.facing = state.position.facing || "east";
this.player = this.physics.add.sprite(safe.x, safe.y, "lima-cast", playerFrame(this.facing)).setScale(0.3).setDepth(100);
this.player.body.setSize(70, 42).setOffset(122, 215);
this.player.setCollideWorldBounds(true).setMaxVelocity(176, 176);
this.physics.add.collider(this.player, this.solids);
this.physics.add.collider(this.player, this.npcGroup);
this.previous = { x: this.player.x, y: this.player.y };
}
createNpcs() {
this.npcGroup = this.physics.add.staticGroup();
(this.map.npcs || []).forEach(([id, x, y]) => {
const npc = this.npcGroup.create(x, y, "lima-cast", Data.NPCS[id].frame).setScale(0.29).setDepth(y + 40);
npc.refreshBody();
npc.body.setSize(70, 42).setOffset(122, 215);
this.interactables.push({ type: "npc", id, x, y, sprite: npc });
});
}
createEnemies() {
this.enemies = this.physics.add.group();
this.physics.add.collider(this.enemies, this.solids);
this.physics.add.collider(this.enemies, this.enemies);
this.physics.add.collider(this.enemies, this.npcGroup);
this.physics.add.overlap(this.player, this.enemies, (_player, enemyObject) => {
this.hurtPlayer(enemyObject.getData("spec").damage, enemyObject.x, enemyObject.y);
});
(this.map.enemies || []).forEach((spawn) => {
if (spawn.boss && controller.getState().defeatedBosses.includes(spawn.type)) return;
this.spawnEnemy(spawn);
});
}
spawnEnemy(spawn) {
const spec = Data.ENEMIES[spawn.type];
const enemyObject = this.physics.add.sprite(spawn.x, spawn.y, "lima-cast", spec.frame)
.setScale(spec.boss ? 0.34 : 0.25).setDepth(spawn.y + 30);
enemyObject.body.setSize(spec.boss ? 130 : 95, spec.boss ? 78 : 58).setOffset(spec.boss ? 92 : 110, spec.boss ? 200 : 205);
enemyObject.setData({
id: `${spawn.type}-${++this.enemySerial}`, type: spawn.type, spec, spawn,
health: spec.health, homeX: spawn.x, homeY: spawn.y, nextAction: this.time.now + 900, phase: 1
});
this.enemies.add(enemyObject);
this.applyEnemyRequirement(enemyObject);
}
applyEnemyRequirement(enemyObject) {
const requirement = enemyObject.getData("spawn").requires;
const available = !requirement || controller.getState().flags[requirement]
|| controller.getState().unlockedRoutes.includes(requirement);
enemyObject.setVisible(available);
enemyObject.body.enable = available;
}
refreshRequiredEnemies() {
this.enemies.getChildren().forEach((enemyObject) => this.applyEnemyRequirement(enemyObject));
}
createExits() {
(this.map.exits || []).forEach((item) => {
const zone = this.add.zone(item.x + item.width / 2, item.y + item.height / 2, item.width, item.height);
this.interactables.push({ type: "exit", id: item.id, x: item.x + item.width / 2, y: item.y + item.height / 2, data: item, sprite: zone });
});
}
createPuzzle() {
const puzzle = this.map.puzzle;
if (!puzzle || controller.getState().solvedPuzzles.includes(puzzle.id)) return;
puzzle.objects.forEach(([id, x, y]) => {
const node = this.add.zone(x, y, 38, 38);
const marker = this.createInteractionMarker(x, y, `${readableId(id)} · E`);
this.interactables.push({ type: "puzzle", id, puzzle, x, y, sprite: node, marker });
});
}
createPickups() {
(this.map.pickups || []).forEach(([id, x, y], index) => {
const chestId = `${this.regionId}-${id}-${index}`;
if (controller.getState().openedChests.includes(chestId)) return;
const item = this.add.zone(x, y, 34, 34);
const marker = this.createInteractionMarker(x, y, `${Data.ITEMS[id].name} · E`);
this.interactables.push({ type: "pickup", id, chestId, x, y, sprite: item, marker });
});
}
createInteractionMarker(x, y, label) {
return this.add.text(x, y - 32, label, {
fontFamily: "monospace",
fontSize: "13px",
fontStyle: "bold",
color: "#fff6ce",
backgroundColor: "#111827",
padding: { x: 7, y: 4 }
}).setOrigin(0.5).setDepth(900).setVisible(false);
}
createInput() {
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({
up: "W", down: "S", left: "A", right: "D", interact: "E", enter: "ENTER",
item: "Q", pause: "ESC", menu: "M", inventory: "I", quests: "J", fullscreen: "F", cycle: "TAB"
});
this.input.keyboard.addCapture(["SPACE", "TAB", "UP", "DOWN", "LEFT", "RIGHT"]);
}
update(time, delta) {
if (!this.player) return;
if (controller.locked || controller.transitioning) {
this.player.setVelocity(0);
return;
}
const traveled = Math.hypot(this.player.x - this.previous.x, this.player.y - this.previous.y);
this.previous = { x: this.player.x, y: this.player.y };
let dx = 0;
let dy = 0;
if (this.cursors.left.isDown || this.keys.left.isDown) dx -= 1;
if (this.cursors.right.isDown || this.keys.right.isDown) dx += 1;
if (this.cursors.up.isDown || this.keys.up.isDown) dy -= 1;
if (this.cursors.down.isDown || this.keys.down.isDown) dy += 1;
if (this.attacking) dx = dy = 0;
const velocity = Systems.approachVelocity(
this.player.body.velocity.x, this.player.body.velocity.y, dx, dy, delta,
Boolean(controller.getState().equipment.boots)
);
this.player.setVelocity(velocity.x, velocity.y);
if (dx || dy) {
this.facing = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south");
this.player.setFrame(playerFrame(this.facing));
const bob = controller.reducedMotion() ? 1 : 1 + Math.sin(time / 90) * 0.018;
this.player.setScale(0.3, 0.3 * bob);
this.stepDistance += traveled;
if (this.stepDistance >= 48) {
controller.audio.play("step", 0.45);
this.stepDistance = 0;
}
} else {
this.player.setScale(0.3);
this.stepDistance = 0;
}
this.player.setDepth(this.player.y + 80);
this.updateNearest();
this.updateEnemies(time);
this.handleKeys(time);
if ((dx || dy) && time - controller.lastSaveAt > 900) {
controller.lastSaveAt = time;
controller.persistPosition(false);
}
}
updateNearest() {
let best = Infinity;
let nearest = null;
this.interactables.forEach((item) => {
const distance = Math.hypot(item.x - this.player.x, item.y - this.player.y);
if (item.marker) item.marker.setVisible(distance <= 92);
const radius = item.type === "exit" ? 70 : 54;
if (distance <= radius && distance < best) {
best = distance;
nearest = item;
}
});
this.nearest = nearest;
controller.prompt(nearest ? promptFor(nearest) : "");
}
handleKeys(time) {
if (Phaser.Input.Keyboard.JustDown(this.keys.interact) || Phaser.Input.Keyboard.JustDown(this.keys.enter)) this.interact();
if (Phaser.Input.Keyboard.JustDown(this.cursors.space)) this.attack(time);
if (Phaser.Input.Keyboard.JustDown(this.keys.item)) controller.useTonic();
if (Phaser.Input.Keyboard.JustDown(this.keys.pause) || Phaser.Input.Keyboard.JustDown(this.keys.menu)) controller.openPanel("pause");
if (Phaser.Input.Keyboard.JustDown(this.keys.inventory)) controller.openPanel("inventory");
if (Phaser.Input.Keyboard.JustDown(this.keys.quests)) controller.openPanel("quests");
if (Phaser.Input.Keyboard.JustDown(this.keys.fullscreen)) controller.toggleFullscreen();
if (Phaser.Input.Keyboard.JustDown(this.keys.cycle)) controller.cycleItem();
}
interact() {
const item = this.nearest;
if (!item) return controller.status("There is nothing close enough to interact with.");
if (item.type === "exit") {
if (item.data.requirement && !controller.getState().unlockedRoutes.includes(item.data.requirement)) {
return controller.status(`The route is blocked. ${Systems.currentObjective(controller.getState())}`);
}
controller.travel(item.data.target, item.data.spawn);
} else if (item.type === "npc") this.interactNpc(item);
else if (item.type === "puzzle") this.activatePuzzle(item);
else if (item.type === "pickup") this.collect(item);
}
interactNpc(item) {
const dx = item.x - this.player.x;
const dy = item.y - this.player.y;
this.facing = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south");
this.player.setFrame(playerFrame(this.facing));
controller.openDialogue(item.id, () => this.resolveNpc(item.id));
}
resolveNpc(id) {
let state = controller.getState();
if (id === "elder" && !state.flags.aftermath_complete) {
state = Systems.completeQuest(Systems.startQuest(state, "aftermath"), "aftermath");
state = Systems.startQuest(state, "village_defence");
controller.setState(state, "Wayfarer Sword acquired. The second raid has begun.");
this.refreshRequiredEnemies();
} else if (id === "nia") {
state = Systems.startQuest(state, "healer_herbs");
if (Systems.quantity(state, "silver_leaf") >= 2) {
state = Systems.completeQuest(state, "healer_herbs");
controller.setState(state, "Nia brews two healing tonics.");
} else controller.setState(state, "Optional quest started: collect two Silver Leaves.");
} else if (id === "tovin") {
state = Systems.startQuest(state, "find_guide");
controller.setState(state, "Wake the three standing stones from youngest tree to oldest.");
} else if (id === "bram" && this.regionId === "mountain") {
state = Systems.startQuest(state, "repair_bridge");
controller.setState(state, "Restart the west and east bridge winches.");
} else if (id === "elowen" && this.regionId === "camp") {
state = Systems.startQuest(state, "free_scout");
state = Systems.progressQuest(state, "free_scout", 1);
controller.setState(state, "Elowen is free. Defeat Captain Veyr for his emblem.");
} else if (id === "elowen" && this.regionId === "fortressInterior") {
state = Systems.startQuest(state, "free_prisoners");
state = Systems.progressQuest(state, "free_prisoners", 1);
state = Systems.startQuest(state, "break_wards");
controller.setState(state, "Elowen's captured ally is free. Disable the fortress wards.");
} else if (id === "prisoner") {
state = Systems.startQuest(state, "free_prisoners");
state = Systems.progressQuest(state, "free_prisoners", 1);
state = Systems.startQuest(state, "break_wards");
controller.setState(state, "A prisoner is free. Disable the fortress wards.");
} else if (id === "lima" && state.defeatedBosses.includes("malrec")) {
state.rescued = true;
state.story = "complete";
controller.setState(State.normalize(state), "Princess Lima is safe.");
controller.openEnding();
}
}
activatePuzzle(item) {
const puzzle = item.puzzle;
const state = controller.getState();
if (state.solvedPuzzles.includes(puzzle.id)) return;
if (puzzle.type === "set") {
if (!this.puzzleInput.includes(item.id)) this.puzzleInput.push(item.id);
if (item.marker) item.marker.setText(`${readableId(item.id)}`);
if (this.puzzleInput.length === puzzle.sequence.length) this.finishPuzzle(puzzle.id);
else controller.status(`${this.puzzleInput.length} / ${puzzle.sequence.length} mechanisms active.`);
return;
}
this.puzzleInput.push(item.id);
const valid = this.puzzleInput.every((value, index) => value === puzzle.sequence[index]);
if (!valid) {
this.puzzleInput = [];
controller.status("The sequence resets. Look for the environmental clue and try again.");
return;
}
if (item.marker) item.marker.setText(`${readableId(item.id)}`);
if (this.puzzleInput.length === puzzle.sequence.length) this.finishPuzzle(puzzle.id);
else controller.status(`${item.id} answers. ${this.puzzleInput.length} / ${puzzle.sequence.length}.`);
}
finishPuzzle(id) {
let state = Systems.solvePuzzle(controller.getState(), id);
if (id === "sun_pedestals") {
state.solvedPuzzles.push(id);
state.flags.sun_veil_broken = true;
state = State.normalize(state);
}
controller.setState(state, "Puzzle complete. A sealed route opens.");
controller.audio.play("puzzle");
if (id === "bridge_winches") controller.reloadRegion();
}
collect(item) {
let state = controller.getState();
if (state.openedChests.includes(item.chestId)) return;
state.openedChests.push(item.chestId);
state = Systems.addItem(state, item.id, 1);
controller.setState(state, `${Data.ITEMS[item.id].name} collected.`);
controller.audio.play("pickup");
item.sprite.destroy();
if (item.marker) item.marker.destroy();
this.interactables = this.interactables.filter((entry) => entry !== item);
}
attack(time) {
if (time - this.lastAttack < Systems.ATTACK_TIMING.cooldown || controller.locked || this.attacking) return;
this.lastAttack = time;
this.attacking = true;
const token = ++this.attackToken;
const facing = this.facing;
this.attackHits = new Set();
this.player.setVelocity(0);
controller.audio.play("attack");
const direction = faceVector(facing);
const blade = this.add.rectangle(0, -22, 6, 42, 0xeaf6ff).setStrokeStyle(2, 0x5e6b78);
const guard = this.add.rectangle(0, 1, 20, 6, 0xf2c467).setStrokeStyle(1, 0x5b3d25);
const grip = this.add.rectangle(0, 11, 6, 19, 0x70432d);
const weapon = this.add.container(
this.player.x + direction.x * 19,
this.player.y + direction.y * 16,
[blade, guard, grip]
).setDepth(690).setAngle(direction.angle + 5);
const windupAngle = facing === "west" || facing === "north" ? -12 : 12;
this.tweens.add({
targets: this.player, angle: windupAngle, scaleX: 0.29, scaleY: 0.31,
duration: Systems.ATTACK_TIMING.windup, ease: "Stepped"
});
this.tweens.add({
targets: weapon,
angle: direction.angle + 90,
duration: Systems.ATTACK_TIMING.windup + Systems.ATTACK_TIMING.active,
ease: "Cubic.Out"
});
this.time.delayedCall(Systems.ATTACK_TIMING.windup, () => {
if (!this.player.active || token !== this.attackToken) return;
this.player.setAngle(-windupAngle).setScale(0.32, 0.28);
this.performAttackHit(facing, token);
});
this.time.delayedCall(Systems.ATTACK_TIMING.windup + Systems.ATTACK_TIMING.active, () => {
if (!this.player.active || token !== this.attackToken) return;
this.player.setAngle(windupAngle / 2).setScale(0.3);
});
this.time.delayedCall(
Systems.ATTACK_TIMING.windup + Systems.ATTACK_TIMING.active + Systems.ATTACK_TIMING.recovery,
() => {
if (!this.player.active || token !== this.attackToken) return;
this.player.setAngle(0).setScale(0.3).setFrame(playerFrame(this.facing));
if (weapon.active) weapon.destroy();
this.attacking = false;
}
);
}
performAttackHit(facing, token) {
const box = Systems.attackHitbox(facing, this.player.x, this.player.y);
const direction = faceVector(facing);
const hitbox = this.add.rectangle(box.x + box.width / 2, box.y + box.height / 2, box.width, box.height, 0xff4df3, controller.debugCollision ? 0.28 : 0);
this.physics.add.existing(hitbox, true);
const slash = this.add.graphics().setDepth(700);
slash.lineStyle(9, 0xffefb0, 0.95).beginPath();
slash.arc(
this.player.x + direction.x * 30, this.player.y + direction.y * 24, 50,
Phaser.Math.DegToRad(direction.angle - 58), Phaser.Math.DegToRad(direction.angle + 58), false
).strokePath();
slash.lineStyle(3, 0xd3f7ff, 0.9).strokePath();
this.physics.overlap(hitbox, this.enemies, (_hit, enemyObject) => this.hitEnemy(enemyObject, direction, token));
this.time.delayedCall(Systems.ATTACK_TIMING.active, () => {
if (hitbox.active) hitbox.destroy();
if (slash.active) slash.destroy();
});
}
hitEnemy(enemyObject, direction, token) {
if (!enemyObject.active || !enemyObject.visible) return;
const enemyId = enemyObject.getData("id");
if (this.attackHits.has(enemyId) || token !== this.attackToken) return;
this.attackHits.add(enemyId);
if (enemyObject.getData("type") === "malrec" && enemyObject.getData("phase") >= 3 && !controller.getState().flags.sun_veil_broken) {
controller.status("Malrec's final veil holds. Activate both Sun Crystal pedestals.");
return;
}
const health = enemyObject.getData("health") - controller.getState().attack;
enemyObject.setData("health", health);
const pushDistance = controller.reducedMotion() ? 20 : enemyObject.getData("spec").boss ? 24 : 42;
const target = Systems.wallSafeKnockback(controller.getState(), this.regionId, enemyObject.x, enemyObject.y, direction.x, direction.y, pushDistance);
const push = Systems.normalizedVector(target.x - enemyObject.x, target.y - enemyObject.y, controller.reducedMotion() ? 90 : 190);
enemyObject.setData("stunnedUntil", this.time.now + 210);
enemyObject.setVelocity(push.x, push.y).setTintFill(0xffe1b0);
controller.audio.play("hit", enemyObject.getData("spec").boss ? 1 : 0.78);
const impact = this.add.star(enemyObject.x, enemyObject.y - 15, 6, 5, 15, 0xfff1a8, 0.95).setDepth(750);
this.time.delayedCall(70, () => { if (impact.active) impact.destroy(); });
this.time.delayedCall(110, () => {
if (enemyObject.active) {
enemyObject.clearTint();
enemyObject.setVelocity(0);
}
});
const pause = enemyObject.getData("spec").boss ? 58 : 38;
this.physics.world.pause();
this.time.delayedCall(pause, () => { if (this.physics.world) this.physics.world.resume(); });
if (enemyObject.getData("spec").boss && !controller.reducedMotion() && controller.getState().settings.screenShake) {
this.cameras.main.shake(70, 0.0025);
}
if (health <= 0) this.defeatEnemy(enemyObject);
}
defeatEnemy(enemyObject) {
const type = enemyObject.getData("type");
const spawn = enemyObject.getData("spawn");
const wasBoss = enemyObject.getData("spec").boss;
enemyObject.destroy();
controller.audio.play("defeat");
let state = controller.getState();
if (spawn.quest === "village_defence") state = Systems.progressQuest(state, "village_defence", 1);
if (wasBoss) state = Systems.recordBoss(state, type);
controller.setState(state, wasBoss ? `${Data.ENEMIES[type].name} defeated. The route is open.` : `${Data.ENEMIES[type].name} defeated.`);
if (type === "malrec") {
controller.audio.play("victory");
this.time.delayedCall(500, () => controller.travel("chamber", "door"));
}
}
updateEnemies(time) {
this.enemies.getChildren().forEach((enemyObject) => {
if (!enemyObject.active || !enemyObject.body.enable) return;
if (time < (enemyObject.getData("stunnedUntil") || 0)) return;
const spec = enemyObject.getData("spec");
const distance = Math.hypot(enemyObject.x - this.player.x, enemyObject.y - this.player.y);
const homeDistance = Math.hypot(enemyObject.x - enemyObject.getData("homeX"), enemyObject.y - enemyObject.getData("homeY"));
if (distance > 270 || homeDistance > enemyObject.getData("spawn").leash) {
this.physics.moveTo(enemyObject, enemyObject.getData("homeX"), enemyObject.getData("homeY"), spec.speed);
return;
}
if (spec.behaviour === "wander" && time > enemyObject.getData("nextAction")) {
const angle = Math.random() * Math.PI * 2;
enemyObject.setVelocity(Math.cos(angle) * spec.speed, Math.sin(angle) * spec.speed);
enemyObject.setData("nextAction", time + 650);
} else if (["charge", "slam", "final"].includes(spec.behaviour)) this.updateBoss(enemyObject, time, distance);
else this.physics.moveToObject(enemyObject, this.player, spec.speed);
enemyObject.setDepth(enemyObject.y + 60);
});
}
updateBoss(enemyObject, time, distance) {
const spec = enemyObject.getData("spec");
const ratio = enemyObject.getData("health") / spec.health;
const phase = Systems.bossPhase(enemyObject.getData("health"), spec.health, spec.phases || 1);
enemyObject.setData("phase", phase);
controller.boss(spec.name, enemyObject.getData("health"), spec.health);
if (time < enemyObject.getData("nextAction")) return;
enemyObject.setVelocity(0).setTint(0xf5c96e);
const telegraph = controller.getState().settings.reducedMotion ? 780 : 560;
enemyObject.setData("nextAction", time + 1500);
this.time.delayedCall(telegraph, () => {
if (!enemyObject.active) return;
enemyObject.clearTint();
if (spec.behaviour === "final" && phase >= 2) this.fireRadial(enemyObject, phase === 3 ? 8 : 5);
else if (distance < 340) this.physics.moveToObject(enemyObject, this.player, spec.speed * 2.1);
});
}
fireRadial(enemyObject, count) {
for (let index = 0; index < count; index += 1) {
const angle = Math.PI * 2 * index / count;
const shot = this.add.circle(enemyObject.x, enemyObject.y, 8, 0x6f3d89).setStrokeStyle(2, 0xf1b4ff).setDepth(400);
this.physics.add.existing(shot);
shot.body.setVelocity(Math.cos(angle) * 150, Math.sin(angle) * 150);
shot.setData("damage", 13);
this.projectiles.add(shot);
this.time.delayedCall(3600, () => { if (shot.active) shot.destroy(); });
}
}
hurtPlayer(amount, fromX, fromY) {
const result = Systems.damage(controller.getState(), amount, this.time.now, this.lastHit);
if (!result.hit) return;
this.lastHit = this.time.now;
controller.setState(result.state, `You take ${result.amount} damage.`);
controller.audio.play("damage");
const push = Systems.normalizedVector(this.player.x - fromX, this.player.y - fromY, 210);
this.player.setVelocity(push.x, push.y).setTint(0xff8c8c);
this.time.delayedCall(170, () => { if (this.player.active) this.player.clearTint(); });
if (!controller.reducedMotion() && controller.getState().settings.screenShake) this.cameras.main.shake(100, 0.003);
if (result.defeated) controller.gameOver();
}
}
return [BootScene, WorldScene];
}
function playerFrame(facing) {
return { south: 0, east: 1, north: 2, west: 3 }[facing] || 0;
}
function faceVector(facing) {
return {
north: { x: 0, y: -1, angle: 270 }, south: { x: 0, y: 1, angle: 90 },
west: { x: -1, y: 0, angle: 180 }, east: { x: 1, y: 0, angle: 0 }
}[facing];
}
function promptFor(item) {
if (item.type === "exit") return `${item.data.label} · E / Enter`;
if (item.type === "npc") return `Speak with ${Data.NPCS[item.id].name} · E / Enter`;
if (item.type === "puzzle") return `Activate ${item.id} · E / Enter`;
if (item.type === "pickup") return `Collect ${Data.ITEMS[item.id].name} · E / Enter`;
return "Interact · E / Enter";
}
function readableId(value) {
return String(value).replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
root.PrincessLimaScenes = Object.freeze({ createSceneClasses, playerFrame });
}(typeof globalThis !== "undefined" ? globalThis : this));