Refactor org web content and simplify shared UI
All checks were successful
Build Org Website / build (push) Successful in 44s
All checks were successful
Build Org Website / build (push) Successful in 44s
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
const root = $(".play-root[data-play-page]");
|
||||
if (!root) return;
|
||||
const page = root.dataset.playPage;
|
||||
const inits = { hub, memory, constellation, poem, bookshelf, recipe, timeline, ink, terminal, study, sigil, rpg };
|
||||
const inits = { hub, memory, constellation, poem, bookshelf, ink, terminal, study, sigil };
|
||||
if (inits[page]) inits[page](root);
|
||||
});
|
||||
|
||||
@@ -256,63 +256,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function recipe(root) {
|
||||
const wheel = $("[data-recipe-spin]", root);
|
||||
const result = $("[data-recipe-result]", root);
|
||||
const groups = [
|
||||
["Rice", "Flatbread", "Roast potatoes", "Noodles"],
|
||||
["Lemon chicken", "Spiced lentils", "Tomato eggs", "Pepper stew"],
|
||||
["Cucumber salad", "Mint yoghurt", "Pickled onions", "Charred greens"],
|
||||
["Serve with stories", "Eat outside", "Use the blue plates", "Make extra tea"]
|
||||
];
|
||||
let spin = 0;
|
||||
let feast = 0;
|
||||
wheel.addEventListener("click", () => {
|
||||
spin += 540 + Math.floor(Math.random() * 540);
|
||||
feast += 1;
|
||||
wheel.style.transform = `rotate(${spin}deg)`;
|
||||
result.innerHTML = groups.map((group) => `<li>${pick(group)}</li>`).join("") + (feast % 4 === 0 ? "<li>Bonus: someone gets the last crispy bit.</li>" : "");
|
||||
});
|
||||
wheel.click();
|
||||
}
|
||||
|
||||
function timeline(root) {
|
||||
const items = [
|
||||
{ year: "2022", text: "The personal web habit begins." },
|
||||
{ year: "2025", text: "Org publishing becomes the main site engine." },
|
||||
{ year: "2025", text: "Weekly reviews and career notes grow into a library." },
|
||||
{ year: "2026", text: "Dashboards, services, and authoring tools join the site." },
|
||||
{ year: "2026", text: "The play wing opens." }
|
||||
];
|
||||
const list = $("[data-timeline-list]", root);
|
||||
const status = $("[data-timeline-status]", root);
|
||||
const paradox = document.createElement("button");
|
||||
paradox.type = "button";
|
||||
paradox.textContent = "Paradox";
|
||||
$("[data-timeline-shuffle]", root).insertAdjacentElement("afterend", paradox);
|
||||
setupSortable(list, render, check);
|
||||
$("[data-timeline-shuffle]", root).addEventListener("click", () => render(shuffle(items)));
|
||||
paradox.addEventListener("click", () => render([...items].reverse()));
|
||||
render(shuffle(items));
|
||||
|
||||
function render(source) {
|
||||
list.innerHTML = "";
|
||||
source.forEach((item, index) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "timeline-card";
|
||||
card.draggable = true;
|
||||
card.dataset.value = String(items.indexOf(item));
|
||||
card.innerHTML = `<span>${item.year}</span><strong>${item.text}</strong>`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
check();
|
||||
}
|
||||
function check() {
|
||||
const current = $$(".timeline-card", list).map((card) => Number(card.dataset.value));
|
||||
status.textContent = current.every((value, index) => value === index) ? "Timeline restored" : "Arrange the cards";
|
||||
}
|
||||
}
|
||||
|
||||
function ink(root) {
|
||||
const canvas = $("[data-ink-canvas]", root);
|
||||
const ctx = canvas.getContext("2d");
|
||||
@@ -492,353 +435,6 @@
|
||||
draw();
|
||||
}
|
||||
|
||||
function rpg(root) {
|
||||
if (window.AshBelowLakeRpg) return window.AshBelowLakeRpg(root);
|
||||
const canvas = $("[data-rpg-canvas]", root);
|
||||
const ctx = canvas.getContext("2d");
|
||||
const els = {
|
||||
speaker: $("[data-rpg-speaker]", root),
|
||||
line: $("[data-rpg-line]", root),
|
||||
room: $("[data-rpg-room]", root),
|
||||
route: $("[data-rpg-route]", root),
|
||||
hearts: $("[data-rpg-hearts]", root),
|
||||
quests: $("[data-rpg-quests]", root),
|
||||
inventory: $("[data-rpg-inventory]", root),
|
||||
status: $("[data-rpg-status]", root)
|
||||
};
|
||||
const tile = 32;
|
||||
const saveSlot = "hollow-archive";
|
||||
const fallbackKey = "play:rpg:hollow-archive";
|
||||
const items = {
|
||||
lamp: { label: "Desk Lamp", room: "entrance", x: 10, y: 8, color: "#f2c94c" },
|
||||
page: { label: "Loose Page", room: "stacks", x: 18, y: 5, color: "#f7efe0" },
|
||||
key: { label: "Basement Key", room: "garden", x: 4, y: 11, color: "#d59b45" }
|
||||
};
|
||||
const npcs = {
|
||||
archivist: { name: "Archivist", room: "entrance", x: 6, y: 6, color: "#b98bff" },
|
||||
shade: { name: "Shy Shade", room: "stacks", x: 17, y: 9, color: "#6ed0d4" },
|
||||
gate: { name: "Iron Door", room: "garden", x: 19, y: 10, color: "#8a8f98" }
|
||||
};
|
||||
const rooms = {
|
||||
entrance: {
|
||||
name: "Entrance",
|
||||
floor: "#353029",
|
||||
exits: [{ x: 22, y: 7, to: "stacks", px: 1, py: 7 }],
|
||||
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 16], [23, 0, 1, 6], [23, 9, 1, 7], [8, 3, 1, 8], [14, 7, 5, 1]])
|
||||
},
|
||||
stacks: {
|
||||
name: "Stacks",
|
||||
floor: "#242d35",
|
||||
exits: [{ x: 0, y: 7, to: "entrance", px: 22, py: 7 }, { x: 23, y: 12, to: "garden", px: 1, py: 12 }],
|
||||
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 6], [0, 9, 1, 7], [23, 0, 1, 11], [23, 14, 1, 2], [4, 3, 2, 10], [10, 2, 2, 11], [16, 3, 2, 5]])
|
||||
},
|
||||
garden: {
|
||||
name: "Moon Garden",
|
||||
floor: "#21362e",
|
||||
exits: [{ x: 0, y: 12, to: "stacks", px: 22, py: 12 }],
|
||||
walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 11], [0, 14, 1, 2], [23, 0, 1, 16], [7, 4, 10, 1], [7, 10, 1, 4], [13, 10, 1, 4]])
|
||||
}
|
||||
};
|
||||
let state = freshState();
|
||||
let running = false;
|
||||
|
||||
function freshState() {
|
||||
return {
|
||||
room: "entrance",
|
||||
player: { x: 3, y: 7, facing: "down" },
|
||||
inventory: [],
|
||||
flags: {},
|
||||
route: "Undecided",
|
||||
hearts: 3,
|
||||
ending: null,
|
||||
message: { speaker: "Archivist", line: "The archive waits. Find the lamp, help the shade, then decide what to do with the locked door." }
|
||||
};
|
||||
}
|
||||
|
||||
function rects(sources) {
|
||||
const set = new Set();
|
||||
sources.forEach(([x, y, w, h]) => {
|
||||
for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) set.add(`${xx},${yy}`);
|
||||
});
|
||||
return set;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const room = rooms[state.room];
|
||||
ctx.fillStyle = "#14110e";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
for (let y = 0; y < 16; y += 1) {
|
||||
for (let x = 0; x < 24; x += 1) {
|
||||
ctx.fillStyle = room.walls.has(`${x},${y}`) ? "#181716" : room.floor;
|
||||
ctx.fillRect(x * tile, y * tile, tile, tile);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.035)";
|
||||
ctx.strokeRect(x * tile, y * tile, tile, tile);
|
||||
}
|
||||
}
|
||||
room.exits.forEach((exit) => drawGlyph(exit.x, exit.y, "#c48a41", "door"));
|
||||
Object.entries(items).forEach(([id, item]) => {
|
||||
if (item.room === state.room && !state.inventory.includes(id)) drawGlyph(item.x, item.y, item.color, "item");
|
||||
});
|
||||
Object.values(npcs).forEach((npc) => {
|
||||
if (npc.room === state.room) drawGlyph(npc.x, npc.y, npc.color, npc.name === "Iron Door" ? "doorNpc" : "npc");
|
||||
});
|
||||
drawPlayer();
|
||||
renderHud();
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function drawGlyph(x, y, color, kind) {
|
||||
const px = x * tile;
|
||||
const py = y * tile;
|
||||
ctx.fillStyle = color;
|
||||
if (kind === "item") {
|
||||
ctx.fillRect(px + 10, py + 10, 12, 12);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
||||
ctx.fillRect(px + 13, py + 7, 6, 6);
|
||||
} else if (kind === "door" || kind === "doorNpc") {
|
||||
ctx.fillRect(px + 7, py + 4, 18, 25);
|
||||
ctx.fillStyle = "#211812";
|
||||
ctx.fillRect(px + 20, py + 16, 3, 3);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.arc(px + 16, py + 12, 9, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillRect(px + 8, py + 20, 16, 8);
|
||||
}
|
||||
}
|
||||
|
||||
function drawPlayer() {
|
||||
const px = state.player.x * tile;
|
||||
const py = state.player.y * tile;
|
||||
ctx.fillStyle = "#f35f5f";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + 16, py + 7);
|
||||
ctx.bezierCurveTo(px + 2, py + 2, px + 1, py + 22, px + 16, py + 28);
|
||||
ctx.bezierCurveTo(px + 31, py + 22, px + 30, py + 2, px + 16, py + 7);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#fff8e8";
|
||||
ctx.fillRect(px + 11, py + 13, 4, 4);
|
||||
ctx.fillRect(px + 18, py + 13, 4, 4);
|
||||
}
|
||||
|
||||
function renderHud() {
|
||||
els.speaker.textContent = state.message.speaker;
|
||||
els.line.textContent = state.ending ? endingLine() : state.message.line;
|
||||
els.room.textContent = rooms[state.room].name;
|
||||
els.route.textContent = state.route;
|
||||
els.hearts.textContent = String(state.hearts);
|
||||
els.inventory.innerHTML = state.inventory.length
|
||||
? state.inventory.map((id) => `<li>${items[id].label}</li>`).join("")
|
||||
: "<li>Empty</li>";
|
||||
const questRows = [
|
||||
["Find a light", state.inventory.includes("lamp")],
|
||||
["Return the loose page to the shade", state.flags.shadeHelped],
|
||||
["Open, force, or leave the iron door", Boolean(state.ending)]
|
||||
];
|
||||
els.quests.innerHTML = questRows.map(([text, done]) => `<li class="${done ? "is-done" : ""}">${done ? "Done: " : ""}${text}</li>`).join("");
|
||||
}
|
||||
|
||||
function move(dx, dy, facing) {
|
||||
if (!running || state.ending) return;
|
||||
state.player.facing = facing;
|
||||
const nx = state.player.x + dx;
|
||||
const ny = state.player.y + dy;
|
||||
const room = rooms[state.room];
|
||||
const exit = room.exits.find((candidate) => candidate.x === nx && candidate.y === ny);
|
||||
if (exit) {
|
||||
state.room = exit.to;
|
||||
state.player.x = exit.px;
|
||||
state.player.y = exit.py;
|
||||
say("Narrator", `You enter ${rooms[state.room].name}.`);
|
||||
autosave();
|
||||
return;
|
||||
}
|
||||
if (nx < 0 || ny < 0 || nx > 23 || ny > 15 || room.walls.has(`${nx},${ny}`) || npcAt(nx, ny)) return;
|
||||
state.player.x = nx;
|
||||
state.player.y = ny;
|
||||
const item = itemAt(nx, ny);
|
||||
if (item) take(item);
|
||||
}
|
||||
|
||||
function act() {
|
||||
if (!running) return start();
|
||||
if (state.ending) return;
|
||||
const front = inFront();
|
||||
const npc = npcAt(front.x, front.y);
|
||||
if (npc) talk(npc);
|
||||
else {
|
||||
const here = itemAt(state.player.x, state.player.y);
|
||||
if (here) take(here);
|
||||
else say("Narrator", "Dust moves in the light. Nothing asks to be changed here.");
|
||||
}
|
||||
autosave();
|
||||
}
|
||||
|
||||
function inFront() {
|
||||
const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[state.player.facing] || [0, 1];
|
||||
return { x: state.player.x + delta[0], y: state.player.y + delta[1] };
|
||||
}
|
||||
|
||||
function itemAt(x, y) {
|
||||
return Object.keys(items).find((id) => {
|
||||
const item = items[id];
|
||||
return item.room === state.room && item.x === x && item.y === y && !state.inventory.includes(id);
|
||||
});
|
||||
}
|
||||
|
||||
function npcAt(x, y) {
|
||||
return Object.keys(npcs).find((id) => {
|
||||
const npc = npcs[id];
|
||||
return npc.room === state.room && npc.x === x && npc.y === y;
|
||||
});
|
||||
}
|
||||
|
||||
function take(id) {
|
||||
state.inventory.push(id);
|
||||
if (id === "lamp") state.route = "Gentle";
|
||||
if (id === "key" && !state.flags.shadeHelped) state.route = "Power";
|
||||
say("Found", `${items[id].label} joined your inventory.`);
|
||||
}
|
||||
|
||||
function talk(id) {
|
||||
if (id === "archivist") {
|
||||
if (!state.inventory.includes("lamp")) say("Archivist", "Take the lamp from the lower desk. The stacks dislike being crossed in the dark.");
|
||||
else if (!state.flags.shadeHelped) say("Archivist", "A loose page has gone missing. The quiet reader in the stacks knows where it belongs.");
|
||||
else say("Archivist", "You have been kind to a forgotten page. The garden door will remember that.");
|
||||
}
|
||||
if (id === "shade") {
|
||||
if (!state.inventory.includes("page")) say("Shy Shade", "I lost the page with my name on it. It fell somewhere nearby.");
|
||||
else {
|
||||
state.flags.shadeHelped = true;
|
||||
state.inventory = state.inventory.filter((item) => item !== "page");
|
||||
state.route = "Mercy";
|
||||
say("Shy Shade", "You returned my page instead of keeping it. Take the honest route through the garden.");
|
||||
}
|
||||
}
|
||||
if (id === "gate") {
|
||||
if (state.flags.shadeHelped) end("mercy");
|
||||
else if (state.inventory.includes("key")) end("power");
|
||||
else end("quiet");
|
||||
}
|
||||
}
|
||||
|
||||
function end(kind) {
|
||||
state.ending = kind;
|
||||
state.route = kind === "mercy" ? "Mercy" : kind === "power" ? "Power" : "Quiet";
|
||||
say("Ending", endingLine());
|
||||
save();
|
||||
}
|
||||
|
||||
function endingLine() {
|
||||
if (state.ending === "mercy") return "Mercy ending: the iron door opens without a sound, and every returned page remembers your name.";
|
||||
if (state.ending === "power") return "Power ending: the key turns, but the archive grows colder around the missing page.";
|
||||
if (state.ending === "quiet") return "Quiet ending: you leave the locked door alone. Some mysteries stay intact.";
|
||||
return state.message.line;
|
||||
}
|
||||
|
||||
function say(speaker, line) {
|
||||
state.message = { speaker, line };
|
||||
}
|
||||
|
||||
function start() {
|
||||
running = true;
|
||||
say("Archivist", "Walk the archive. Speak gently, or take what you need. The route will notice.");
|
||||
els.status.textContent = "Started. Progress autosaves after room changes and actions.";
|
||||
}
|
||||
|
||||
async function save() {
|
||||
localStorage.setItem(fallbackKey, JSON.stringify(state));
|
||||
try {
|
||||
const response = await fetch(`/api/play/rpg/save/${saveSlot}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ payload: state })
|
||||
});
|
||||
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
|
||||
els.status.textContent = "Saved to backend.";
|
||||
} catch (_error) {
|
||||
els.status.textContent = "Saved locally. Backend save API was not reachable.";
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch(`/api/play/rpg/save/${saveSlot}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
state = normalizeState(data.payload);
|
||||
running = true;
|
||||
els.status.textContent = "Loaded from backend.";
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
/* Fall through to local save. */
|
||||
}
|
||||
const local = localStorage.getItem(fallbackKey);
|
||||
if (local) {
|
||||
state = normalizeState(JSON.parse(local));
|
||||
running = true;
|
||||
els.status.textContent = "Loaded local save.";
|
||||
} else {
|
||||
els.status.textContent = "No save found.";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeState(candidate) {
|
||||
return Object.assign(freshState(), candidate || {}, {
|
||||
player: Object.assign(freshState().player, (candidate && candidate.player) || {}),
|
||||
inventory: Array.isArray(candidate && candidate.inventory) ? candidate.inventory.filter((id) => items[id]) : [],
|
||||
flags: Object.assign({}, (candidate && candidate.flags) || {}),
|
||||
message: Object.assign(freshState().message, (candidate && candidate.message) || {})
|
||||
});
|
||||
}
|
||||
|
||||
function autosave() {
|
||||
save();
|
||||
}
|
||||
|
||||
$("[data-rpg-start]", root).addEventListener("click", start);
|
||||
$("[data-rpg-save]", root).addEventListener("click", save);
|
||||
$("[data-rpg-load]", root).addEventListener("click", load);
|
||||
$("[data-rpg-reset]", root).addEventListener("click", async () => {
|
||||
state = freshState();
|
||||
running = false;
|
||||
localStorage.removeItem(fallbackKey);
|
||||
try {
|
||||
await fetch(`/api/play/rpg/save/${saveSlot}`, { method: "DELETE" });
|
||||
els.status.textContent = "Reset and cleared backend save.";
|
||||
} catch (_error) {
|
||||
els.status.textContent = "Reset locally. Backend save API was not reachable.";
|
||||
}
|
||||
});
|
||||
$("[data-rpg-act]", root).addEventListener("click", act);
|
||||
$$("[data-rpg-move]", root).forEach((button) => {
|
||||
const moves = { up: [0, -1, "up"], down: [0, 1, "down"], left: [-1, 0, "left"], right: [1, 0, "right"] };
|
||||
button.addEventListener("click", () => move(...moves[button.dataset.rpgMove]));
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!root.isConnected) return;
|
||||
const tag = event.target.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
const keys = {
|
||||
ArrowUp: [0, -1, "up"], w: [0, -1, "up"],
|
||||
ArrowDown: [0, 1, "down"], s: [0, 1, "down"],
|
||||
ArrowLeft: [-1, 0, "left"], a: [-1, 0, "left"],
|
||||
ArrowRight: [1, 0, "right"], d: [1, 0, "right"]
|
||||
};
|
||||
if (keys[event.key]) {
|
||||
event.preventDefault();
|
||||
move(...keys[event.key]);
|
||||
}
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
act();
|
||||
}
|
||||
});
|
||||
draw();
|
||||
load();
|
||||
}
|
||||
|
||||
function setupSortable(container, _render, after) {
|
||||
let dragged = null;
|
||||
container.addEventListener("dragstart", (event) => {
|
||||
|
||||
Reference in New Issue
Block a user