423 lines
16 KiB
JavaScript
Executable File
423 lines
16 KiB
JavaScript
Executable File
(function () {
|
||
"use strict";
|
||
|
||
const Data = window.ArchiveWorldData;
|
||
const State = window.ArchiveWorldState;
|
||
const Systems = window.ArchiveWorldSystems;
|
||
const UI = window.ArchiveWorldUI;
|
||
const Scenes = window.ArchiveWorldScenes;
|
||
const Audio = window.ArchiveWorldAudio;
|
||
|
||
const controller = {
|
||
root: null,
|
||
state: null,
|
||
game: null,
|
||
scene: null,
|
||
ui: null,
|
||
audio: null,
|
||
lock: false,
|
||
move: { up: false, down: false, left: false, right: false },
|
||
prompt: "",
|
||
lastSaveAt: 0,
|
||
systemReducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||
getState: () => controller.state,
|
||
locked: () => controller.lock,
|
||
reducedMotion: () => controller.state && controller.state.settings.reducedMotion !== null
|
||
? controller.state.settings.reducedMotion : controller.systemReducedMotion
|
||
};
|
||
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
|
||
function init() {
|
||
const root = document.querySelector('.archive-world[data-play-page="archive-world"]');
|
||
if (!root || !Data || !State || !Systems || !UI || !Scenes || !Audio) return;
|
||
controller.root = root;
|
||
controller.audio = Audio.create(() => controller.state);
|
||
bindSetup();
|
||
bindControls();
|
||
bindSettings();
|
||
controller.ui = UI.create(root, {
|
||
getState: () => controller.state,
|
||
onLock: (locked) => { controller.lock = locked; },
|
||
onStartQuest: startQuest,
|
||
onCompleteQuest: completeQuest,
|
||
onTravel: travel,
|
||
onUseItem: useItem,
|
||
onChoice: completeChoice,
|
||
onRespawn: respawn,
|
||
onFullscreen: toggleFullscreen,
|
||
onReset: resetAll
|
||
});
|
||
|
||
const loaded = loadState();
|
||
if (loaded.state) {
|
||
controller.state = developmentAreaOverride(loaded.state);
|
||
renderHud();
|
||
startGame();
|
||
if (loaded.message) status(loaded.message);
|
||
} else {
|
||
openSetup(loaded.message);
|
||
}
|
||
}
|
||
|
||
function developmentAreaOverride(state) {
|
||
const local = ["localhost", "127.0.0.1"].includes(window.location.hostname);
|
||
const params = new URLSearchParams(window.location.search);
|
||
const areaId = params.get("collisionDebug") === "1" ? params.get("area") : null;
|
||
if (!local || !Data.AREAS[areaId]) return state;
|
||
const spawn = Data.AREAS[areaId].spawn;
|
||
return State.withPosition(state, areaId, spawn.x, spawn.y, "north", "entrance");
|
||
}
|
||
|
||
function loadState() {
|
||
try {
|
||
const rawV2 = localStorage.getItem(State.STORAGE_KEY);
|
||
if (rawV2) {
|
||
const parsed = State.parse(rawV2);
|
||
return parsed
|
||
? { state: ensureSafePosition(parsed), message: null }
|
||
: { state: null, message: "The saved Archive World data was malformed, so a clean traveler setup is ready." };
|
||
}
|
||
const rawV1 = localStorage.getItem(State.LEGACY_KEY);
|
||
if (rawV1) {
|
||
const migrated = State.parse(rawV1);
|
||
if (migrated) {
|
||
persistState(migrated);
|
||
return { state: ensureSafePosition(migrated), message: "Your original Archive World traveler was safely migrated to the new adventure." };
|
||
}
|
||
return { state: null, message: "The older save could not be restored, so a clean traveler setup is ready." };
|
||
}
|
||
} catch (_error) {
|
||
return { state: null, message: "Local saving is unavailable. You can still play during this visit." };
|
||
}
|
||
return { state: null, message: null };
|
||
}
|
||
|
||
function ensureSafePosition(state) {
|
||
if (state.health <= 0) state = Systems.respawn(state);
|
||
const safe = Systems.nearestSafeSpawn(state.position.area, state.position.x, state.position.y);
|
||
return State.withPosition(state, safe.area, safe.x, safe.y, state.position.facing, safe.spawn);
|
||
}
|
||
|
||
function bindSetup() {
|
||
const dialog = controller.root.querySelector("[data-world-setup]");
|
||
const form = controller.root.querySelector("[data-world-setup-form]");
|
||
if (!dialog || !form) return;
|
||
dialog.addEventListener("cancel", (event) => event.preventDefault());
|
||
form.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const formData = new FormData(form);
|
||
const name = State.validateName(formData.get("name"));
|
||
const palette = State.validatePalette(formData.get("palette"));
|
||
const error = controller.root.querySelector("[data-world-setup-error]");
|
||
if (!name || !palette) {
|
||
error.textContent = "Choose a palette and enter a name between 1 and 20 characters.";
|
||
return;
|
||
}
|
||
controller.state = State.fresh(name, palette);
|
||
persistState(controller.state);
|
||
dialog.close();
|
||
renderHud();
|
||
startGame();
|
||
});
|
||
}
|
||
|
||
function openSetup(message) {
|
||
const dialog = controller.root.querySelector("[data-world-setup]");
|
||
if (!dialog) {
|
||
status(message || "Traveler setup could not open. Reload the page to try again; the plain directory remains available.");
|
||
return;
|
||
}
|
||
if (message) {
|
||
controller.root.querySelector("[data-world-setup-error]").textContent = message;
|
||
status(message);
|
||
}
|
||
window.setTimeout(() => {
|
||
dialog.showModal();
|
||
dialog.querySelector("input[name=name]").focus();
|
||
}, 0);
|
||
}
|
||
|
||
function bindControls() {
|
||
controller.root.querySelectorAll("[data-world-move]").forEach((button) => {
|
||
const direction = button.dataset.worldMove;
|
||
const down = (event) => {
|
||
event.preventDefault();
|
||
controller.move[direction] = true;
|
||
controller.root.dataset.touching = "true";
|
||
};
|
||
const up = (event) => {
|
||
event.preventDefault();
|
||
controller.move[direction] = false;
|
||
controller.root.dataset.touching = "false";
|
||
};
|
||
button.addEventListener("pointerdown", down);
|
||
["pointerup", "pointercancel", "pointerleave"].forEach((name) => button.addEventListener(name, up));
|
||
});
|
||
bindClick("[data-world-interact]", () => {
|
||
if (controller.scene) controller.scene.interact();
|
||
});
|
||
bindClick("[data-world-attack]", () => {
|
||
if (controller.scene) controller.scene.attack(controller.scene.time.now);
|
||
});
|
||
bindClick("[data-world-item]", useSelectedItem);
|
||
bindClick("[data-world-menu]", openMenu);
|
||
bindClick("[data-world-quests]", () => controller.ui.openMenu(controller.state, "quests"));
|
||
bindClick("[data-world-inventory]", () => controller.ui.openMenu(controller.state, "inventory"));
|
||
bindClick("[data-world-journal-open]", () => controller.ui.openMenu(controller.state, "journal"));
|
||
bindClick("[data-world-sound]", toggleSound);
|
||
bindClick("[data-world-fullscreen]", toggleFullscreen);
|
||
bindClick("[data-world-reset]", () => controller.ui.openMenu(controller.state, "settings"));
|
||
|
||
document.addEventListener("fullscreenchange", updateFullscreen);
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.hidden && controller.scene) controller.scene.persistPosition(false);
|
||
});
|
||
window.addEventListener("pagehide", () => {
|
||
if (controller.scene) controller.scene.persistPosition(false);
|
||
});
|
||
if (!document.fullscreenEnabled) controller.root.querySelectorAll("[data-world-fullscreen]").forEach((button) => { button.hidden = true; });
|
||
}
|
||
|
||
function bindSettings() {
|
||
controller.root.addEventListener("change", (event) => {
|
||
if (!controller.state) return;
|
||
if (event.target.matches("[data-setting='assist']")) {
|
||
controller.state.settings.assist = event.target.checked;
|
||
setState(controller.state, event.target.checked ? "Assist mode enabled." : "Assist mode disabled.");
|
||
} else if (event.target.matches("[data-volume]")) {
|
||
controller.state.settings[event.target.dataset.volume] = Number(event.target.value);
|
||
setState(controller.state, null, true);
|
||
controller.audio.apply();
|
||
}
|
||
});
|
||
}
|
||
|
||
function startGame() {
|
||
if (!window.Phaser) {
|
||
status("The world engine could not load. Every destination remains available in the plain directory.");
|
||
return;
|
||
}
|
||
const sceneClasses = Scenes.createSceneClasses(controller);
|
||
controller.game = new Phaser.Game({
|
||
type: Phaser.CANVAS,
|
||
parent: "archive-world-game",
|
||
width: 960,
|
||
height: 640,
|
||
backgroundColor: "#101b18",
|
||
pixelArt: true,
|
||
roundPixels: true,
|
||
physics: { default: "arcade", arcade: { debug: false } },
|
||
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
|
||
scene: sceneClasses,
|
||
input: { keyboard: true, mouse: true, touch: true },
|
||
render: { antialias: false, pixelArt: true, roundPixels: true }
|
||
});
|
||
controller.root.dataset.gameReady = "true";
|
||
controller.root.querySelector("#archive-world-game").focus();
|
||
window.archiveWorldGame = controller.game;
|
||
}
|
||
|
||
function startQuest(id) {
|
||
setState(Systems.startQuest(controller.state, id), `${Data.QUESTS[id].title} added to the quest log.`);
|
||
controller.audio.play("quest");
|
||
}
|
||
|
||
function completeQuest(id) {
|
||
let next = Systems.completeQuest(controller.state, id);
|
||
if (id === "museum" && next.sigils.length >= 4) {
|
||
next.story.midpointSeen = true;
|
||
next = Systems.updateStory(next);
|
||
status("Midpoint revelation: the Redactor was built to preserve the archive, but learned to mistake emptiness for safety.");
|
||
}
|
||
setState(next, `${Data.QUESTS[id].title} complete. The ${Data.LANDMARKS[Data.QUESTS[id].landmark].title} Sigil is restored.`);
|
||
controller.audio.play("quest");
|
||
}
|
||
|
||
function completeChoice(choice) {
|
||
let next = Systems.startQuest(controller.state, "lost_letter");
|
||
next.npcFlags.nellChoice = choice;
|
||
next = Systems.completeQuest(next, "lost_letter");
|
||
setState(next, "Nell remembers that choosing what to preserve is part of the story.");
|
||
controller.audio.play("dialogue");
|
||
}
|
||
|
||
function openLandmark(id) {
|
||
const wasNew = !controller.state.discovered.includes(id);
|
||
setState(Systems.discover(controller.state, id), wasNew ? `${Data.LANDMARKS[id].title} added to the discovery journal.` : null);
|
||
if (wasNew) controller.audio.play("quest", { volume: 0.7 });
|
||
controller.ui.openLandmark(id, controller.state);
|
||
}
|
||
|
||
function openNpc(id) {
|
||
controller.audio.play("dialogue", { volume: 0.45 });
|
||
controller.ui.openNpc(id, controller.state);
|
||
}
|
||
|
||
function openChallenge(id) {
|
||
if (controller.state.quests[id].status === "locked") controller.state = Systems.startQuest(controller.state, id);
|
||
controller.ui.openChallenge(id, controller.state);
|
||
}
|
||
|
||
function openMenu() {
|
||
if (!controller.state || controller.ui.isOpen()) return;
|
||
controller.ui.openMenu(controller.state, "quests");
|
||
}
|
||
|
||
function travel(area) {
|
||
if (!Data.AREAS[area]) return;
|
||
const spawn = Data.AREAS[area].spawn;
|
||
controller.state = State.withPosition(controller.state, area, spawn.x, spawn.y, "north", "entrance");
|
||
controller.state = State.withCheckpoint(controller.state, area, "entrance", spawn.x, spawn.y);
|
||
persistState(controller.state);
|
||
if (controller.scene) controller.scene.scene.restart({ area });
|
||
}
|
||
|
||
function useSelectedItem() {
|
||
useItem(controller.state.selectedItem);
|
||
}
|
||
|
||
function useItem(id) {
|
||
const result = Systems.consumeItem(controller.state, id);
|
||
if (!result.used) {
|
||
status(controller.state.health >= controller.state.maxHealth ? "Health is already full." : "That item cannot be used now.");
|
||
return;
|
||
}
|
||
setState(result.state, `${Data.ITEMS[id].name} restored ${result.amount} health.`);
|
||
controller.audio.play("item");
|
||
}
|
||
|
||
function cycleItem() {
|
||
const consumables = controller.state.inventory.filter((entry) => Data.ITEMS[entry.id].type === "consumable");
|
||
if (!consumables.length) return status("No usable items are in the inventory.");
|
||
const index = consumables.findIndex((entry) => entry.id === controller.state.selectedItem);
|
||
controller.state.selectedItem = consumables[(index + 1) % consumables.length].id;
|
||
setState(controller.state, `${Data.ITEMS[controller.state.selectedItem].name} selected.`);
|
||
}
|
||
|
||
function respawn() {
|
||
const next = Systems.respawn(controller.state);
|
||
controller.state = next;
|
||
persistState(next);
|
||
travel(next.position.area);
|
||
status("The Living Bookmark returns you to a safe place. Story and items were preserved.");
|
||
}
|
||
|
||
function toggleSound() {
|
||
if (!controller.state) return;
|
||
controller.state.settings.soundEnabled = !controller.state.settings.soundEnabled;
|
||
if (controller.state.settings.soundEnabled) controller.audio.unlock();
|
||
controller.audio.apply();
|
||
setState(controller.state, controller.state.settings.soundEnabled ? "Archive World audio enabled." : "Audio muted.");
|
||
}
|
||
|
||
async function toggleFullscreen() {
|
||
if (!document.fullscreenEnabled) return;
|
||
try {
|
||
if (document.fullscreenElement) await document.exitFullscreen();
|
||
else await controller.root.querySelector("[data-world-shell]").requestFullscreen();
|
||
} catch (_error) {
|
||
status("Fullscreen is unavailable in this browser.");
|
||
}
|
||
}
|
||
|
||
function updateFullscreen() {
|
||
const active = Boolean(document.fullscreenElement);
|
||
controller.root.querySelectorAll("[data-world-fullscreen]").forEach((button) => {
|
||
button.textContent = active ? "Exit fullscreen" : "Fullscreen";
|
||
button.setAttribute("aria-pressed", String(active));
|
||
});
|
||
if (controller.game) controller.game.scale.refresh();
|
||
if (!active) controller.root.querySelector("#archive-world-game").focus();
|
||
}
|
||
|
||
function resetAll() {
|
||
try {
|
||
localStorage.removeItem(State.STORAGE_KEY);
|
||
localStorage.removeItem(State.LEGACY_KEY);
|
||
} catch (_error) {
|
||
// Reload still provides a fresh in-memory state.
|
||
}
|
||
window.location.reload();
|
||
}
|
||
|
||
function setState(next, message, quiet) {
|
||
controller.state = State.normalize(next) || controller.state;
|
||
if (!quiet) renderHud();
|
||
persistState(controller.state);
|
||
if (message) status(message);
|
||
}
|
||
|
||
function persistState(state) {
|
||
try {
|
||
localStorage.setItem(State.STORAGE_KEY, JSON.stringify(State.normalize(state)));
|
||
} catch (_error) {
|
||
status("Progress could not be saved on this device.");
|
||
}
|
||
}
|
||
|
||
function renderHud() {
|
||
if (!controller.state) return;
|
||
const state = controller.state;
|
||
text("[data-world-player]", state.name);
|
||
text("[data-world-level]", `Lv ${state.level}`);
|
||
text("[data-world-health-text]", `${Math.ceil(state.health)} / ${state.maxHealth}`);
|
||
const healthBar = controller.root.querySelector("[data-world-health-bar]");
|
||
if (healthBar) healthBar.style.width = `${(state.health / state.maxHealth) * 100}%`;
|
||
text("[data-world-objective]", Systems.currentObjective(state));
|
||
text("[data-world-discovery-count]", `${state.discovered.length} / 8`);
|
||
text("[data-world-sigil-count]", `${state.sigils.length} / 8`);
|
||
text("[data-world-sound]", state.settings.soundEnabled ? "Sound: on" : "Sound: muted");
|
||
const selected = Data.ITEMS[state.selectedItem];
|
||
text("[data-world-selected-item]", selected ? `${selected.name} ×${Systems.itemQuantity(state, state.selectedItem)}` : "None");
|
||
const badge = controller.root.querySelector("[data-world-badge]");
|
||
if (badge) badge.hidden = !state.complete;
|
||
controller.root.classList.toggle("is-restored", state.story.ending);
|
||
}
|
||
|
||
function setPrompt(message) {
|
||
if (message === controller.prompt) return;
|
||
controller.prompt = message;
|
||
text("[data-world-prompt]", message || "Follow the paths, signs, and warm window light.");
|
||
}
|
||
|
||
function status(message) {
|
||
text("[data-world-status]", message);
|
||
}
|
||
|
||
function showBoss(name, health, maximum) {
|
||
const boss = controller.root.querySelector("[data-world-boss]");
|
||
boss.hidden = false;
|
||
text("[data-world-boss-name]", name);
|
||
boss.querySelector("[data-world-boss-bar]").style.width = `${Math.max(0, health / maximum) * 100}%`;
|
||
}
|
||
|
||
function hideBoss() {
|
||
controller.root.querySelector("[data-world-boss]").hidden = true;
|
||
}
|
||
|
||
function openDefeat() {
|
||
controller.lock = true;
|
||
controller.ui.openDefeat();
|
||
}
|
||
|
||
function openEnding() {
|
||
controller.ui.openEnding(controller.state);
|
||
}
|
||
|
||
function text(selector, value) {
|
||
const node = controller.root.querySelector(selector);
|
||
if (node) node.textContent = value;
|
||
}
|
||
|
||
function bindClick(selector, handler) {
|
||
const node = controller.root.querySelector(selector);
|
||
if (node) node.addEventListener("click", handler);
|
||
}
|
||
|
||
Object.assign(controller, {
|
||
setState, renderHud, status, setPrompt, showBoss, hideBoss, openDefeat, openEnding,
|
||
openLandmark, openNpc, openChallenge, openMenu, travel, useSelectedItem, cycleItem
|
||
});
|
||
}());
|