718 lines
40 KiB
JavaScript
Executable File
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));
|