Files
org_web/assets/scripts/pages/archive-world-scenes.js
gitea-actions c06af38d80
All checks were successful
Build Org Website / build (push) Successful in 39s
Harden archive-world setup and refresh site links
2026-07-30 09:08:12 +01:00

547 lines
26 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;
}
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);
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.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(900, 900).setMaxVelocity(190, 190);
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.startFollow(this.player, true,
controller.reducedMotion() ? 1 : 0.14, controller.reducedMotion() ? 1 : 0.14);
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", 115, 550, "memory_fragment", 2);
this.addChest("town-wall", 1280, 870, "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, 95, 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: 110 });
this.add.circle(this.area.width / 2, 110, 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.area.enemies.forEach(([type, x, y]) => {
if ((type === "sentinel" || type === "redactor") && controller.getState().defeatedBosses.includes(type)) return;
this.spawnEnemy(type, x, y);
});
}
spawnEnemy(type, x, y) {
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, 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.setAcceleration(0).setVelocity(0);
this.player.anims.stop();
return;
}
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();
const acceleration = state.equipment.boots ? 980 : 760;
if (moving) {
const vector = Systems.normalizedVector(dx, dy, acceleration);
this.player.setAcceleration(vector.x, vector.y);
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);
if (state.settings.soundEnabled && time - this.lastStepAt > 350) {
controller.audio.play("step", { rate: 0.96 + Math.random() * 0.08 });
this.lastStepAt = time;
}
} else {
this.player.setAcceleration(0);
this.player.anims.stop();
this.player.setFrame(frameFor(this.lastFacing));
}
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 (state.quests.study.status === "active" && this.areaId === "town") {
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 distance = Phaser.Math.Distance.Between(enemy.x, enemy.y, this.player.x, this.player.y);
if (spec.behaviour === "chase") {
if (distance < 280) this.physics.moveToObject(enemy, this.player, spec.speed);
else enemy.setVelocity(0);
} else if (spec.behaviour === "wander") {
if (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 (spec.behaviour === "ranged") {
if (distance < 340 && 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 enemy.setVelocity(0);
} 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);
});
}
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 (this.areaId === "town" && isTownSafe(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.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 || this.areaId === "town") {
const safe = Systems.nearestSafeSpawn(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());
}
function isTownSafe(x, y) {
return y > 835 || (x > 570 && x < 875 && y > 405 && y < 690);
}
root.ArchiveWorldScenes = Object.freeze({ createSceneClasses });
}(typeof globalThis !== "undefined" ? globalThis : this));