613 lines
29 KiB
JavaScript
Executable File
613 lines
29 KiB
JavaScript
Executable File
(function (root) {
|
|
"use strict";
|
|
|
|
const Data = root.ArchiveWorldData;
|
|
const Systems = root.ArchiveWorldSystems;
|
|
|
|
function createSceneClasses(controller) {
|
|
class BootScene extends Phaser.Scene {
|
|
constructor() { super("ArchiveBoot"); }
|
|
|
|
preload() {
|
|
const base = "/assets/images/play/archive-world/v2/";
|
|
this.load.image("town", `${base}archive-town.webp`);
|
|
this.load.spritesheet("areas", `${base}areas.webp`, { frameWidth: 724, frameHeight: 543 });
|
|
["brass", "moss", "berry"].forEach((palette) => {
|
|
this.load.spritesheet(`traveler-${palette}`, `${base}traveler-${palette}.png`, { frameWidth: 256, frameHeight: 256 });
|
|
});
|
|
this.load.spritesheet("cast", `${base}cast-atlas.png`, { frameWidth: 313, frameHeight: 313 });
|
|
this.load.spritesheet("items", `${base}item-atlas.png`, { frameWidth: 313, frameHeight: 313 });
|
|
|
|
const audio = "/assets/audio/archive-world/v2/";
|
|
[
|
|
["ambient-v2", "town-ambient.ogg"], ["vault-ambient", "vault-ambient.ogg"], ["step-v2", "step.wav"],
|
|
["attack", "attack.wav"], ["damage", "damage.wav"], ["enemy-defeat", "enemy-defeat.wav"],
|
|
["item", "item.wav"], ["quest", "quest.wav"], ["dialogue", "dialogue.wav"], ["puzzle", "puzzle.wav"],
|
|
["boss-attack", "boss-attack.wav"], ["victory", "victory.wav"], ["portal-v2", "portal.wav"]
|
|
].forEach(([key, file]) => this.load.audio(key, `${audio}${file}`));
|
|
}
|
|
|
|
create() {
|
|
this.scene.start("ArchiveWorld", { area: controller.getState().position.area || "town" });
|
|
}
|
|
}
|
|
|
|
class WorldScene extends Phaser.Scene {
|
|
constructor() {
|
|
super("ArchiveWorld");
|
|
this.areaId = "town";
|
|
this.nearest = null;
|
|
this.lastFacing = "north";
|
|
this.lastStepAt = 0;
|
|
this.lastAttackAt = 0;
|
|
this.lastHitAt = 0;
|
|
this.enemySerial = 0;
|
|
this.bossPuzzle = [];
|
|
this.bossPuzzleSolved = false;
|
|
this.controlLockedUntil = 0;
|
|
this.stepDistance = 0;
|
|
this.previousPlayerPosition = null;
|
|
}
|
|
|
|
init(data) {
|
|
this.areaId = Data.AREAS[data && data.area] ? data.area : "town";
|
|
}
|
|
|
|
create() {
|
|
controller.scene = this;
|
|
const area = Data.AREAS[this.areaId];
|
|
this.area = area;
|
|
this.physics.world.setBounds(0, 0, area.width, area.height);
|
|
this.cameras.main.setBackgroundColor("#0b1518");
|
|
if (area.background === "town") this.add.image(0, 0, "town").setOrigin(0).setDepth(0);
|
|
else this.add.image(0, 0, "areas", area.frame).setOrigin(0).setDepth(0);
|
|
|
|
this.collisions = this.physics.add.staticGroup();
|
|
const debug = new URLSearchParams(window.location.search).get("collisionDebug") === "1"
|
|
&& ["localhost", "127.0.0.1"].includes(window.location.hostname);
|
|
area.collisions.forEach((item) => {
|
|
const block = this.add.rectangle(item.x + item.width / 2, item.y + item.height / 2, item.width, item.height, 0xff3b5c, debug ? 0.25 : 0)
|
|
.setDepth(debug ? 50 : item.depth || 2);
|
|
this.physics.add.existing(block, true);
|
|
this.collisions.add(block);
|
|
});
|
|
this.gateBlocks = [];
|
|
(area.gates || []).forEach((gate) => {
|
|
if (controller.getState().sigils.includes(gate.requiresSigil)) return;
|
|
const block = this.add.rectangle(
|
|
gate.x + gate.width / 2, gate.y + gate.height / 2,
|
|
gate.width, gate.height, 0xf4c96b, debug ? 0.2 : 0
|
|
).setDepth(debug ? 51 : 2);
|
|
this.physics.add.existing(block, true);
|
|
this.collisions.add(block);
|
|
this.gateBlocks.push({ gate, block });
|
|
});
|
|
|
|
this.createAnimations();
|
|
this.npcSprites = [];
|
|
const state = controller.getState();
|
|
const safe = Systems.nearestSafeSpawn(this.areaId,
|
|
state.position.area === this.areaId ? state.position.x : area.spawn.x,
|
|
state.position.area === this.areaId ? state.position.y : area.spawn.y);
|
|
this.lastFacing = state.position.facing || "north";
|
|
this.player = this.physics.add.sprite(safe.x, safe.y, `traveler-${state.palette}`, frameFor(this.lastFacing))
|
|
.setScale(0.3).setDepth(20);
|
|
this.player.body.setSize(72, 42).setOffset(92, 190);
|
|
this.player.setCollideWorldBounds(true).setDrag(0, 0).setMaxVelocity(230, 230);
|
|
this.previousPlayerPosition = { x: this.player.x, y: this.player.y };
|
|
this.physics.add.collider(this.player, this.collisions);
|
|
|
|
this.interactables = [];
|
|
this.markers = [];
|
|
this.createWorldObjects();
|
|
this.createEnemies();
|
|
this.createInput();
|
|
|
|
this.projectiles = this.physics.add.group();
|
|
this.physics.add.collider(this.projectiles, this.collisions, (projectile) => projectile.destroy());
|
|
this.physics.add.overlap(this.player, this.projectiles, (_player, projectile) => {
|
|
projectile.destroy();
|
|
this.damagePlayer(projectile.getData("damage") || 10, projectile.x, projectile.y);
|
|
});
|
|
this.physics.add.overlap(this.player, this.enemies, (_player, enemy) => {
|
|
this.damagePlayer(enemy.getData("spec").damage, enemy.x, enemy.y);
|
|
});
|
|
|
|
this.cameras.main.setBounds(0, 0, area.width, area.height);
|
|
this.cameras.main.setZoom(this.areaId === "town" ? 1 : 1.32);
|
|
this.cameras.main.startFollow(this.player, true,
|
|
controller.reducedMotion() ? 1 : 0.24, controller.reducedMotion() ? 1 : 0.24);
|
|
this.cameras.main.setDeadzone(72, 48);
|
|
controller.audio.attach(this, this.areaId);
|
|
controller.renderHud();
|
|
controller.status(this.areaId === "town"
|
|
? "Archive Town is listening. Speak with Gatekeeper Orin or explore any landmark."
|
|
: `${pretty(this.areaId)} entered. The doorway behind you returns to Archive Town.`);
|
|
this.persistPosition(true);
|
|
}
|
|
|
|
createAnimations() {
|
|
const palette = controller.getState().palette;
|
|
const texture = `traveler-${palette}`;
|
|
["south", "west", "east", "north"].forEach((face, row) => {
|
|
const walkKey = `${texture}-walk-${face}`;
|
|
if (!this.anims.exists(walkKey)) {
|
|
this.anims.create({
|
|
key: walkKey,
|
|
frames: this.anims.generateFrameNumbers(texture, { start: row * 6, end: row * 6 + 5 }),
|
|
frameRate: controller.reducedMotion() ? 6 : 9,
|
|
repeat: -1
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
createInput() {
|
|
this.cursors = this.input.keyboard.createCursorKeys();
|
|
this.keys = this.input.keyboard.addKeys({
|
|
up: Phaser.Input.Keyboard.KeyCodes.W, down: Phaser.Input.Keyboard.KeyCodes.S,
|
|
left: Phaser.Input.Keyboard.KeyCodes.A, right: Phaser.Input.Keyboard.KeyCodes.D,
|
|
interact: Phaser.Input.Keyboard.KeyCodes.E, enter: Phaser.Input.Keyboard.KeyCodes.ENTER,
|
|
use: Phaser.Input.Keyboard.KeyCodes.Q, menu: Phaser.Input.Keyboard.KeyCodes.ESC,
|
|
cycle: Phaser.Input.Keyboard.KeyCodes.TAB
|
|
});
|
|
}
|
|
|
|
createWorldObjects() {
|
|
if (this.areaId === "town") {
|
|
Object.values(Data.LANDMARKS).forEach((place) => {
|
|
const marker = this.add.circle(place.x, place.y, 24, 0xf1c46f, 0.18)
|
|
.setStrokeStyle(3, 0xffedaa, 0.9).setDepth(6);
|
|
marker.setData("interaction", { type: "landmark", id: place.id, x: place.x, y: place.y });
|
|
this.markers.push(marker);
|
|
this.interactables.push(marker.getData("interaction"));
|
|
if (!controller.reducedMotion()) this.tweens.add({ targets: marker, alpha: 0.45, scale: 1.14, yoyo: true, repeat: -1, duration: 1100 });
|
|
});
|
|
this.area.npcs.forEach(([id, x, y]) => {
|
|
const npc = this.add.sprite(x, y, "cast", Data.NPCS[id].frame).setScale(0.23).setDepth(y + 60);
|
|
npc.setData("interaction", { type: "npc", id, x, y });
|
|
this.npcSprites.push(npc);
|
|
this.interactables.push(npc.getData("interaction"));
|
|
if (!controller.reducedMotion()) this.tweens.add({ targets: npc, y: y - 2, duration: 1200 + Data.NPCS[id].frame * 30, yoyo: true, repeat: -1 });
|
|
});
|
|
this.addChest("town-garden", 205, 565, "memory_fragment", 2);
|
|
this.addChest("town-wall", 1235, 840, "ink_vial", 2);
|
|
} else {
|
|
this.addPortal("town", this.area.width / 2, this.area.height - 38, "Return to Archive Town");
|
|
if (this.areaId === "vault") {
|
|
this.addPortal("boss", this.area.width / 2, 120, controller.getState().story.ending ? "Visit the restored core" : "Confront The Redactor");
|
|
this.addChest("vault-lore", 100, 420, "lore_page", 2);
|
|
}
|
|
if (this.areaId === "playroom") {
|
|
this.interactables.push({ type: "challenge", id: "playroom", x: this.area.width / 2, y: 255 });
|
|
this.add.circle(this.area.width / 2, 255, 22, 0xf8d879, 0.45).setDepth(5);
|
|
}
|
|
if (this.areaId === "boss") this.createBossPedestals();
|
|
}
|
|
}
|
|
|
|
addPortal(area, x, y, label) {
|
|
const portal = this.add.circle(x, y, 28, 0x87d4c0, 0.24).setStrokeStyle(3, 0xbff8e9, 0.8).setDepth(5);
|
|
const interaction = { type: "portal", id: area, x, y, label };
|
|
portal.setData("interaction", interaction);
|
|
this.interactables.push(interaction);
|
|
}
|
|
|
|
addChest(id, x, y, item, quantity) {
|
|
if (controller.getState().openedChests.includes(id)) return;
|
|
const chest = this.add.sprite(x, y, "items", 11).setScale(0.2).setDepth(y + 10);
|
|
const interaction = { type: "chest", id, x, y, item, quantity };
|
|
chest.setData("interaction", interaction);
|
|
chest.setData("sprite", chest);
|
|
this.interactables.push(interaction);
|
|
}
|
|
|
|
createBossPedestals() {
|
|
const labels = ["self", "neighbour", "town", "archive"];
|
|
const positions = [[220, 180], [505, 180], [505, 350], [220, 350]];
|
|
labels.forEach((value, index) => {
|
|
const [x, y] = positions[index];
|
|
const pedestal = this.add.circle(x, y, 20, 0x34506b, 0.7).setStrokeStyle(3, 0xe3c16f).setDepth(4);
|
|
this.interactables.push({ type: "boss-memory", id: value, x, y, sprite: pedestal });
|
|
});
|
|
}
|
|
|
|
createEnemies() {
|
|
this.enemies = this.physics.add.group();
|
|
this.physics.add.collider(this.enemies, this.collisions);
|
|
this.physics.add.collider(this.enemies, this.enemies);
|
|
this.area.enemies.forEach((entry) => {
|
|
const spawn = Array.isArray(entry)
|
|
? { type: entry[0], x: entry[1], y: entry[2] }
|
|
: entry;
|
|
const { type, x, y } = spawn;
|
|
if ((type === "sentinel" || type === "redactor") && controller.getState().defeatedBosses.includes(type)) return;
|
|
this.spawnEnemy(type, x, y, spawn);
|
|
});
|
|
}
|
|
|
|
spawnEnemy(type, x, y, spawn) {
|
|
const spec = Data.ENEMIES[type];
|
|
const enemy = this.physics.add.sprite(x, y, "cast", spec.frame).setScale(spec.boss ? 0.31 : 0.21).setDepth(y + 30);
|
|
enemy.body.setSize(spec.boss ? 155 : 115, spec.boss ? 85 : 65).setOffset(spec.boss ? 79 : 99, spec.boss ? 196 : 200);
|
|
enemy.setData({
|
|
id: `${type}-${++this.enemySerial}`, type, spec, health: spec.health,
|
|
originX: x, originY: y, leash: spawn && spawn.leash || 150,
|
|
engage: spawn && spawn.engage || 245, requiresSigil: spawn && spawn.requiresSigil || null,
|
|
questId: spawn && spawn.questId || null, nextAction: this.time.now + 900, phase: 1
|
|
});
|
|
this.enemies.add(enemy);
|
|
return enemy;
|
|
}
|
|
|
|
update(time, delta) {
|
|
if (!this.player) return;
|
|
if (controller.locked()) {
|
|
this.player.setVelocity(0);
|
|
this.player.anims.stop();
|
|
return;
|
|
}
|
|
const traveled = Math.hypot(
|
|
this.player.x - this.previousPlayerPosition.x,
|
|
this.player.y - this.previousPlayerPosition.y
|
|
);
|
|
this.previousPlayerPosition = { x: this.player.x, y: this.player.y };
|
|
let dx = 0;
|
|
let dy = 0;
|
|
if (this.cursors.left.isDown || this.keys.left.isDown || controller.move.left) dx -= 1;
|
|
if (this.cursors.right.isDown || this.keys.right.isDown || controller.move.right) dx += 1;
|
|
if (this.cursors.up.isDown || this.keys.up.isDown || controller.move.up) dy -= 1;
|
|
if (this.cursors.down.isDown || this.keys.down.isDown || controller.move.down) dy += 1;
|
|
const moving = dx !== 0 || dy !== 0;
|
|
const state = controller.getState();
|
|
this.gateBlocks = this.gateBlocks.filter((entry) => {
|
|
if (!state.sigils.includes(entry.gate.requiresSigil)) return true;
|
|
entry.block.destroy();
|
|
controller.status("The Town Gate opens. Archive Town is ready to be explored.");
|
|
return false;
|
|
});
|
|
const acceptingMovement = time >= this.controlLockedUntil;
|
|
if (acceptingMovement) {
|
|
const velocity = Systems.approachVelocity(
|
|
this.player.body.velocity.x, this.player.body.velocity.y,
|
|
dx, dy, this.area.movement, delta, state.equipment.boots
|
|
);
|
|
this.player.setVelocity(velocity.x, velocity.y);
|
|
}
|
|
if (moving && acceptingMovement) {
|
|
this.lastFacing = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? "west" : "east") : (dy < 0 ? "north" : "south");
|
|
this.player.anims.play(`traveler-${state.palette}-walk-${this.lastFacing}`, true);
|
|
this.stepDistance += traveled;
|
|
if (state.settings.soundEnabled && this.stepDistance >= 44) {
|
|
controller.audio.play("step", { rate: 0.96 + Math.random() * 0.08 });
|
|
this.lastStepAt = time;
|
|
this.stepDistance = 0;
|
|
}
|
|
} else {
|
|
this.player.anims.stop();
|
|
this.player.setFrame(frameFor(this.lastFacing));
|
|
if (!moving) this.stepDistance = 0;
|
|
}
|
|
this.player.setDepth(this.player.y + 60);
|
|
this.npcSprites.forEach((npc) => npc.setFlipX(this.player.x < npc.x));
|
|
this.updateEnemies(time, delta);
|
|
this.updateNearest();
|
|
this.handleKeys(time);
|
|
if (moving && time - controller.lastSaveAt > 700) {
|
|
controller.lastSaveAt = time;
|
|
this.persistPosition(false);
|
|
}
|
|
}
|
|
|
|
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.use)) controller.useSelectedItem();
|
|
if (Phaser.Input.Keyboard.JustDown(this.keys.cycle)) {
|
|
this.keys.cycle.reset();
|
|
controller.cycleItem();
|
|
}
|
|
if (Phaser.Input.Keyboard.JustDown(this.keys.menu)) controller.openMenu();
|
|
}
|
|
|
|
updateNearest() {
|
|
let nearest = null;
|
|
let best = Infinity;
|
|
this.interactables.forEach((item) => {
|
|
const distance = Phaser.Math.Distance.Between(this.player.x, this.player.y, item.x, item.y);
|
|
if (distance < best) { best = distance; nearest = item; }
|
|
});
|
|
this.nearest = best < 92 ? nearest : null;
|
|
controller.setPrompt(this.nearest ? promptFor(this.nearest) : "");
|
|
}
|
|
|
|
interact() {
|
|
if (controller.locked()) return;
|
|
const item = this.nearest;
|
|
if (!item) return controller.status("Nothing nearby answers the Living Bookmark.");
|
|
if (item.type === "landmark") controller.openLandmark(item.id);
|
|
else if (item.type === "npc") controller.openNpc(item.id);
|
|
else if (item.type === "portal") {
|
|
if (item.id === "boss" && controller.getState().sigils.length < 8) {
|
|
controller.status("All eight Archive Sigils are needed to reveal the vault core.");
|
|
} else this.travel(item.id);
|
|
} else if (item.type === "challenge") controller.openChallenge(item.id);
|
|
else if (item.type === "chest") this.openChest(item);
|
|
else if (item.type === "boss-memory") this.activateBossMemory(item);
|
|
}
|
|
|
|
openChest(item) {
|
|
let state = controller.getState();
|
|
if (state.openedChests.includes(item.id)) return;
|
|
state.openedChests.push(item.id);
|
|
state = Systems.addItem(state, item.item, item.quantity);
|
|
controller.setState(state, `${Data.ITEMS[item.item].name} collected.`);
|
|
controller.audio.play("item");
|
|
this.interactables = this.interactables.filter((entry) => entry !== item);
|
|
const sprite = this.children.list.find((child) => child.getData && child.getData("interaction") === item);
|
|
if (sprite) sprite.destroy();
|
|
}
|
|
|
|
travel(area) {
|
|
controller.audio.play("portal");
|
|
this.persistPosition(false);
|
|
controller.travel(area);
|
|
}
|
|
|
|
attack(time) {
|
|
if (time - this.lastAttackAt < 330 || controller.locked()) return;
|
|
this.lastAttackAt = time;
|
|
controller.audio.play("attack");
|
|
const direction = faceVector(this.lastFacing);
|
|
const range = controller.getState().attack > 1 ? 74 : 62;
|
|
const hit = this.add.rectangle(this.player.x + direction.x * 48, this.player.y + direction.y * 38, range, range, 0xf8df83, 0.32).setDepth(100);
|
|
this.physics.add.existing(hit);
|
|
const struck = new Set();
|
|
this.physics.overlap(hit, this.enemies, (_zone, enemy) => {
|
|
if (struck.has(enemy)) return;
|
|
struck.add(enemy);
|
|
this.hitEnemy(enemy, controller.getState().attack, direction);
|
|
});
|
|
this.time.delayedCall(controller.reducedMotion() ? 80 : 130, () => hit.destroy());
|
|
}
|
|
|
|
hitEnemy(enemy, damage, direction) {
|
|
if (!enemy.active) return;
|
|
if (enemy.getData("type") === "redactor" && enemy.getData("phase") >= 3 && !this.bossPuzzleSolved) {
|
|
controller.status("The Redactor's weak point is hidden. Restore the four memory pedestals.");
|
|
return;
|
|
}
|
|
enemy.setData("health", enemy.getData("health") - damage);
|
|
enemy.setVelocity(direction.x * 170, direction.y * 170);
|
|
enemy.setTint(0xffd6d6);
|
|
this.time.delayedCall(90, () => { if (enemy.active) enemy.clearTint(); });
|
|
if (enemy.getData("health") <= 0) this.defeatEnemy(enemy);
|
|
this.updateBossHud(enemy);
|
|
}
|
|
|
|
defeatEnemy(enemy) {
|
|
const type = enemy.getData("type");
|
|
const spec = enemy.getData("spec");
|
|
const x = enemy.x;
|
|
const y = enemy.y;
|
|
enemy.destroy();
|
|
controller.audio.play(type === "redactor" ? "victory" : "defeat");
|
|
let state = Systems.grantXp(controller.getState(), spec.xp);
|
|
if (type === "sentinel" || type === "redactor") state = Systems.recordBoss(state, type);
|
|
if (enemy.getData("questId") === "study" && state.quests.study.status === "active") {
|
|
state = Systems.progressQuest(state, "study", 1);
|
|
if (state.quests.study.count >= Data.QUESTS.study.target) state = Systems.completeQuest(state, "study");
|
|
}
|
|
if (Math.random() < 0.55 && !spec.boss) state = Systems.addItem(state, "ink_vial", 1);
|
|
controller.setState(state, `${spec.name} restored into harmless ink. +${spec.xp} Memory.`);
|
|
this.add.circle(x, y, 10, 0xbde4d6, 0.7).setDepth(30);
|
|
if (type === "redactor") {
|
|
controller.hideBoss();
|
|
this.time.delayedCall(500, () => controller.openEnding());
|
|
}
|
|
}
|
|
|
|
updateEnemies(time) {
|
|
this.enemies.getChildren().forEach((enemy) => {
|
|
if (!enemy.active) return;
|
|
const spec = enemy.getData("spec");
|
|
const required = enemy.getData("requiresSigil");
|
|
const available = !required || controller.getState().sigils.includes(required);
|
|
enemy.setVisible(available);
|
|
enemy.body.enable = available;
|
|
if (!available) return;
|
|
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("originX"), enemy.getData("originY")
|
|
);
|
|
const playerInSafeZone = Systems.isCombatSafe(this.areaId, this.player.x, this.player.y);
|
|
if (playerInSafeZone || homeDistance > enemy.getData("leash")) {
|
|
if (homeDistance > 8) {
|
|
this.physics.moveTo(enemy, enemy.getData("originX"), enemy.getData("originY"), spec.speed);
|
|
} else {
|
|
enemy.setPosition(enemy.getData("originX"), enemy.getData("originY")).setVelocity(0);
|
|
}
|
|
enemy.setDepth(enemy.y + 50);
|
|
return;
|
|
}
|
|
if (spec.behaviour === "chase") {
|
|
if (distance < enemy.getData("engage")) this.physics.moveToObject(enemy, this.player, spec.speed);
|
|
else this.returnEnemyHome(enemy, spec.speed);
|
|
} else if (spec.behaviour === "wander") {
|
|
if (distance < enemy.getData("engage") && time > enemy.getData("nextAction")) {
|
|
enemy.setData("nextAction", time + 650 + Math.random() * 800);
|
|
const angle = Math.random() * Math.PI * 2;
|
|
enemy.setVelocity(Math.cos(angle) * spec.speed, Math.sin(angle) * spec.speed);
|
|
} else if (distance >= enemy.getData("engage")) this.returnEnemyHome(enemy, spec.speed);
|
|
} else if (spec.behaviour === "ranged") {
|
|
if (distance < enemy.getData("engage") && time > enemy.getData("nextAction")) {
|
|
enemy.setData("nextAction", time + (controller.getState().settings.assist ? 1900 : 1350));
|
|
this.fireProjectile(enemy, spec.damage);
|
|
} else if (distance < 170) this.physics.moveToObject(enemy, this.player, -spec.speed);
|
|
else this.returnEnemyHome(enemy, spec.speed);
|
|
} else if (spec.behaviour === "charge") {
|
|
if (time > enemy.getData("nextAction")) {
|
|
enemy.setTint(0xffd36e);
|
|
enemy.setVelocity(0);
|
|
enemy.setData("nextAction", time + 1800);
|
|
this.time.delayedCall(controller.getState().settings.assist ? 850 : 550, () => {
|
|
if (!enemy.active) return;
|
|
enemy.clearTint();
|
|
this.physics.moveToObject(enemy, this.player, spec.speed * 2.1);
|
|
controller.audio.play("boss");
|
|
});
|
|
}
|
|
} else if (spec.behaviour === "redactor") {
|
|
this.updateRedactor(enemy, time, distance);
|
|
}
|
|
enemy.setDepth(enemy.y + 50);
|
|
});
|
|
}
|
|
|
|
returnEnemyHome(enemy, speed) {
|
|
const distance = Phaser.Math.Distance.Between(
|
|
enemy.x, enemy.y, enemy.getData("originX"), enemy.getData("originY")
|
|
);
|
|
if (distance > 8) this.physics.moveTo(enemy, enemy.getData("originX"), enemy.getData("originY"), speed);
|
|
else enemy.setVelocity(0);
|
|
}
|
|
|
|
updateRedactor(enemy, time, distance) {
|
|
const health = enemy.getData("health");
|
|
const phase = health > 20 ? 1 : health > 10 ? 2 : 3;
|
|
if (phase !== enemy.getData("phase")) {
|
|
enemy.setData("phase", phase);
|
|
if (phase === 2) {
|
|
controller.status("The Redactor tears open lost footnotes. Keep to the clear floor.");
|
|
this.spawnEnemy("ink_blot", 150, 250);
|
|
this.spawnEnemy("lost_footnote", 575, 250);
|
|
} else {
|
|
controller.status("Final phase: activate Self, Neighbour, Town, then Archive to reveal the weak point.");
|
|
enemy.setFrame(15).setTint(0x9e7cc1);
|
|
}
|
|
}
|
|
if (time > enemy.getData("nextAction")) {
|
|
enemy.setData("nextAction", time + (controller.getState().settings.assist ? 2100 : 1450));
|
|
if (phase === 1) this.fireProjectile(enemy, 14);
|
|
else if (phase === 2) {
|
|
this.fireRadial(enemy, 8, 12);
|
|
controller.audio.play("boss");
|
|
} else if (!this.bossPuzzleSolved) {
|
|
this.fireRadial(enemy, 5, 10);
|
|
} else if (distance < 320) this.physics.moveToObject(enemy, this.player, 65);
|
|
}
|
|
}
|
|
|
|
fireProjectile(enemy, damage) {
|
|
enemy.setTint(0xead58a);
|
|
this.time.delayedCall(controller.getState().settings.assist ? 650 : 380, () => {
|
|
if (!enemy.active) return;
|
|
enemy.clearTint();
|
|
const shot = this.add.circle(enemy.x, enemy.y, 9, 0x16121d, 0.95).setStrokeStyle(2, 0xf0bfd0).setDepth(40);
|
|
this.physics.add.existing(shot);
|
|
shot.setData("damage", damage);
|
|
this.projectiles.add(shot);
|
|
this.physics.moveToObject(shot, this.player, 175);
|
|
this.time.delayedCall(3600, () => { if (shot.active) shot.destroy(); });
|
|
controller.audio.play("boss", { volume: 0.6 });
|
|
});
|
|
}
|
|
|
|
fireRadial(enemy, count, damage) {
|
|
for (let index = 0; index < count; index += 1) {
|
|
const angle = (Math.PI * 2 * index) / count;
|
|
const shot = this.add.circle(enemy.x, enemy.y, 8, 0x151018, 0.95).setStrokeStyle(2, 0xd991aa).setDepth(40);
|
|
this.physics.add.existing(shot);
|
|
shot.setData("damage", damage);
|
|
shot.body.setVelocity(Math.cos(angle) * 135, Math.sin(angle) * 135);
|
|
this.projectiles.add(shot);
|
|
this.time.delayedCall(4000, () => { if (shot.active) shot.destroy(); });
|
|
}
|
|
}
|
|
|
|
activateBossMemory(item) {
|
|
if (this.bossPuzzleSolved) return;
|
|
const order = ["self", "neighbour", "town", "archive"];
|
|
this.bossPuzzle.push(item.id);
|
|
const valid = this.bossPuzzle.every((value, index) => value === order[index]);
|
|
if (!valid) {
|
|
this.bossPuzzle = [];
|
|
controller.status("The memory chain breaks, but the sigils hold. Begin with Self.");
|
|
return;
|
|
}
|
|
item.sprite.setFillStyle(0xf3d67d, 0.9);
|
|
if (this.bossPuzzle.length === order.length) {
|
|
this.bossPuzzleSolved = true;
|
|
controller.status("Context restored: The Redactor's weak point is revealed.");
|
|
controller.audio.play("puzzle");
|
|
const boss = this.enemies.getChildren().find((enemy) => enemy.getData("type") === "redactor");
|
|
if (boss) boss.clearTint().setFrame(15);
|
|
} else controller.status(`${pretty(item.id)} restored. ${this.bossPuzzle.length} / 4 memories connected.`);
|
|
}
|
|
|
|
damagePlayer(amount, fromX, fromY) {
|
|
if (Systems.isCombatSafe(this.areaId, this.player.x, this.player.y)) return;
|
|
const result = Systems.takeDamage(controller.getState(), amount, this.time.now, this.lastHitAt);
|
|
if (!result.hit) return;
|
|
this.lastHitAt = this.time.now;
|
|
controller.setState(result.state, `The ink struck for ${Math.round(amount * (result.state.settings.assist ? 0.5 : 1))} damage.`);
|
|
controller.audio.play("damage");
|
|
const direction = Systems.normalizedVector(this.player.x - fromX, this.player.y - fromY, 220);
|
|
this.player.setVelocity(direction.x, direction.y).setTint(0xffb2b2);
|
|
this.controlLockedUntil = this.time.now + 180;
|
|
this.time.delayedCall(160, () => { if (this.player.active) this.player.clearTint(); });
|
|
if (!controller.reducedMotion()) this.cameras.main.shake(90, 0.003);
|
|
if (result.defeated) {
|
|
this.player.setVelocity(0);
|
|
controller.openDefeat();
|
|
}
|
|
}
|
|
|
|
updateBossHud(enemy) {
|
|
if (!enemy || !enemy.getData("spec").boss) return;
|
|
controller.showBoss(enemy.getData("spec").name, enemy.getData("health"), enemy.getData("spec").health);
|
|
}
|
|
|
|
persistPosition(checkpoint) {
|
|
if (!this.player) return;
|
|
let state = StateWithSafe(controller.getState(), this.areaId, this.player.x, this.player.y, this.lastFacing);
|
|
if (checkpoint || Systems.isCombatSafe(this.areaId, this.player.x, this.player.y)) {
|
|
const safe = Systems.nearestNamedSafeSpawn(this.areaId, this.player.x, this.player.y);
|
|
state = root.ArchiveWorldState.withCheckpoint(state, safe.area, safe.spawn, safe.x, safe.y);
|
|
}
|
|
controller.setState(state, null, true);
|
|
}
|
|
}
|
|
|
|
return [BootScene, WorldScene];
|
|
}
|
|
|
|
function StateWithSafe(state, area, x, y, facing) {
|
|
const safe = Systems.nearestSafeSpawn(area, x, y);
|
|
return root.ArchiveWorldState.withPosition(state, safe.area, safe.x, safe.y, facing, safe.spawn);
|
|
}
|
|
|
|
function frameFor(face) {
|
|
return { south: 0, west: 6, east: 12, north: 18 }[face] || 18;
|
|
}
|
|
|
|
function faceVector(face) {
|
|
return { north: { x: 0, y: -1 }, south: { x: 0, y: 1 }, west: { x: -1, y: 0 }, east: { x: 1, y: 0 } }[face];
|
|
}
|
|
|
|
function promptFor(item) {
|
|
if (item.type === "portal") return `${item.label}. Press E or Explore.`;
|
|
if (item.type === "chest") return "A sealed archive chest is nearby. Press E or Explore.";
|
|
if (item.type === "boss-memory") return `${pretty(item.id)} pedestal. Press E or Explore.`;
|
|
if (item.type === "challenge") return "The lantern console is nearby. Press E or Explore.";
|
|
const title = item.type === "npc" ? Data.NPCS[item.id].name : Data.LANDMARKS[item.id].title;
|
|
return `${title} is nearby. Press E or Explore.`;
|
|
}
|
|
|
|
function pretty(value) {
|
|
return String(value).replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
}
|
|
|
|
root.ArchiveWorldScenes = Object.freeze({ createSceneClasses });
|
|
}(typeof globalThis !== "undefined" ? globalThis : this));
|