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