feat: Doing more polishing (M8b)
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft
|
||||
// (slow attacks, low levels, filtered noise) so it stays in the background of the desk.
|
||||
// Browsers keep audio locked until the first click or key press; unlock() resumes it.
|
||||
|
||||
export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
||||
let ctx = null;
|
||||
let master = null;
|
||||
let noiseBuf = null;
|
||||
|
||||
function context() {
|
||||
if (!ctx) {
|
||||
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
try {
|
||||
ctx = new AC();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
master = ctx.createGain();
|
||||
master.connect(ctx.destination);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
const c = context();
|
||||
if (c && c.state === "suspended") c.resume?.().catch?.(() => {});
|
||||
}
|
||||
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, unlock, { passive: true });
|
||||
|
||||
// One note: a sine (or other) wave with an attack and an exponential fade.
|
||||
function tone(c, at, { freq, to, type = "sine", dur = 0.2, gain = 0.2, attack = 0.008 }) {
|
||||
const o = c.createOscillator();
|
||||
const g = c.createGain();
|
||||
o.type = type;
|
||||
o.frequency.setValueAtTime(freq, at);
|
||||
if (to) o.frequency.exponentialRampToValueAtTime(to, at + dur);
|
||||
g.gain.setValueAtTime(0.0001, at);
|
||||
g.gain.linearRampToValueAtTime(gain, at + attack);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||
o.connect(g).connect(master);
|
||||
o.start(at);
|
||||
o.stop(at + dur + 0.05);
|
||||
}
|
||||
|
||||
// Filtered noise: paper, pages, thumps and clicks.
|
||||
function noise(c, at, { dur = 0.15, gain = 0.1, type = "bandpass", freq = 2000, to, q = 0.8, attack = 0.02 }) {
|
||||
noiseBuf ??= (() => {
|
||||
const b = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
||||
const d = b.getChannelData(0);
|
||||
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||
return b;
|
||||
})();
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = noiseBuf;
|
||||
src.loop = true;
|
||||
const f = c.createBiquadFilter();
|
||||
f.type = type;
|
||||
f.Q.value = q;
|
||||
f.frequency.setValueAtTime(freq, at);
|
||||
if (to) f.frequency.exponentialRampToValueAtTime(to, at + dur);
|
||||
const g = c.createGain();
|
||||
g.gain.setValueAtTime(0.0001, at);
|
||||
g.gain.linearRampToValueAtTime(gain, at + attack);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||
src.connect(f).connect(g).connect(master);
|
||||
src.start(at);
|
||||
src.stop(at + dur + 0.05);
|
||||
}
|
||||
|
||||
const SOUNDS = {
|
||||
stampYes: (c, t) => {
|
||||
tone(c, t, { freq: 150, to: 55, dur: 0.16, gain: 0.5 });
|
||||
noise(c, t, { type: "lowpass", freq: 500, dur: 0.07, gain: 0.25, attack: 0.004 });
|
||||
noise(c, t, { freq: 2200, dur: 0.04, gain: 0.07, attack: 0.004 });
|
||||
},
|
||||
stampNo: (c, t) => {
|
||||
tone(c, t, { freq: 118, to: 44, dur: 0.18, gain: 0.5 });
|
||||
noise(c, t, { type: "lowpass", freq: 420, dur: 0.08, gain: 0.28, attack: 0.004 });
|
||||
noise(c, t, { freq: 1800, dur: 0.04, gain: 0.07, attack: 0.004 });
|
||||
},
|
||||
paper: (c, t) => noise(c, t, { freq: 2600, to: 1200, dur: 0.17, gain: 0.1, attack: 0.04 }),
|
||||
page: (c, t) => {
|
||||
noise(c, t, { freq: 500, to: 3200, dur: 0.24, gain: 0.11, attack: 0.05 });
|
||||
noise(c, t + 0.2, { freq: 2400, dur: 0.06, gain: 0.05, attack: 0.01 });
|
||||
},
|
||||
sign: (c, t) => {
|
||||
noise(c, t, { freq: 3400, to: 2600, dur: 0.2, gain: 0.05, q: 2, attack: 0.05 });
|
||||
tone(c, t + 0.22, { freq: 140, to: 60, dur: 0.14, gain: 0.3 });
|
||||
},
|
||||
telegram: (c, t) => {
|
||||
for (let i = 0; i < 4; i++) noise(c, t + i * 0.07, { type: "highpass", freq: 3200, dur: 0.02, gain: 0.1, attack: 0.002 });
|
||||
tone(c, t + 0.34, { freq: 1320, dur: 0.7, gain: 0.1, attack: 0.01 });
|
||||
tone(c, t + 0.34, { freq: 1980, dur: 0.45, gain: 0.04, attack: 0.01 });
|
||||
},
|
||||
alert: (c, t) => {
|
||||
tone(c, t, { freq: 233, dur: 0.4, gain: 0.2, type: "triangle", attack: 0.03 });
|
||||
tone(c, t + 0.24, { freq: 175, dur: 0.55, gain: 0.2, type: "triangle", attack: 0.03 });
|
||||
},
|
||||
chime: (c, t) => {
|
||||
tone(c, t, { freq: 659, dur: 0.7, gain: 0.12, attack: 0.02 });
|
||||
tone(c, t + 0.16, { freq: 880, dur: 0.9, gain: 0.1, attack: 0.02 });
|
||||
},
|
||||
tick: (c, t) => tone(c, t, { freq: 1100, to: 900, dur: 0.035, gain: 0.03, attack: 0.002 }),
|
||||
tock: (c, t) => tone(c, t, { freq: 820, to: 700, dur: 0.035, gain: 0.03, attack: 0.002 }),
|
||||
click: (c, t) => {
|
||||
tone(c, t, { freq: 900, to: 600, dur: 0.04, gain: 0.06, attack: 0.002 });
|
||||
noise(c, t, { type: "highpass", freq: 3000, dur: 0.015, gain: 0.04, attack: 0.002 });
|
||||
},
|
||||
fall: (c, t) => {
|
||||
[330, 247, 196, 147].forEach((f, i) => tone(c, t + i * 0.45, { freq: f, dur: 0.9, gain: 0.14, attack: 0.04, type: "triangle" }));
|
||||
},
|
||||
fanfare: (c, t) => {
|
||||
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.14, { freq: f, dur: 1.1, gain: 0.09, attack: 0.03 }));
|
||||
},
|
||||
};
|
||||
|
||||
function play(name) {
|
||||
if (!enabled()) return;
|
||||
const make = SOUNDS[name];
|
||||
const c = context();
|
||||
if (!make || !c) return;
|
||||
if (c.state === "suspended") {
|
||||
c.resume?.().catch?.(() => {});
|
||||
return; // still locked: nothing to hear yet
|
||||
}
|
||||
master.gain.value = Math.max(0, Math.min(1, volume()));
|
||||
try {
|
||||
make(c, c.currentTime + 0.005);
|
||||
} catch {
|
||||
/* a failing sound never stops the game */
|
||||
}
|
||||
}
|
||||
|
||||
return { play, unlock, names: Object.keys(SOUNDS) };
|
||||
}
|
||||
+23
-8
@@ -1,17 +1,22 @@
|
||||
// Desk calendar: in-game date, term progress, next decision dates, the brass speed
|
||||
// controls and the way into the menu.
|
||||
// Desk calendar: a dial clock that turns once per in-game day, the date, term progress, next
|
||||
// decision dates, the brass speed controls and the way into the menu.
|
||||
|
||||
import { t, dateParts } from "../i18n/i18n.js";
|
||||
import { dateOfDay } from "../engine/index.js";
|
||||
import { $ } from "./desk.js";
|
||||
import { $, dialSvg, setDial } from "./desk.js";
|
||||
|
||||
export function createCalendar(root, bal, { onSpeed, onMenu }) {
|
||||
const speeds = bal.time.speeds;
|
||||
root.innerHTML = `
|
||||
<div class="cal__head"><span class="cal__weekday"></span></div>
|
||||
<div class="cal__body">
|
||||
<div class="cal__day"></div>
|
||||
<div class="cal__month"></div>
|
||||
<div class="cal__top">
|
||||
${dialSvg("cal__dial")}
|
||||
<div class="cal__date">
|
||||
<div class="cal__day"></div>
|
||||
<div class="cal__month"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal__count"></div>
|
||||
<div class="cal__term"><span></span></div>
|
||||
<dl class="cal__next">
|
||||
@@ -38,17 +43,27 @@ export function createCalendar(root, bal, { onSpeed, onMenu }) {
|
||||
return n === 1 ? t("cal.tomorrow") : t("cal.inDays", { n });
|
||||
};
|
||||
|
||||
const dial = $(root, ".cal__dial");
|
||||
let lastDay = -1;
|
||||
|
||||
function update(state) {
|
||||
const p = dateParts(dateOfDay(state.startDate, state.day).iso);
|
||||
$(root, ".cal__weekday").textContent = p.weekday;
|
||||
$(root, ".cal__day").textContent = p.day;
|
||||
const dayEl = $(root, ".cal__day");
|
||||
dayEl.textContent = p.day;
|
||||
if (lastDay >= 0 && state.day !== lastDay && state.speed > 0 && state.speed <= 2) {
|
||||
dayEl.classList.remove("is-flip");
|
||||
void dayEl.offsetWidth; // restart the animation
|
||||
dayEl.classList.add("is-flip");
|
||||
}
|
||||
lastDay = state.day;
|
||||
$(root, ".cal__month").textContent = `${p.month} ${p.year}`;
|
||||
const shown = Math.min(state.day + 1, bal.time.termDays);
|
||||
$(root, ".cal__count").textContent = t("cal.dayCount", { day: shown, total: bal.time.termDays });
|
||||
$(root, ".cal__term span").style.width = `${(state.day / bal.time.termDays) * 100}%`;
|
||||
|
||||
const over = Boolean(state.over);
|
||||
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(state.pending.weeklyCard ? 0 : until(state.day, bal.time.daysPerWeek));
|
||||
$(root, '[data-next="briefing"]').textContent = over ? "–" : when(until(state.day, bal.time.daysPerWeek));
|
||||
$(root, '[data-next="review"]').textContent = over ? "–" : when(state.pending.review ? 0 : until(state.day, bal.time.daysPerQuarter));
|
||||
|
||||
for (const b of root.querySelectorAll("button[data-speed]")) {
|
||||
@@ -56,5 +71,5 @@ export function createCalendar(root, bal, { onSpeed, onMenu }) {
|
||||
}
|
||||
}
|
||||
|
||||
return { update };
|
||||
return { update, clockHand: (progress) => setDial(dial, progress) };
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export function createDashboard(root, scenario) {
|
||||
spark: $(root, `.gauge[data-ind="${id}"] .gauge__spark`),
|
||||
}]));
|
||||
const sparkLen = {};
|
||||
const shownValue = {};
|
||||
|
||||
function trend(series, eps, goodUp) {
|
||||
if (!series || series.length < 2) return ["", ""];
|
||||
@@ -82,7 +83,14 @@ export function createDashboard(root, scenario) {
|
||||
g.fill.style.width = `${v}%`;
|
||||
g.el.classList.toggle("is-low", v < 30);
|
||||
g.el.classList.toggle("is-high", v >= 70);
|
||||
g.value.textContent = Math.round(v);
|
||||
const r = Math.round(v);
|
||||
g.value.textContent = r;
|
||||
if (shownValue[id] !== undefined && shownValue[id] !== r) {
|
||||
g.value.classList.remove("is-up", "is-down");
|
||||
void g.value.offsetWidth; // restart the animation
|
||||
g.value.classList.add(r > shownValue[id] ? "is-up" : "is-down");
|
||||
}
|
||||
shownValue[id] = r;
|
||||
if (id === "opinion") g.band.textContent = t("opinion." + bandIndex(v, OPINION_CUTS));
|
||||
|
||||
const series = state.history.series[id] ?? [];
|
||||
|
||||
+48
-13
@@ -1,26 +1,34 @@
|
||||
// Centre folder: the weekly decision card as a paper document with two rubber
|
||||
// stamps. Also shows the idle and end-of-term messages.
|
||||
// Centre folder: the in-tray. The oldest waiting proposal is the document on top, answered with
|
||||
// two rubber stamps; the others show as a pile of sheets under it. Also shows the idle and
|
||||
// end-of-term messages.
|
||||
|
||||
import { t, tx, dateParts } from "../i18n/i18n.js";
|
||||
import { dateOfDay } from "../engine/index.js";
|
||||
import { $, esc } from "./desk.js";
|
||||
|
||||
const STAMP_MS = 750; // how long the stamp animation plays before the answer is applied
|
||||
const STAMP_MS = 600; // how long the stamp animation plays before the paper leaves the tray
|
||||
const LEAVE_MS = 320; // and how long the paper takes to slide away
|
||||
|
||||
export function createDecision(root, { cardsById, characters, onAnswer, onProfile, keysEnabled = () => true }) {
|
||||
export function createDecision(root, { cardsById, characters, inboxMax = 10, onAnswer, onStamp = () => {}, onProfile, keysEnabled = () => true }) {
|
||||
root.innerHTML = `
|
||||
<h2 class="inbox__tab">${t("inbox.title")}</h2>
|
||||
<h2 class="inbox__tab">${t("inbox.title")} <span class="inbox__count" data-count></span></h2>
|
||||
<p class="inbox__full" role="status" hidden>${t("inbox.full")}</p>
|
||||
<div class="inbox__body"></div>`;
|
||||
const body = $(root, ".inbox__body");
|
||||
const countEl = $(root, "[data-count]");
|
||||
const fullEl = $(root, ".inbox__full");
|
||||
|
||||
let shown = "";
|
||||
let locked = false;
|
||||
let latest = null;
|
||||
let lastCount = 0;
|
||||
let timers = [];
|
||||
|
||||
function choose(choice) {
|
||||
if (locked) return;
|
||||
const card = $(body, ".card");
|
||||
if (!card) return;
|
||||
const id = latest?.inbox[0]?.id;
|
||||
if (!card || !id) return;
|
||||
locked = true;
|
||||
card.dataset.stamped = choice;
|
||||
for (const b of card.querySelectorAll("button")) b.disabled = true;
|
||||
@@ -28,7 +36,9 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
|
||||
stamp.className = `card__stamp card__stamp--${choice}`;
|
||||
stamp.textContent = t(choice === "yes" ? "card.approved" : "card.rejected");
|
||||
card.append(stamp);
|
||||
setTimeout(() => onAnswer(choice), STAMP_MS);
|
||||
onStamp(choice);
|
||||
timers.push(setTimeout(() => card.classList.add("card--leave"), STAMP_MS));
|
||||
timers.push(setTimeout(() => onAnswer(choice, id), STAMP_MS + LEAVE_MS));
|
||||
}
|
||||
|
||||
body.addEventListener("click", (e) => {
|
||||
@@ -40,7 +50,7 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
|
||||
|
||||
// The panel is rebuilt on language changes and new games, so its key listener must be removable.
|
||||
const onKey = (e) => {
|
||||
if (!keysEnabled() || !latest?.pendingCard) return;
|
||||
if (!keysEnabled() || !latest?.inbox.length) return;
|
||||
if (latest.pending.review || latest.pending.profile || latest.pending.alert) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.target.closest?.("input, textarea, select")) return;
|
||||
@@ -61,10 +71,10 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
|
||||
|
||||
function cardHtml(card, state) {
|
||||
const from = characters[card.sender];
|
||||
const date = dateParts(dateOfDay(state.startDate, state.pendingCard.day).iso);
|
||||
const date = dateParts(dateOfDay(state.startDate, state.inbox[0].day).iso);
|
||||
const paragraphs = tx(card.body).split("\n\n").map((p) => `<p>${esc(p)}</p>`).join("");
|
||||
return `
|
||||
<article class="card card--${esc(card.doc)}">
|
||||
<article class="card card--enter card--${esc(card.doc)}" data-pile="${Math.min(3, state.inbox.length - 1)}">
|
||||
<header class="card__head">
|
||||
<span class="card__kind">${t("kind." + card.doc)}</span>
|
||||
<span class="card__date">${esc(date.day)} ${esc(date.month)} ${esc(date.year)}</span>
|
||||
@@ -80,15 +90,34 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
|
||||
</article>`;
|
||||
}
|
||||
|
||||
// The tab counts the proposals waiting; the pile under the top paper grows with them.
|
||||
function updateTray(state) {
|
||||
const n = state.inbox.length;
|
||||
countEl.textContent = n ? `${n}/${inboxMax}` : "";
|
||||
countEl.classList.toggle("is-warn", n >= inboxMax - 2);
|
||||
if (n > lastCount) {
|
||||
countEl.classList.remove("is-bump");
|
||||
void countEl.offsetWidth; // restart the animation
|
||||
countEl.classList.add("is-bump");
|
||||
}
|
||||
lastCount = n;
|
||||
fullEl.hidden = !(state.pending.inboxFull && !state.over);
|
||||
const card = $(body, ".card");
|
||||
if (card) card.dataset.pile = Math.min(3, Math.max(0, n - 1));
|
||||
}
|
||||
|
||||
function update(state) {
|
||||
latest = state;
|
||||
updateTray(state);
|
||||
let kind = "idle";
|
||||
if (state.over) kind = "over";
|
||||
else if (state.pending.profile) kind = "profile";
|
||||
else if (state.pendingCard) kind = `card:${state.pendingCard.id}`;
|
||||
else if (state.inbox.length) kind = `card:${state.inbox[0].id}`;
|
||||
if (kind === shown) return;
|
||||
shown = kind;
|
||||
locked = false;
|
||||
timers.forEach(clearTimeout);
|
||||
timers = [];
|
||||
body.classList.toggle("is-card", kind.startsWith("card:"));
|
||||
|
||||
if (kind === "profile") {
|
||||
@@ -105,9 +134,15 @@ export function createDecision(root, { cardsById, characters, onAnswer, onProfil
|
||||
} else if (kind === "over") {
|
||||
body.innerHTML = `<h3>${t("inbox.overTitle")}</h3><p>${t("inbox.overBody")}</p>`;
|
||||
} else {
|
||||
body.innerHTML = cardHtml(cardsById.get(state.pendingCard.id), state);
|
||||
body.innerHTML = cardHtml(cardsById.get(state.inbox[0].id), state);
|
||||
}
|
||||
}
|
||||
|
||||
return { update, destroy: () => document.removeEventListener("keydown", onKey) };
|
||||
return {
|
||||
update,
|
||||
destroy: () => {
|
||||
timers.forEach(clearTimeout);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,3 +16,22 @@ export const $ = (root, sel) => root.querySelector(sel);
|
||||
|
||||
// Escape text for use inside innerHTML templates.
|
||||
export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||
|
||||
// A 12-hour dial whose single hand makes one full turn per in-game day. progress is 0..1.
|
||||
export function dialSvg(className = "") {
|
||||
const ticks = Array.from({ length: 12 }, (_, i) => {
|
||||
const long = i % 3 === 0;
|
||||
return `<line x1="50" y1="${long ? 8 : 10}" x2="50" y2="${long ? 18 : 15}" transform="rotate(${i * 30} 50 50)"/>`;
|
||||
}).join("");
|
||||
return `
|
||||
<svg class="dial ${className}" viewBox="0 0 100 100" aria-hidden="true" focusable="false">
|
||||
<circle class="dial__face" cx="50" cy="50" r="46"/>
|
||||
<g class="dial__ticks">${ticks}</g>
|
||||
<line class="dial__hand" x1="50" y1="54" x2="50" y2="16" transform="rotate(0 50 50)"/>
|
||||
<circle class="dial__pin" cx="50" cy="50" r="4"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
export function setDial(svg, progress) {
|
||||
svg?.querySelector(".dial__hand")?.setAttribute("transform", `rotate(${(progress * 360).toFixed(1)} 50 50)`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// A tiny Markdown renderer for the rules. It supports exactly what the rules use: #, ##, ### headings,
|
||||
// paragraphs, - and 1. lists (no nesting), **bold**, *italic*, `code`, pipe tables, --- and > quotes.
|
||||
// Everything is escaped first, so a rules file can never inject markup.
|
||||
|
||||
import { esc } from "./desk.js";
|
||||
|
||||
function inline(text) {
|
||||
return esc(text)
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(^|[^*\w])\*([^*\s][^*]*?)\*(?![*\w])/g, "$1<em>$2</em>");
|
||||
}
|
||||
|
||||
const cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
||||
|
||||
export function renderMarkdown(src) {
|
||||
const lines = src.replace(/\r\n?/g, "\n").split("\n");
|
||||
const out = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) { i++; continue; }
|
||||
|
||||
const h = /^(#{1,3})\s+(.*)$/.exec(line);
|
||||
if (h) {
|
||||
out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (/^-{3,}\s*$/.test(line)) { out.push("<hr>"); i++; continue; }
|
||||
|
||||
if (/^\|.*\|\s*$/.test(line) && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1] ?? "")) {
|
||||
const head = cells(line);
|
||||
i += 2;
|
||||
const rows = [];
|
||||
while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]));
|
||||
out.push(
|
||||
`<table><thead><tr>${head.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead>` +
|
||||
`<tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody></table>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s?/.test(line)) {
|
||||
const quote = [];
|
||||
while (i < lines.length && /^>\s?/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, ""));
|
||||
out.push(`<blockquote>${quote.map(inline).join(" ")}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const list = /^(-|\d+\.)\s+/.exec(line);
|
||||
if (list) {
|
||||
const tag = list[1] === "-" ? "ul" : "ol";
|
||||
const re = tag === "ul" ? /^-\s+(.*)$/ : /^\d+\.\s+(.*)$/;
|
||||
const items = [];
|
||||
while (i < lines.length && re.test(lines[i])) items.push(re.exec(lines[i++])[1]);
|
||||
out.push(`<${tag}>${items.map((x) => `<li>${inline(x)}</li>`).join("")}</${tag}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const para = [];
|
||||
while (i < lines.length && lines[i].trim() && !/^(#{1,3}\s|-{3,}\s*$|>|\||(-|\d+\.)\s)/.test(lines[i])) para.push(lines[i++]);
|
||||
out.push(`<p>${inline(para.join(" "))}</p>`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
+31
-1
@@ -7,6 +7,7 @@ import { dateOfDay, restoreState, PRONOUNS } from "../engine/index.js";
|
||||
import { encodeSave, decodeSave, migrate } from "../save/codec.js";
|
||||
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
|
||||
import { $, esc } from "./desk.js";
|
||||
import { renderMarkdown } from "./markdown.js";
|
||||
|
||||
const ARM_MS = 4000; // how long a "Sure?" button stays armed
|
||||
|
||||
@@ -17,6 +18,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
let armed = null; // "action:arg" waiting for a second click
|
||||
let armTimer = 0;
|
||||
let exportText = "";
|
||||
const rulesCache = {}; // language -> rendered html, or "error"
|
||||
|
||||
const say = (text, kind = "ok") => { message = { text, kind }; };
|
||||
const go = (next) => { screen = next; message = null; disarm(); render(); };
|
||||
@@ -85,6 +87,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
${button("load", t("menu.load"))}
|
||||
${button("export", t("menu.export"))}
|
||||
${button("import", t("menu.import"))}
|
||||
${button("rules", t("menu.rules"))}
|
||||
${button("settings", t("menu.settings"))}
|
||||
${button("quit", t("menu.quit"))}
|
||||
</div>`;
|
||||
@@ -100,6 +103,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
${button("new", t("menu.new"))}
|
||||
${button("load", t("menu.load"))}
|
||||
${button("import", t("menu.import"))}
|
||||
${button("rules", t("menu.rules"))}
|
||||
${button("settings", t("menu.settings"))}
|
||||
${button("credits", t("menu.credits"))}
|
||||
</div>`;
|
||||
@@ -213,6 +217,13 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
<input type="checkbox" id="setting-slow" ${s.slow ? "checked" : ""}>
|
||||
<span><b>${t("settings.slow")}</b><small>${t("settings.slowHint")}</small></span>
|
||||
</label>
|
||||
<label class="menu__field menu__check">
|
||||
<input type="checkbox" id="setting-sound" ${s.sound ? "checked" : ""}>
|
||||
<span><b>${t("settings.sound")}</b><small>${t("settings.soundHint")}</small></span>
|
||||
</label>
|
||||
<label class="menu__field">${t("settings.volume")}
|
||||
<input type="range" id="setting-volume" min="0" max="1" step="0.05" value="${s.volume}" ${s.sound ? "" : "disabled"}>
|
||||
</label>
|
||||
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
|
||||
${back()}`;
|
||||
}
|
||||
@@ -226,7 +237,23 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
${back()}`;
|
||||
}
|
||||
|
||||
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits };
|
||||
// The rules are plain Markdown files (data/rules/rules.<lang>.md), fetched on first use.
|
||||
function rules() {
|
||||
const lang = getLang();
|
||||
const body = rulesCache[lang];
|
||||
if (body === undefined) {
|
||||
rulesCache[lang] = null; // loading
|
||||
fetch(`data/rules/rules.${lang}.md`)
|
||||
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((md) => { rulesCache[lang] = renderMarkdown(md); })
|
||||
.catch(() => { rulesCache[lang] = "error"; })
|
||||
.finally(() => { if (screen === "rules" && !root.hidden) render(); });
|
||||
}
|
||||
const html = body === "error" ? `<p class="menu__msg menu__msg--error">${t("rules.error")}</p>` : body ?? `<p class="menu__sub">${t("rules.loading")}</p>`;
|
||||
return `<div class="rules" tabindex="0" aria-label="${t("rules.title")}">${html}</div>${back()}`;
|
||||
}
|
||||
|
||||
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules };
|
||||
|
||||
function render() {
|
||||
root.innerHTML = `
|
||||
@@ -296,6 +323,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
export: () => { exportText = encodeSave(getState()); go("export"); },
|
||||
import: () => go("import"),
|
||||
settings: () => go("settings"),
|
||||
rules: () => go("rules"),
|
||||
credits: () => go("credits"),
|
||||
back: () => go("home"),
|
||||
resume: () => handlers.onResume(),
|
||||
@@ -336,6 +364,8 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
||||
|
||||
root.addEventListener("change", async (e) => {
|
||||
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
|
||||
if (e.target.id === "setting-sound") { handlers.onSettings({ sound: e.target.checked }); render(); }
|
||||
if (e.target.id === "setting-volume") handlers.onSettings({ volume: Number(e.target.value) });
|
||||
if (e.target.id === "import-file" && e.target.files[0]) {
|
||||
$(root, "#import-text").value = await e.target.files[0].text();
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ export function createNewspaper(root, tpl) {
|
||||
if (key === shown) return;
|
||||
shown = key;
|
||||
|
||||
root.classList.remove("is-fresh");
|
||||
void root.offsetWidth; // restart the fade
|
||||
root.classList.add("is-fresh");
|
||||
const fill = (s) => s.replaceAll("{country}", state.countryName);
|
||||
const [lead, ...rest] = computeHeadlines(state, tpl);
|
||||
$(root, ".news__lead h3").textContent = fill(tx(lead.h));
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Phone layout only: the shelf along the top of the desk. It shows the date and a small dial,
|
||||
// cycles the speed, and picks which single paper is on the desk (tray, briefing, news,
|
||||
// calendar, and the ledger while a budget review is open).
|
||||
|
||||
import { t, dateParts } from "../i18n/i18n.js";
|
||||
import { dateOfDay } from "../engine/index.js";
|
||||
import { $, dialSvg, setDial } from "./desk.js";
|
||||
|
||||
const VIEWS = ["tray", "brief", "news", "clock"];
|
||||
|
||||
export function createShelf(root, bal, { getView, onView, onSpeed, onMenu }) {
|
||||
const speeds = [0, ...bal.time.speeds];
|
||||
root.innerHTML = `
|
||||
<div class="shelf__time">
|
||||
${dialSvg("shelf__dial")}
|
||||
<div class="shelf__date"><b></b><span></span></div>
|
||||
<button class="brass shelf__speed" data-speed-cycle></button>
|
||||
</div>
|
||||
<div class="shelf__tabs" role="group" aria-label="${t("shelf.nav")}">
|
||||
${VIEWS.map((v) => `<button class="shelf__tab" data-view="${v}"><span>${t("shelf." + v)}</span>${v === "tray" ? `<i class="shelf__badge" data-badge></i>` : ""}</button>`).join("")}
|
||||
<button class="shelf__tab shelf__tab--ledger" data-view="ledger" hidden><span>${t("shelf.ledger")}</span></button>
|
||||
<button class="shelf__tab shelf__tab--menu" data-menu><span>${t("shelf.menu")}</span></button>
|
||||
</div>`;
|
||||
|
||||
const dial = $(root, ".shelf__dial");
|
||||
let speed = 0;
|
||||
|
||||
root.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("button");
|
||||
if (!b) return;
|
||||
if (b.dataset.menu !== undefined) onMenu();
|
||||
else if (b.dataset.speedCycle !== undefined) onSpeed(speeds[(speeds.indexOf(speed) + 1) % speeds.length]);
|
||||
else if (b.dataset.view) onView(b.dataset.view);
|
||||
});
|
||||
|
||||
function update(state) {
|
||||
const d = dateParts(dateOfDay(state.startDate, state.day).iso);
|
||||
$(root, ".shelf__date b").textContent = `${d.weekday} ${d.day} ${d.month}`;
|
||||
$(root, ".shelf__date span").textContent = d.year;
|
||||
speed = state.speed;
|
||||
const sp = $(root, ".shelf__speed");
|
||||
sp.textContent = speed === 0 ? "II" : `${speed}×`;
|
||||
sp.setAttribute("aria-label", speed === 0 ? t("cal.pause") : t("cal.speed", { n: speed }));
|
||||
|
||||
const view = getView();
|
||||
const ledger = Boolean(state.pending.review && !state.pending.profile && !state.over);
|
||||
$(root, '[data-view="ledger"]').hidden = !ledger;
|
||||
for (const b of root.querySelectorAll("[data-view]")) b.setAttribute("aria-pressed", String(b.dataset.view === view));
|
||||
const badge = $(root, "[data-badge]");
|
||||
badge.textContent = state.inbox.length || "";
|
||||
badge.hidden = state.inbox.length === 0;
|
||||
badge.classList.toggle("is-warn", state.inbox.length >= bal.cards.inboxMax - 2);
|
||||
}
|
||||
|
||||
return { update, clockHand: (p) => setDial(dial, p) };
|
||||
}
|
||||
Reference in New Issue
Block a user