511 lines
21 KiB
TypeScript
511 lines
21 KiB
TypeScript
/**
|
|
* Browser entry point — wires the DOM to `game.ts`.
|
|
*
|
|
* Presentation only. Every question of what is legal, what it means, or what the board looks like
|
|
* is answered by the engine or by the shared view helpers.
|
|
*/
|
|
|
|
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
|
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
|
import { play } from './sound.ts';
|
|
import type { Game } from './game.ts';
|
|
import {
|
|
actionMenu,
|
|
handPlayable,
|
|
currentActor,
|
|
fromSave,
|
|
newGame,
|
|
submit,
|
|
toSave,
|
|
view,
|
|
} from './game.ts';
|
|
|
|
const SAVE_KEY = 'station-master.save.v1';
|
|
|
|
let game: Game;
|
|
/** Which card or track piece is picked, waiting for a location. */
|
|
let selected: string | null = null;
|
|
/** A square picked on the board, waiting for a rotation. */
|
|
let pendingAt: string | null = null;
|
|
/**
|
|
* AUTO-FOCUS on the district.
|
|
*
|
|
* It is only worth the vertical space during the phases that change it — Local Operations, where
|
|
* cards and track are placed and the crew switches, and Cargo, where loads move. During New Train,
|
|
* Mainline and Supervisor Shift nothing in the district moves and the Division map is what matters.
|
|
*
|
|
* 'auto' follows the phase; 'open' and 'closed' are the player overriding it and stay put until
|
|
* they change it again. Display only — in a multiplayer game two players may reasonably want it
|
|
* set differently, so this must never become part of game state.
|
|
*/
|
|
let districtMode: 'auto' | 'open' | 'closed' = 'auto';
|
|
/**
|
|
* Sound, OFF until asked for.
|
|
*
|
|
* Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio
|
|
* rather than the finished thing — and a playtester who did not ask for noise should not get any.
|
|
* One click in the title bar turns it on, and that click is also the gesture browsers require
|
|
* before any audio may start.
|
|
*/
|
|
let soundOn = false;
|
|
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
|
|
|
|
const $ = (id: string): HTMLElement => {
|
|
const el = document.getElementById(id);
|
|
if (!el) throw new Error(`missing element: ${id}`);
|
|
return el as HTMLElement;
|
|
};
|
|
|
|
const esc = (s: string): string =>
|
|
s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* THE TURN CHART — the five phases of a Stage, in order, with the current one lit.
|
|
*
|
|
* The title bar used to say `phase: Mainline`, which names the moment and nothing around it: a
|
|
* player could not see what had already happened this Stage or what was coming. The printed game
|
|
* has a turn chart for exactly this reason.
|
|
*
|
|
* Each phase carries a tooltip describing what actually happens in it — the first three are the
|
|
* rules text; Cargo and Supervisor Shift are written from what the engine does, since the recovered
|
|
* sheet does not spell them out.
|
|
*/
|
|
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
|
|
{
|
|
key: 'localOps',
|
|
label: 'Local Operations',
|
|
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
|
|
// a turnout: the choice of three roads
|
|
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
|
|
},
|
|
{
|
|
key: 'newTrain',
|
|
label: 'New Train',
|
|
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
|
|
// a locomotive being made up
|
|
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
|
},
|
|
{
|
|
key: 'mainline',
|
|
label: 'Mainline',
|
|
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
|
|
// open road
|
|
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
|
|
},
|
|
{
|
|
key: 'loadUnload',
|
|
label: 'Cargo',
|
|
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
|
|
// a loaded box on a flat
|
|
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
|
|
},
|
|
{
|
|
key: 'shiftChange',
|
|
label: 'Supervisor Shift',
|
|
tip: 'End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent\'s hat passes to the next player.',
|
|
// a pocket watch
|
|
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
|
|
},
|
|
];
|
|
|
|
function renderTurnChart(f: ReturnType<typeof view>): void {
|
|
$('tc-day').textContent = `Day ${f.day}`;
|
|
$('tc-stage').textContent = `Stage ${f.stage} of 12`;
|
|
$('tc-clock').textContent = f.clock;
|
|
$('tc-phase').textContent = f.phase;
|
|
// WHO THE TABLE IS WAITING ON. Solitaire has one seat, but the question is the same one a
|
|
// multiplayer table asks constantly, and an automatic phase is waiting on nobody.
|
|
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
|
|
$('tc-who').textContent = actorName ?? 'nobody — the Division is running itself';
|
|
|
|
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
|
|
$('tc-phases').innerHTML = PHASES.map((p, i) => {
|
|
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
|
|
return (
|
|
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
|
|
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
|
|
`<span>${esc(p.label)}</span></li>`
|
|
);
|
|
}).join('');
|
|
}
|
|
|
|
function start(): void {
|
|
const params = new URLSearchParams(location.search);
|
|
const requested = params.get('seed');
|
|
|
|
const saved = load();
|
|
if (saved && requested === null) {
|
|
game = fromSave(saved);
|
|
} else {
|
|
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
|
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
|
game = newGame(seed);
|
|
}
|
|
render();
|
|
}
|
|
|
|
function render(): void {
|
|
const f = view(game);
|
|
const menu = actionMenu(game);
|
|
|
|
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
|
|
// coordinate list into a board: you pick the thing, then click where it goes.
|
|
const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected);
|
|
const spotsAt = new Map<string, { label: string; index: number }[]>();
|
|
for (const sp of chosen?.spots ?? []) {
|
|
const key = `${sp.coord.row},${sp.coord.col}`;
|
|
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
|
|
}
|
|
|
|
renderTurnChart(f);
|
|
$('revenue').textContent = String(f.revenue);
|
|
const obj = $('objective');
|
|
obj.textContent = f.objective.note;
|
|
obj.className = f.objective.onPace ? 'pace good' : 'pace behind';
|
|
$('seed').textContent = String(game.seed);
|
|
|
|
// -- division
|
|
$('division').innerHTML = divisionSvg(f.division);
|
|
|
|
// -- board. Both renderers are shared with the replay so the two can never draw different
|
|
// pictures of the same position.
|
|
const grid = $('grid');
|
|
// The empty squares a legal placement would extend the district onto. Drawn by `officeSvg` in the
|
|
// same pass so they share its origin and its canvas: a target outside the canvas is a legal move
|
|
// the player cannot click.
|
|
const ghostCoords = [...spotsAt.keys()]
|
|
.filter((k) => !f.cells.some((c) => `${c.row},${c.col}` === k))
|
|
.map((k) => {
|
|
const [gr, gc] = k.split(',').map(Number);
|
|
return { row: gr!, col: gc! };
|
|
});
|
|
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords);
|
|
|
|
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
|
for (const [key, list] of spotsAt) {
|
|
const [gr, gc] = key.split(',').map(Number);
|
|
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
|
|
if (g) {
|
|
g.classList.add('bs-legal');
|
|
(g as unknown as HTMLElement).onclick = () => pick(key, list);
|
|
}
|
|
}
|
|
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
|
|
for (const [key, list] of spotsAt) {
|
|
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
|
|
const g = grid.querySelector(`g[data-ghost="${key}"]`);
|
|
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
|
}
|
|
|
|
function pick(key: string, list: { label: string; index: number }[]): void {
|
|
if (list.length === 1) {
|
|
const intent = menu.options[list[0]!.index];
|
|
if (intent) submit(game, intent);
|
|
selected = null;
|
|
pendingAt = null;
|
|
} else {
|
|
pendingAt = key;
|
|
}
|
|
render();
|
|
}
|
|
|
|
// -- facilities
|
|
$('facs').innerHTML =
|
|
f.facilities.length === 0
|
|
? '<div class="dim">no facilities yet</div>'
|
|
: f.facilities
|
|
.map(
|
|
(x) =>
|
|
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)} · laborers ${esc(x.laborers)} · porters ${esc(x.porters)}" tabindex="0">` +
|
|
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
|
|
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
|
|
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
|
|
x.maw
|
|
.map(
|
|
(m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`,
|
|
)
|
|
.join('') +
|
|
`</div>` +
|
|
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
|
|
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
|
|
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
|
|
x.jammed
|
|
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
|
|
: x.canFinish
|
|
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
|
|
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
|
|
}">` +
|
|
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
|
|
`</div></div>`,
|
|
)
|
|
.join('');
|
|
|
|
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
|
|
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
|
|
// The crosshatch says "not yet playable"; it used to be a `not yet` caption, which had to be read
|
|
// rather than seen. The tooltip still explains WHY, which a hatch cannot.
|
|
const cardRow = (name: string, why: string, playable: boolean | null): string =>
|
|
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
|
|
`<b>${esc(name)}</b></div>`;
|
|
|
|
const canPlay = handPlayable(game);
|
|
$('hand').innerHTML = f.hand.length
|
|
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
|
|
: '<span class="dim">empty</span>';
|
|
// Reference, not choices: the Department cards are three FACE-UP CARDS, not three decks — there
|
|
// is nothing underneath them. Drawn like the hand so they read as cards, dashed and unlit so they
|
|
// do not read as clickable.
|
|
$('depts').innerHTML = f.departments.length
|
|
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
|
|
: '<span class="dim">none</span>';
|
|
|
|
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
|
|
$('supply').innerHTML =
|
|
`<div class="dim">${total} pieces left · one may be laid per turn, during the DRAW option</div>` +
|
|
f.trackSupply
|
|
.map(
|
|
(t) =>
|
|
`<span class="card ${t.left === 0 ? 'gone' : ''}">${esc(t.piece)} <b>${t.left}</b></span>`,
|
|
)
|
|
.join(' ');
|
|
|
|
$('blocked').innerHTML =
|
|
f.blocked.length === 0
|
|
? '<li class="dim">nothing blocked</li>'
|
|
: f.blocked
|
|
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
|
|
.join('');
|
|
|
|
// -- log
|
|
const log = $('log');
|
|
log.innerHTML = game.log
|
|
.slice(-60)
|
|
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
|
|
.join('');
|
|
log.scrollTop = log.scrollHeight;
|
|
|
|
renderDistrict(f);
|
|
renderActions(menu);
|
|
|
|
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
|
|
// not then play a backlog of everything that happened while it was off.
|
|
const cues = game.cues.splice(0, game.cues.length);
|
|
if (soundOn) for (const c of cues) play(c);
|
|
|
|
save();
|
|
}
|
|
|
|
function renderDistrict(f: ReturnType<typeof view>): void {
|
|
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
|
const sec = $('district');
|
|
if (open) sec.classList.remove('folded');
|
|
else sec.classList.add('folded');
|
|
|
|
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
|
|
const crew = f.cells.filter((c) => c.tray).length;
|
|
$('districtsummary').textContent =
|
|
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
|
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
|
|
|
const btn = $('districttoggle');
|
|
btn.textContent = districtMode === 'auto' ? (open ? 'auto · open' : 'auto · folded') : districtMode;
|
|
btn.onclick = () => {
|
|
// auto -> the opposite of what it is doing now -> back to auto.
|
|
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
|
render();
|
|
};
|
|
}
|
|
|
|
function boxes(items: string[], cap: number): string {
|
|
let out = '';
|
|
for (let i = 0; i < Math.max(cap, items.length); i++) {
|
|
const v = items[i];
|
|
out += `<span class="box ${v ? 'f' : 'empty'}">${v ? esc(v) : '·'}</span>`;
|
|
}
|
|
return out || '<span class="dim">—</span>';
|
|
}
|
|
|
|
function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
|
const el = $('actions');
|
|
|
|
if (game.state.status !== 'active') {
|
|
const o = game.state.outcome;
|
|
el.innerHTML =
|
|
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
|
|
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
|
|
`final Revenue ${game.state.players[0]?.revenue ?? 0} against a target of ${view(game).objective.target}</div>` +
|
|
`<button id="again">new game</button>`;
|
|
$('again').onclick = () => {
|
|
clearSave();
|
|
location.search = '';
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (menu.direct.length === 0 && menu.placeable.length === 0) {
|
|
el.innerHTML = '<div class="dim">nothing to decide — the engine is running the Division</div>';
|
|
return;
|
|
}
|
|
|
|
const apply = (index: number): void => {
|
|
const intent = menu.options[index];
|
|
if (intent) submit(game, intent);
|
|
selected = null;
|
|
pendingAt = null;
|
|
render();
|
|
};
|
|
|
|
// A long label is two things: the action, and why it is offered. Put the first on the button and
|
|
// the second on the tooltip, or the list crowds out the board.
|
|
const actionButton = (label: string, index: number): string => {
|
|
const cut = label.indexOf(' — ');
|
|
const head = cut > 0 ? label.slice(0, cut) : label;
|
|
const rest = cut > 0 ? label.slice(cut + 3) : '';
|
|
return (
|
|
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
|
|
);
|
|
};
|
|
|
|
// A heading over the buttons, so the panel says what it is before it says what is in it. The
|
|
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
|
|
// no worker able to act, the only thing offered is "End my Cargo phase".
|
|
let html = '<h3 class="actions-hd">Actions</h3>';
|
|
|
|
html += menu.direct
|
|
.map(
|
|
(g) =>
|
|
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
|
g.actions
|
|
.map((a) => {
|
|
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
|
|
// the INTENT, not the label: matching button text would break the moment the wording
|
|
// changed, and would have caught `switch.end` too.
|
|
const blocked = menu.options[a.index]?.type === 'draw.end' && game.mustPlayCard;
|
|
return blocked
|
|
? `<button class="act blocked" disabled data-tip="You have drawn a card. Play it or discard it to a Department slot before ending your turn — §6.2 holds you to three cards.">${esc(a.label)} — play or discard first</button>`
|
|
: actionButton(a.label, a.index);
|
|
})
|
|
.join('') +
|
|
`</div>`,
|
|
)
|
|
.join('');
|
|
|
|
// Subject first, location second. Picking a card then a square is how the choice is actually made;
|
|
// one flat list of every card-square-rotation combination was unreadable.
|
|
for (const group of menu.placeable) {
|
|
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
|
|
for (const item of group.items) {
|
|
const open = selected === item.subjectKey;
|
|
html +=
|
|
`<button class="subj ${open ? 'open' : ''}" data-subj="${esc(item.subjectKey)}">` +
|
|
`${esc(item.subject)} <span class="dim">${item.spots.length} spot${item.spots.length === 1 ? '' : 's'}</span>` +
|
|
`</button>`;
|
|
if (open) {
|
|
// Once a square is picked, show only that square's rotations — the rest is noise.
|
|
const shown = pendingAt
|
|
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
|
: item.spots;
|
|
html +=
|
|
`<div class="spots"><div class="dim">` +
|
|
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
|
|
`</div>` +
|
|
shown.map((sp) => `<button class="act" data-i="${sp.index}">${esc(sp.label)}</button>`).join('') +
|
|
`</div>`;
|
|
}
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
el.innerHTML = html;
|
|
|
|
for (const b of Array.from(el.querySelectorAll('button.act'))) {
|
|
const node = b as HTMLElement;
|
|
node.onclick = () => apply(Number(node.dataset['i']));
|
|
}
|
|
for (const b of Array.from(el.querySelectorAll('button.subj'))) {
|
|
const node = b as HTMLElement;
|
|
node.onclick = () => {
|
|
const key = node.dataset['subj'] ?? null;
|
|
selected = selected === key ? null : key;
|
|
pendingAt = null;
|
|
render();
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Saving. localStorage only — nothing leaves the browser.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Write the game out as a file.
|
|
*
|
|
* The save IS the replay: a seed and the moves made, which the engine can replay exactly. A few
|
|
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
|
|
* where a rendered page would have been megabytes.
|
|
*/
|
|
function downloadSave(): void {
|
|
const data = JSON.stringify(toSave(game), null, 1);
|
|
const blob = new Blob([data], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `station-master-seed${game.seed}-day${game.state.clock.day}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function save(): void {
|
|
try {
|
|
localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game)));
|
|
} catch {
|
|
// A full or disabled localStorage must not take the game down with it.
|
|
}
|
|
}
|
|
|
|
function load(): ReturnType<typeof toSave> | null {
|
|
try {
|
|
const raw = localStorage.getItem(SAVE_KEY);
|
|
return raw ? (JSON.parse(raw) as ReturnType<typeof toSave>) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function clearSave(): void {
|
|
try {
|
|
localStorage.removeItem(SAVE_KEY);
|
|
} catch {
|
|
/* nothing to do */
|
|
}
|
|
}
|
|
|
|
// BOTH stylesheets. The board is SVG built by the shared renderers, and every shape it draws is
|
|
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
|
|
// background, so the cards are drawn correctly and are simply invisible.
|
|
const pageStyle = document.createElement('style');
|
|
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS;
|
|
document.head.appendChild(pageStyle);
|
|
installTooltips();
|
|
|
|
const saveBtn = document.getElementById('savefile');
|
|
if (saveBtn) saveBtn.onclick = downloadSave;
|
|
|
|
const soundBtn = document.getElementById('sound');
|
|
if (soundBtn) {
|
|
const paint = (): void => {
|
|
soundBtn.textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
|
|
};
|
|
soundBtn.onclick = () => {
|
|
soundOn = !soundOn;
|
|
paint();
|
|
// Confirm the change audibly — the one press where a sound is unambiguously wanted, and it
|
|
// doubles as the user gesture the browser needs before any audio may start.
|
|
if (soundOn) play('stage');
|
|
};
|
|
paint();
|
|
}
|
|
|
|
start();
|