Files
org_web/assets/scripts/pages/archive-world.js
gitea-actions 7afc554f5d
All checks were successful
Build Org Website / build (push) Successful in 44s
Refactor org web content and simplify shared UI
2026-07-29 15:17:17 +01:00

421 lines
16 KiB
JavaScript

(function () {
"use strict";
const State = window.ArchiveWorldState;
const LANDMARKS = Object.freeze({
gate: landmark("Town Gate", "Arrivals", 640, 830,
"The old gate remembers every arrival. Begin at the dashboard or follow the newest paths.",
[["Home", "/"], ["Recently updated", "/recently-updated.html"], ["Contact", "/home/contact.html"]]),
library: landmark("Grand Library", "Knowledge", 260, 285,
"Long-lived explanations, categories, and learning notes fill the blue-roofed library.",
[["Posts", "/posts/posts-list.html"], ["Categories", "/home/categories.html"], ["Posts introduction", "/posts/posts-intro.html"]]),
study: landmark("Guild Study", "Work", 640, 250,
"Engineering notes, professional lessons, and active competencies cover every desk.",
[["Career", "/posts/career/career-list.html"], ["Competency status", "/home/status.html"], ["Probation objectives", "/posts/career/probation-objectives.html"]]),
inn: landmark("Kitchen Inn", "Daily life", 1005, 270,
"Weekly reviews and ordinary days stay warm beside the inn's oven.",
[["Blogs", "/blogs/blogs-list.html"], ["Weekly reviews", "/tags/review.html"], ["Blogs introduction", "/blogs/blogs-intro.html"]]),
workshop: landmark("Workshop", "Living systems", 1030, 535,
"Trackers, services, plans, and practical machinery keep the town moving.",
[["Services", "/home/services.html"], ["Wird tracker", "/home/wird-tracker.html"], ["Countdowns", "/home/countdown.html"], ["Backlog", "/home/backlog.html"]]),
playroom: landmark("Playroom", "Experiments", 1005, 765,
"Games, generators, and stranger little mechanisms glow behind the bright windows.",
[["Play hub", "/play/play.html"], ["The Rain Index", "/play/the-rain-index.html"], ["House of Pages", "/play/house.html"]]),
museum: landmark("Lima Museum", "Keepsakes", 275, 765,
"Personal fragments, older writing, and small memories rest beneath the glass roof.",
[["Lima archive", "/lima/index.html"], ["Older writing", "/blogs/2025/2025-list.html"], ["Memory Cabinet", "/play/memory.html"]]),
garden: landmark("Notes Garden", "Connections", 220, 515,
"Loose notes, paths between topics, and recently tended pages grow beyond the trellis.",
[["Notes wall", "/home/notes.html"], ["Categories", "/home/categories.html"], ["Recently updated", "/recently-updated.html"], ["Sitemap", "/sitemap.html"]])
});
const PALETTE_TINTS = Object.freeze({
brass: 0xf1c46f,
moss: 0x91c788,
berry: 0xd894ad
});
const controller = {
state: null,
scene: null,
lock: false,
move: { up: false, down: false, left: false, right: false },
reducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
saveTimer: 0
};
function landmark(title, eyebrow, x, y, description, links) {
return Object.freeze({ title, eyebrow, x, y, description, links: Object.freeze(links) });
}
document.addEventListener("DOMContentLoaded", init);
function init() {
const root = document.querySelector('.archive-world[data-play-page="archive-world"]');
if (!root || !State) return;
const stored = State.parse(localStorage.getItem(State.STORAGE_KEY));
controller.state = stored;
bindInterface(root);
if (!window.Phaser) {
setStatus(root, "The world engine could not load. Every destination remains available in the plain directory.");
return;
}
if (stored) {
startGame(root);
} else {
openSetup(root);
}
}
function bindInterface(root) {
const setup = root.querySelector("[data-world-setup]");
const setupForm = root.querySelector("[data-world-setup-form]");
const preview = root.querySelector("[data-world-preview]");
const journal = root.querySelector("[data-world-journal]");
setup.addEventListener("cancel", (event) => event.preventDefault());
setupForm.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(setupForm);
const name = State.validateName(data.get("name"));
const palette = State.validatePalette(data.get("palette"));
const error = 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);
persist();
setup.close();
startGame(root);
});
root.querySelector("[data-world-preview-close]").addEventListener("click", () => preview.close());
preview.addEventListener("close", () => {
controller.lock = false;
root.querySelector("#archive-world-game").focus();
});
root.querySelector("[data-world-journal-open]").addEventListener("click", () => {
renderJournal(root);
controller.lock = true;
journal.showModal();
});
root.querySelector("[data-world-journal-close]").addEventListener("click", () => journal.close());
journal.addEventListener("close", () => {
controller.lock = false;
root.querySelector("[data-world-journal-open]").focus();
});
root.querySelector("[data-world-sound]").addEventListener("click", () => toggleSound(root));
root.querySelector("[data-world-reset]").addEventListener("click", () => {
if (!window.confirm("Reset your traveler, discoveries, and saved position?")) return;
localStorage.removeItem(State.STORAGE_KEY);
window.location.reload();
});
root.querySelectorAll("[data-world-move]").forEach((button) => {
const direction = button.dataset.worldMove;
const down = (event) => {
event.preventDefault();
controller.move[direction] = true;
};
const up = (event) => {
event.preventDefault();
controller.move[direction] = false;
};
button.addEventListener("pointerdown", down);
button.addEventListener("pointerup", up);
button.addEventListener("pointercancel", up);
button.addEventListener("pointerleave", up);
});
root.querySelector("[data-world-interact]").addEventListener("click", () => {
if (controller.scene) controller.scene.interact();
});
window.addEventListener("pagehide", savePosition);
document.addEventListener("visibilitychange", () => {
if (document.hidden) savePosition();
});
}
function openSetup(root) {
const dialog = root.querySelector("[data-world-setup]");
window.setTimeout(() => {
dialog.showModal();
dialog.querySelector("input[name=name]").focus();
}, 0);
}
function startGame(root) {
renderHud(root);
const game = new Phaser.Game({
type: Phaser.AUTO,
parent: "archive-world-game",
width: 960,
height: 640,
backgroundColor: "#17251e",
pixelArt: true,
roundPixels: true,
physics: {
default: "arcade",
arcade: { debug: false }
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
scene: ArchiveTownScene,
input: {
keyboard: true,
mouse: true,
touch: true
}
});
root.dataset.gameReady = "true";
root.querySelector("#archive-world-game").focus();
window.archiveWorldGame = game;
}
class ArchiveTownScene extends Phaser.Scene {
constructor() {
super("ArchiveTown");
this.nearest = null;
this.lastStepAt = 0;
this.wasMoving = false;
}
preload() {
this.load.image("archive-town", "/assets/images/play/archive-world/archive-town.webp");
this.load.image("traveler", "/assets/images/play/archive-world/traveler.png");
this.load.audio("ambient", "/assets/audio/archive-world/ambient.ogg");
this.load.audio("step", "/assets/audio/archive-world/step.wav");
this.load.audio("discover", "/assets/audio/archive-world/discover.wav");
this.load.audio("portal", "/assets/audio/archive-world/portal.wav");
}
create() {
controller.scene = this;
this.add.image(0, 0, "archive-town").setOrigin(0).setDepth(0);
this.physics.world.setBounds(0, 0, 1280, 960);
this.markers = {};
Object.entries(LANDMARKS).forEach(([id, item]) => {
const marker = this.add.circle(item.x, item.y, 22, 0xf4d98a, 0.16)
.setStrokeStyle(3, 0xf8e6a7, 0.9)
.setDepth(2);
marker.setData("landmarkId", id);
this.markers[id] = marker;
if (!controller.reducedMotion) {
this.tweens.add({
targets: marker,
scale: 1.18,
alpha: 0.58,
duration: 900,
yoyo: true,
repeat: -1,
delay: Object.keys(LANDMARKS).indexOf(id) * 90
});
}
});
this.player = this.physics.add.sprite(
controller.state.position.x,
controller.state.position.y,
"traveler"
).setDepth(4).setTint(PALETTE_TINTS[controller.state.palette]);
this.player.setCollideWorldBounds(true);
this.player.body.setSize(28, 34).setOffset(8, 58);
this.cameras.main.setBounds(0, 0, 1280, 960);
this.cameras.main.startFollow(this.player, true, controller.reducedMotion ? 1 : 0.12, controller.reducedMotion ? 1 : 0.12);
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
});
this.ambient = this.sound.add("ambient", { loop: true, volume: 0.24 });
this.stepSound = this.sound.add("step", { volume: 0.22 });
this.discoverSound = this.sound.add("discover", { volume: 0.35 });
this.portalSound = this.sound.add("portal", { volume: 0.3 });
if (controller.state.soundEnabled) this.enableSound();
setStatus(document.querySelector(".archive-world"), `Welcome, ${controller.state.name}. Walk near a glowing landmark and press E, Space, or Enter.`);
this.updateNearest();
}
update(time) {
if (!this.player) return;
let dx = 0;
let dy = 0;
if (!controller.lock) {
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;
if (moving) {
const vector = new Phaser.Math.Vector2(dx, dy).normalize().scale(175);
this.player.setVelocity(vector.x, vector.y);
if (dx) this.player.setFlipX(dx < 0);
if (!controller.reducedMotion) {
this.player.rotation = Math.sin(time / 80) * 0.025;
this.player.setScale(1, 0.98 + Math.abs(Math.sin(time / 95)) * 0.04);
}
if (controller.state.soundEnabled && time - this.lastStepAt > 340) {
this.stepSound.play();
this.lastStepAt = time;
}
if (time - controller.saveTimer > 700) {
controller.saveTimer = time;
savePosition();
}
} else {
this.player.setVelocity(0, 0);
this.player.rotation = 0;
this.player.setScale(1);
}
this.wasMoving = moving;
this.updateNearest();
if (!controller.lock && (
Phaser.Input.Keyboard.JustDown(this.keys.interact) ||
Phaser.Input.Keyboard.JustDown(this.keys.enter) ||
Phaser.Input.Keyboard.JustDown(this.cursors.space)
)) this.interact();
}
updateNearest() {
let nearest = null;
let distance = Infinity;
Object.entries(LANDMARKS).forEach(([id, item]) => {
const nextDistance = Phaser.Math.Distance.Between(this.player.x, this.player.y, item.x, item.y);
if (nextDistance < distance) {
nearest = id;
distance = nextDistance;
}
this.markers[id].setStrokeStyle(nextDistance < 95 ? 5 : 3, nextDistance < 95 ? 0xffffff : 0xf8e6a7, 0.95);
});
const next = distance < 95 ? nearest : null;
if (next !== this.nearest) {
this.nearest = next;
const root = document.querySelector(".archive-world");
setStatus(root, next
? `${LANDMARKS[next].title} is nearby. Press E, Space, Enter, or Explore.`
: "Follow the stone paths toward a glowing landmark.");
}
}
interact() {
if (controller.lock) return;
if (!this.nearest) {
setStatus(document.querySelector(".archive-world"), "Move closer to one of the glowing landmark circles.");
return;
}
openPreview(document.querySelector(".archive-world"), this.nearest);
}
enableSound() {
if (this.sound.locked && this.sound.unlock) this.sound.unlock();
this.sound.mute = false;
if (!this.ambient.isPlaying) this.ambient.play();
}
disableSound() {
this.sound.mute = true;
if (this.ambient.isPlaying) this.ambient.pause();
}
}
function openPreview(root, id) {
const item = LANDMARKS[id];
if (!item) return;
const wasDiscovered = controller.state.discovered.includes(id);
controller.state = State.discover(controller.state, id);
persist();
controller.lock = true;
root.querySelector("[data-world-preview-eyebrow]").textContent = item.eyebrow;
root.querySelector("[data-world-preview-title]").textContent = item.title;
root.querySelector("[data-world-preview-description]").textContent = item.description;
const links = root.querySelector("[data-world-preview-links]");
links.replaceChildren();
item.links.forEach(([label, href]) => {
const anchor = document.createElement("a");
anchor.href = href;
anchor.textContent = label;
anchor.addEventListener("click", () => {
if (controller.scene && controller.state.soundEnabled) controller.scene.portalSound.play();
savePosition();
});
links.appendChild(anchor);
});
renderHud(root);
if (!wasDiscovered && controller.scene && controller.state.soundEnabled) controller.scene.discoverSound.play();
root.querySelector("[data-world-preview]").showModal();
setStatus(root, wasDiscovered
? `${item.title} is already recorded in your journal.`
: `${item.title} was added to your discovery journal.`);
}
function toggleSound(root) {
if (!controller.state || !controller.scene) return;
controller.state.soundEnabled = !controller.state.soundEnabled;
if (controller.state.soundEnabled) controller.scene.enableSound();
else controller.scene.disableSound();
persist();
renderHud(root);
setStatus(root, controller.state.soundEnabled ? "Ambient sound is on." : "Sound is muted.");
}
function savePosition() {
if (!controller.state || !controller.scene || !controller.scene.player) return;
controller.state = State.withPosition(controller.state, controller.scene.player.x, controller.scene.player.y);
persist();
}
function persist() {
if (controller.state) localStorage.setItem(State.STORAGE_KEY, JSON.stringify(controller.state));
}
function renderHud(root) {
if (!controller.state) return;
root.querySelector("[data-world-player]").textContent = controller.state.name;
root.querySelector("[data-world-discovery-count]").textContent = `${controller.state.discovered.length} / ${State.LANDMARK_IDS.length}`;
root.querySelector("[data-world-sound]").textContent = controller.state.soundEnabled ? "Sound: on" : "Sound: muted";
const badge = root.querySelector("[data-world-badge]");
badge.hidden = !controller.state.complete;
renderJournal(root);
}
function renderJournal(root) {
if (!controller.state) return;
const list = root.querySelector("[data-world-journal-list]");
list.replaceChildren();
State.LANDMARK_IDS.forEach((id) => {
const row = document.createElement("li");
const found = controller.state.discovered.includes(id);
row.className = found ? "is-discovered" : "";
row.textContent = `${found ? "Discovered" : "Unexplored"}${LANDMARKS[id].title}`;
list.appendChild(row);
});
}
function setStatus(root, message) {
const status = root && root.querySelector("[data-world-status]");
if (status) status.textContent = message;
}
}());