611 lines
29 KiB
JavaScript
Executable File
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));
|