docs/arcade/arcade.js
Rama main · réplica de lectura del repositorio original.
/* The arcade's shared half: one harness, five cabinets.
*
* Every game here grows the same agent. What a player collects in the runner is
* still there in the canyon, because all five read and write one record in
* `localStorage` - that record is the point of the arcade, and the games are
* five ways of filling it in.
*
* Amigo himself is transcribed from `docs/assets/amigo.svg`, one letter per
* pixel, so the sprite on screen and the mascot in the README are the same
* drawing rather than two that drifted apart.
*/
(function (global) {
"use strict";
var KEY = "agenticos.arcade.harness.v1";
var REPO = "https://github.com/vstorm-co/agenticos";
/* The harness catalogue. Each part is a real thing an AgenticOS agent is
* given - a capability, a skill, a context file, a memory, an MCP server -
* and each carries a perk that changes how the games play, so the collection
* is a progression and not a sticker album. */
var PARTS = [
{
id: "skill.coordination",
kind: "skill",
name: "Coordination",
blurb: "A smaller team with a clear task beats a room full of meetings.",
where: "Just One More Agent",
how: "Ship the comma before time or money runs out",
perk: "Agent running costs are reduced by ten percent.",
},
{
id: "data.signal_filter",
kind: "data",
name: "Signal filter",
blurb: "Keep the instructions. Lose the thousand lines of verbose logs.",
where: "Context Tetris",
how: "Answer one task by collecting all three context types",
perk: "Start Context Tetris with one extra discard.",
},
{
id: "tool.web_search",
kind: "tool",
name: "Web search",
blurb: "It looks things up instead of guessing.",
where: "Desert Run",
how: "Bank 30 tokens, equip Web search, or collect four tool cells",
perk: "Tokens pull closer in Desert Run; Web search speeds up the office.",
},
{
id: "tool.browser",
kind: "tool",
name: "Browser control",
blurb: "It drives a real browser, clicks and all.",
where: "Capability Canyon",
how: "Reach the publish flag",
perk: "A second jump in mid-air, everywhere.",
},
{
id: "tool.charts",
kind: "tool",
name: "Charts",
blurb: "Numbers come back as a picture, not a paragraph.",
where: "Desert Run",
how: "Survive a full minute",
perk: "The budget meter holds a fifth more.",
},
{
id: "skill.retry",
kind: "skill",
name: "Retry policy",
blurb: "A failed call is retried with a backoff instead of surfacing.",
where: "Desert Run",
how: "Fail three shifts - failure is the curriculum",
perk: "One free hit per run, in the runner and the canyon.",
},
{
id: "skill.compaction",
kind: "skill",
name: "Context compaction",
blurb: "A long conversation is folded down before it overflows.",
where: "Capability Canyon",
how: "Bank 40 tokens or collect four skill cells in Context Tetris",
perk: "The budget drains a quarter slower.",
},
{
id: "skill.handoff",
kind: "skill",
name: "Delegation",
blurb: "Work it should not do itself goes to a sub-agent.",
where: "Night Shift",
how: "Close tickets inside three minutes or equip Delegation in the office",
perk: "Amigo walks faster on the shift floor; delegation reduces office chaos.",
},
{
id: "skill.structured",
kind: "skill",
name: "Structured output",
blurb: "Pydantic AI validates the answer into a typed model, or refuses it.",
where: "Capability Canyon",
how: "Take the schema orb above the spikes",
perk: "Hallucinations drift slower - structure tames them.",
},
{
id: "context.playbook",
kind: "context",
name: "Company playbook",
blurb: "Standing knowledge bound to the agent, not pasted per message.",
where: "Night Shift",
how: "Finish Night Shift or equip Company docs in the office",
perk: "The shift shows the next station; Company docs calm the office.",
},
{
id: "context.brand",
kind: "context",
name: "Brand voice",
blurb: "It answers in the company's words rather than the model's.",
where: "The arcade",
how: "Play any three cabinets",
perk: "Amigo's poncho picks up a gold trim.",
},
{
id: "memory.session",
kind: "memory",
name: "Session memory",
blurb: "It remembers the last thing you said. Only that far.",
where: "Capability Canyon",
how: "Take the knowledge orb out of the drop",
perk: "The canyon restarts at your last checkpoint.",
},
{
id: "memory.long_term",
kind: "memory",
name: "Long-term memory",
blurb: "What it learned last week survives into this one.",
where: "Night Shift",
how: "Work two full shifts",
perk: "A fourth heart in the canyon.",
},
{
id: "data.vectors",
kind: "data",
name: "Embedded documents",
blurb: "Chunked, embedded and searchable by meaning in pgvector.",
where: "Capability Canyon",
how: "Collect three document chunks in one run",
perk: "The canyon map shows every capability, playbook or not.",
},
{
id: "data.clean",
kind: "data",
name: "Clean customer data",
blurb: "The CRM export, minus the duplicates, the nulls and the test rows.",
where: "Night Shift",
how: "Take the messy export through the parser",
perk: "Half as much bad data blows across the desert.",
},
{
id: "mcp.github",
kind: "mcp",
name: "GitHub MCP",
blurb: "Issues, pull requests and releases, from a chat message.",
where: "Any continue screen",
how: "Open the repository, equip GitHub MCP, or collect four MCP cells",
perk: "The office ships faster and focus recharges sooner. MCP cells restore discards.",
},
{
id: "mcp.linear",
kind: "mcp",
name: "Linear MCP",
blurb: "One connection, fourteen tools, three of them allowed.",
where: "Night Shift",
how: "Carry the tool back to the chat desk",
perk: "A sixth ticket lands on the shift.",
},
{
id: "mcp.gdrive",
kind: "mcp",
name: "Google Drive MCP",
blurb: "A shared drive the agent can read, with the sharing rules intact.",
where: "Night Shift",
how: "Connect the drive the sales folder lives on",
perk: "An extra document chunk appears in the canyon.",
},
{
id: "mcp.notion",
kind: "mcp",
name: "Notion MCP",
blurb: "The team wiki, as pages the agent can search and write back to.",
where: "Capability Canyon",
how: "Find the page hidden past the second drop",
perk: "The night shift names the stop after the next one.",
},
{
id: "model.claude",
kind: "model",
name: "Claude Opus",
blurb: "The default here: long context, careful with tools.",
where: "Desert Run",
how: "Catch the Anthropic chip",
perk: "Careful calls: the budget drains a tenth slower.",
},
{
id: "model.gpt",
kind: "model",
name: "GPT",
blurb: "The one everybody already has a key for.",
where: "Desert Run",
how: "Catch the OpenAI chip",
perk: "Every token pickup counts double.",
},
{
id: "model.qwen",
kind: "model",
name: "Qwen",
blurb: "Open weights, run on your own hardware, no per-token invoice.",
where: "Night Shift",
how: "Answer the thirty-thousand-dollar invoice",
perk: "Self-hosted: the first fifteen seconds of a run are free.",
},
{
id: "model.deepseek",
kind: "model",
name: "DeepSeek",
blurb: "Reasoning at a price that does not need a meeting.",
where: "Capability Canyon",
how: "Find the chip at the bottom of the deep drop",
perk: "Cheap tokens: each one refills more budget.",
},
{
id: "vstorm.badge",
kind: "vstorm",
name: "Vstorm badge",
blurb: "The people who build this. Rare, and it shows.",
where: "Desert Run",
how: "Catch the badge - it only flies past once in a while",
perk: "Amigo wears the colours, and every shift starts with a bigger budget.",
},
];
var KINDS = {
tool: { label: "Capabilities", color: "#2e8b57" },
skill: { label: "Skills", color: "#f2c14e" },
context: { label: "Context", color: "#3d9fb5" },
memory: { label: "Memory", color: "#9b59b6" },
data: { label: "Knowledge", color: "#d4763a" },
mcp: { label: "MCP", color: "#4b6bdd" },
model: { label: "Models", color: "#c8412b" },
vstorm: { label: "Vstorm", color: "#7b5cf0" },
};
var DEFAULT_STATE = {
v: 1,
unlocked: [],
played: [],
stats: {
agentsBest: 0,
agentsRuns: 0,
agentsWins: 0,
contextBest: 0,
contextRuns: 0,
contextTasks: 0,
runnerBest: 0,
runnerRuns: 0,
runnerDeaths: 0,
runnerLongest: 0,
canyonClears: 0,
canyonBestTokens: 0,
canyonDepth: 0,
shiftClears: 0,
shiftBest: 0,
lifetimeTokens: 0,
},
};
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
/* Every read and write is wrapped: a private window, blocked site data or a
* thumbnail capture all answer with a throw rather than an empty string.
*
* The last known record is also held in memory, and that copy is what the
* games read. Without it, a browser that refuses to store anything would
* throw away each write the moment it happened - the next `read()` would hand
* back defaults, and an unlock earned thirty seconds ago would already be
* gone. This way a blocked browser still plays a whole session properly; it
* just forgets between visits. */
var cache = null;
function load() {
var state = clone(DEFAULT_STATE);
try {
var raw = global.localStorage.getItem(KEY);
if (!raw) return state;
var saved = JSON.parse(raw);
if (!saved || saved.v !== 1) return state;
if (Array.isArray(saved.unlocked)) state.unlocked = saved.unlocked.slice();
if (Array.isArray(saved.played)) state.played = saved.played.slice();
if (saved.stats) {
Object.keys(state.stats).forEach(function (key) {
if (typeof saved.stats[key] === "number") state.stats[key] = saved.stats[key];
});
}
} catch (err) {
return clone(DEFAULT_STATE);
}
return state;
}
function read() {
if (!cache) cache = load();
return clone(cache);
}
function write(state) {
cache = clone(state);
try {
global.localStorage.setItem(KEY, JSON.stringify(state));
} catch (err) {
/* Nothing to do and nothing worth saying: the run still counts through
* the copy above, it just does not outlive the tab. */
}
return state;
}
/* Drop the in-memory copy so the next read goes back to storage - for a page
* restored from the back/forward cache, or another tab writing the record. */
function invalidate() {
cache = null;
}
var listeners = [];
function emit(event) {
listeners.forEach(function (fn) {
fn(event);
});
}
var Harness = {
all: PARTS,
kinds: KINDS,
repo: REPO,
state: read,
part: function (id) {
var found = null;
PARTS.forEach(function (p) {
if (p.id === id) found = p;
});
return found;
},
has: function (id) {
return read().unlocked.indexOf(id) !== -1;
},
/* Returns the part when it was newly unlocked, and null when the player
* already had it - which is what decides whether a toast fires. */
unlock: function (id) {
var part = Harness.part(id);
if (!part) return null;
var state = read();
if (state.unlocked.indexOf(id) !== -1) return null;
state.unlocked.push(id);
write(state);
emit({ type: "unlock", part: part });
return part;
},
played: function (game) {
var state = read();
if (state.played.indexOf(game) === -1) {
state.played.push(game);
write(state);
}
if (read().played.length >= 3) Harness.unlock("context.brand");
},
/* `best` keeps the larger number, `add` accumulates, `set` overwrites. The
* shift's clock is the one stat where smaller is better, so it is stored
* through `best` as a negative and read back by the hub. */
record: function (patch) {
var state = read();
Object.keys(patch).forEach(function (key) {
var entry = patch[key];
var current = state.stats[key] || 0;
if (entry.best !== undefined) state.stats[key] = Math.max(current, entry.best);
else if (entry.add !== undefined) state.stats[key] = current + entry.add;
else if (entry.set !== undefined) state.stats[key] = entry.set;
});
write(state);
return state.stats;
},
stats: function () {
return read().stats;
},
reset: function () {
write(clone(DEFAULT_STATE));
emit({ type: "reset" });
},
invalidate: invalidate,
key: KEY,
on: function (fn) {
listeners.push(fn);
},
};
/* Amigo, 16 by 19. Rows 0 to 16 are the body; the legs swap per frame exactly
* the way `docs/assets/amigo-walk.svg` swaps them. */
var PALETTE = {
".": null,
K: "#4a2c17",
H: "#d99a5b",
R: "#8e1b1b",
F: "#f1c27d",
W: "#ffffff",
E: "#1a1f2e",
M: "#5b3a1e",
P: "#c8412b",
G: "#2e8b57",
Y: "#f2c14e",
};
var BODY = [
"......KKKK......",
".....KHHHHK.....",
"....KHHHHHHK....",
"KHHHHHHHHHHHHHHK",
".KRRRRRRRRRRRRK.",
"....KFFFFFFK....",
"....KWEFFWEK....",
"....KEEFFEEK....",
"....KEEFFEEK....",
"...KMMMFFMMMK...",
"...KPPPPPPPPK...",
"..KPPGGGGGGPPK..",
".KPPPPPPPPPPPPK.",
".KPGGGGGGGGGGPK.",
".KPPPPPPPPPPPPK.",
".KPYYYYYYYYYYPK.",
".KKKKKKKKKKKKKK.",
];
var LEGS = {
stand: ["....KKK..KKK....", "....KKK..KKK...."],
step: ["...KKK....KKK...", "...KKK....KKK..."],
air: ["...KK......KK...", "..KK........KK.."],
tuck: ["...KKKK..KKKK...", "................"],
};
function bake(legs, palette) {
var rows = BODY.concat(LEGS[legs]);
var canvas = global.document.createElement("canvas");
canvas.width = 16;
canvas.height = rows.length;
var g = canvas.getContext("2d");
rows.forEach(function (row, y) {
for (var x = 0; x < row.length; x++) {
var color = palette[row[x]];
if (!color) continue;
g.fillStyle = color;
g.fillRect(x, y, 1, 1);
}
});
return canvas;
}
/* One cosmetic, earned rather than chosen: the brand-voice context file turns
* the poncho's bottom band gold and warms the green stripes to match. */
function paletteFor(state) {
var palette = {};
Object.keys(PALETTE).forEach(function (key) {
palette[key] = PALETTE[key];
});
if (state.unlocked.indexOf("context.brand") !== -1) {
palette.G = "#c79a2e";
palette.Y = "#ffe9a8";
}
if (state.unlocked.indexOf("vstorm.badge") !== -1) {
palette.R = "#7b5cf0";
}
return palette;
}
function sprites() {
var palette = paletteFor(read());
return {
stand: bake("stand", palette),
step: bake("step", palette),
air: bake("air", palette),
tuck: bake("tuck", palette),
};
}
/* The shell every cabinet shares: a toast for an unlock, and the continue
* screen with the repository button on it. */
function toast(host, message, tone) {
var el = host.querySelector(".arc-toast");
if (!el) {
el = global.document.createElement("div");
el.className = "arc-toast";
host.appendChild(el);
}
el.textContent = message;
el.dataset.tone = tone || "plain";
el.classList.add("is-shown");
global.clearTimeout(el._timer);
el._timer = global.setTimeout(function () {
el.classList.remove("is-shown");
}, 2600);
}
function unlockToast(host, part) {
if (!part) return;
Sound.play(part.kind === "vstorm" ? "vstorm" : "unlock");
toast(host, "Harness upgraded: " + part.name + " - " + part.perk, "unlock");
}
var NAGS = [
"Continue? Insert one GitHub star.",
"The agent respawns. The repository does not star itself.",
"Amigo works for free. A star is the tip.",
"This cabinet accepts stars only.",
];
/* The continue screen. The repository button is the joke and the call to
* action at once, and opening it is what connects the GitHub MCP server -
* the one part of the harness you cannot earn by playing well. */
function continueScreen(options) {
/* The catalogue promises that connecting the GitHub server stops the
* arcade asking for a star, so it has to actually stop. */
var starred = Harness.has("mcp.github");
var nag = starred
? "GitHub MCP connected. No more nagging."
: NAGS[Math.floor(Math.random() * NAGS.length)];
var stats = (options.stats || [])
.map(function (row) {
return '<span><b>' + row[0] + "</b>" + row[1] + "</span>";
})
.join("");
return (
'<h2 class="' +
(options.won ? "is-win" : "") +
'">' +
options.title +
"</h2>" +
(options.reason ? '<p class="arc-reason">' + options.reason + "</p>" : "") +
(options.note ? "<p>" + options.note + "</p>" : "") +
(stats ? '<div class="arc-tally">' + stats + "</div>" : "") +
'<p class="' +
(starred ? "arc-hint" : "arc-nag") +
'">' +
nag +
"</p>" +
'<div class="arc-actions">' +
'<button type="button" class="arc-btn is-primary" data-action="retry">' +
(options.retryLabel || "Run it again") +
"</button>" +
'<a class="arc-btn is-repo" data-action="repo" href="' +
REPO +
'" target="_blank" rel="noopener">Open the repo</a>' +
"</div>" +
'<p class="arc-hint">Space to continue · <a href="./">back to the arcade</a></p>'
);
}
/* Wires the two buttons a continue screen has. The repository link is allowed
* to do its own navigation; the unlock rides along on the same click. */
function wireContinue(overlay, onRetry) {
if (!overlay.hasAttribute("tabindex")) overlay.setAttribute("tabindex", "-1");
if (!overlay.hidden) {
try {
overlay.focus({ preventScroll: true });
} catch (err) {
overlay.focus();
}
}
var retry = overlay.querySelector('[data-action="retry"]');
if (retry) {
retry.addEventListener("click", function () {
onRetry();
});
}
var repo = overlay.querySelector('[data-action="repo"]');
if (repo) {
repo.addEventListener("click", function () {
var part = Harness.unlock("mcp.github");
if (part) unlockToast(overlay.parentNode, part);
});
}
}
/* Sound, synthesised on the spot.
*
* No files: every effect here is one or two oscillators and a noise burst
* through a gain envelope, which keeps the arcade a handful of text files and
* means nothing has to load before the first jump. The context is built on
* the first gesture, because a browser refuses to start audio before one, and
* the preference survives in `localStorage` - a muted arcade stays muted.
*/
var SOUND_KEY = "agenticos.arcade.sound.v1";
var Sound = (function () {
var ctx = null;
var master = null;
var on = true;
try {
on = global.localStorage.getItem(SOUND_KEY) !== "off";
} catch (err) {
on = true;
}
function wake() {
if (!on) return null;
if (!ctx) {
var Ctor = global.AudioContext || global.webkitAudioContext;
if (!Ctor) return null;
ctx = new Ctor();
master = ctx.createGain();
master.gain.value = 0.22;
master.connect(ctx.destination);
}
if (ctx.state === "suspended") ctx.resume();
return ctx;
}
/* One voice: a shape, a pitch that may slide, and an envelope. Everything
* the games ask for is built out of a few of these. */
function tone(opts) {
var c = wake();
if (!c) return;
var at = c.currentTime + (opts.delay || 0);
var osc = c.createOscillator();
var gain = c.createGain();
osc.type = opts.type || "square";
osc.frequency.setValueAtTime(opts.from, at);
if (opts.to && opts.to !== opts.from) {
osc.frequency.exponentialRampToValueAtTime(Math.max(20, opts.to), at + opts.dur);
}
var peak = opts.gain === undefined ? 0.5 : opts.gain;
gain.gain.setValueAtTime(0.0001, at);
gain.gain.exponentialRampToValueAtTime(peak, at + 0.008);
gain.gain.exponentialRampToValueAtTime(0.0001, at + opts.dur);
osc.connect(gain);
gain.connect(master);
osc.start(at);
osc.stop(at + opts.dur + 0.02);
}
/* Noise, for anything that is an impact rather than a note. */
function noise(opts) {
var c = wake();
if (!c) return;
var at = c.currentTime + (opts.delay || 0);
var frames = Math.floor(c.sampleRate * opts.dur);
var buffer = c.createBuffer(1, frames, c.sampleRate);
var data = buffer.getChannelData(0);
for (var i = 0; i < frames; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / frames);
var src = c.createBufferSource();
src.buffer = buffer;
var filter = c.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(opts.cut || 1400, at);
var gain = c.createGain();
gain.gain.setValueAtTime(opts.gain === undefined ? 0.35 : opts.gain, at);
gain.gain.exponentialRampToValueAtTime(0.0001, at + opts.dur);
src.connect(filter);
filter.connect(gain);
gain.connect(master);
src.start(at);
}
function chord(notes, step, opts) {
notes.forEach(function (freq, i) {
tone({
from: freq,
to: freq,
dur: opts && opts.dur ? opts.dur : 0.12,
type: (opts && opts.type) || "square",
gain: (opts && opts.gain) || 0.4,
delay: i * step,
});
});
}
var EFFECTS = {
jump: function () {
tone({ from: 380, to: 760, dur: 0.12, type: "square", gain: 0.35 });
},
jump2: function () {
tone({ from: 520, to: 980, dur: 0.11, type: "square", gain: 0.3 });
},
land: function () {
noise({ dur: 0.07, cut: 700, gain: 0.22 });
},
token: function () {
tone({ from: 988, to: 988, dur: 0.05, type: "square", gain: 0.3 });
tone({ from: 1319, to: 1319, dur: 0.07, type: "square", gain: 0.26, delay: 0.05 });
},
pickup: function () {
chord([659, 880, 1175], 0.06, { dur: 0.1, type: "triangle", gain: 0.42 });
},
stomp: function () {
tone({ from: 420, to: 110, dur: 0.16, type: "square", gain: 0.4 });
noise({ dur: 0.1, cut: 900, gain: 0.25 });
},
hurt: function () {
tone({ from: 320, to: 90, dur: 0.28, type: "sawtooth", gain: 0.4 });
},
deny: function () {
tone({ from: 220, to: 180, dur: 0.14, type: "square", gain: 0.34 });
tone({ from: 165, to: 130, dur: 0.18, type: "square", gain: 0.3, delay: 0.12 });
},
blip: function () {
tone({ from: 760, to: 760, dur: 0.04, type: "square", gain: 0.16 });
},
ticket: function () {
chord([523, 784], 0.07, { dur: 0.12, type: "triangle", gain: 0.36 });
},
unlock: function () {
chord([523, 659, 784, 1047], 0.075, { dur: 0.16, type: "triangle", gain: 0.42 });
},
win: function () {
chord([523, 659, 784, 1047, 1319], 0.09, { dur: 0.22, type: "triangle", gain: 0.45 });
tone({ from: 1047, to: 1047, dur: 0.5, type: "square", gain: 0.2, delay: 0.45 });
},
fail: function () {
tone({ from: 392, to: 370, dur: 0.18, type: "square", gain: 0.36 });
tone({ from: 311, to: 294, dur: 0.22, type: "square", gain: 0.34, delay: 0.16 });
tone({ from: 233, to: 110, dur: 0.5, type: "sawtooth", gain: 0.34, delay: 0.34 });
},
bill: function () {
tone({ from: 160, to: 55, dur: 0.55, type: "sawtooth", gain: 0.42 });
noise({ dur: 0.3, cut: 500, gain: 0.2 });
},
sludge: function () {
tone({ from: 180, to: 120, dur: 0.22, type: "sawtooth", gain: 0.3 });
noise({ dur: 0.18, cut: 380, gain: 0.22 });
},
vstorm: function () {
chord([784, 988, 1175, 1568, 1976], 0.06, { dur: 0.3, type: "triangle", gain: 0.4 });
},
pause: function () {
tone({ from: 440, to: 330, dur: 0.1, type: "triangle", gain: 0.25 });
},
};
return {
get enabled() {
return on;
},
play: function (name) {
if (!on) return;
var effect = EFFECTS[name];
if (!effect) return;
try {
effect();
} catch (err) {
/* An audio graph that refuses to build is not a reason to stop the
* game; the run carries on silently. */
}
},
set: function (next) {
on = !!next;
try {
global.localStorage.setItem(SOUND_KEY, on ? "on" : "off");
} catch (err) {
/* Unsaved, but honoured for this tab. */
}
if (on) wake();
},
toggle: function () {
Sound.set(!on);
if (on) Sound.play("blip");
return on;
},
};
})();
/* The speaker control every page carries, plus M as its shortcut. */
function soundButton(button) {
function label() {
button.textContent = Sound.enabled ? "Sound on" : "Sound off";
button.setAttribute("aria-pressed", Sound.enabled ? "true" : "false");
}
label();
button.addEventListener("click", function () {
Sound.toggle();
label();
});
global.document.addEventListener("keydown", function (e) {
if (e.code === "KeyM" && !e.metaKey && !e.ctrlKey && !e.altKey) {
Sound.toggle();
label();
}
});
}
/* Pause. Three ways in, one way out, and the tab losing focus counts as one
* of them: a runner whose budget drains while you answer a message elsewhere
* is a runner that punishes you for leaving, which no arcade cabinet does.
* `R` restarts without going through the continue screen. */
function pauser(screen, isPlaying, restart, release) {
var panel = global.document.createElement("div");
panel.className = "arc-paused";
panel.hidden = true;
panel.innerHTML = "<h3>Paused</h3><p>P to resume · R to restart</p>";
screen.appendChild(panel);
var api = { on: false };
function set(next) {
api.on = isPlaying() ? next : false;
panel.hidden = !api.on;
/* A window that loses focus mid-stride is never told the key came back
* up, so the game would resume with Amigo still walking or still ducking
* into whatever killed him. */
if (api.on && release) release();
}
global.document.addEventListener("keydown", function (e) {
if (e.code === "KeyP") {
if (!isPlaying()) return;
e.preventDefault();
set(!api.on);
} else if (e.code === "KeyR" && restart) {
e.preventDefault();
set(false);
restart();
}
});
global.addEventListener("blur", function () {
if (release) release();
set(true);
});
global.document.addEventListener("visibilitychange", function () {
if (global.document.hidden) {
if (release) release();
set(true);
}
});
return api;
}
/* On-screen controls for a touch screen.
*
* A phone held upright cannot give a two-to-one canvas more height than half
* its width, so the bottom half of the page is dead space. This puts the
* controls there, and it does it by synthesising the same key events the
* keyboard sends - the games keep one input path, and a thumb and a keyboard
* are the same thing to them.
*/
var LAYOUTS = {
runner: [
{ code: "ArrowDown", label: "Duck" },
{ code: "Space", label: "Jump", main: true },
],
canyon: [
{ code: "ArrowLeft", label: "Left" },
{ code: "ArrowRight", label: "Right" },
{ code: "Space", label: "Jump", main: true },
],
shift: [
{ code: "ArrowLeft", label: "Left" },
{ code: "ArrowUp", label: "Up" },
{ code: "ArrowDown", label: "Down" },
{ code: "ArrowRight", label: "Right" },
{ code: "KeyE", label: "Use", main: true },
],
};
function touchpad(page, layout) {
if (!global.matchMedia || !global.matchMedia("(pointer: coarse)").matches) return null;
var keys = LAYOUTS[layout];
if (!keys) return null;
var pad = global.document.createElement("div");
pad.className = "arc-pad";
pad.setAttribute("data-layout", layout);
page.classList.add("has-pad");
keys.forEach(function (key) {
var button = global.document.createElement("button");
button.type = "button";
button.className = "arc-pad-key" + (key.main ? " is-main" : "");
button.setAttribute("data-code", key.code);
button.textContent = key.label;
button.setAttribute("aria-label", key.label);
function down(e) {
e.preventDefault();
button.classList.add("is-held");
if (button.setPointerCapture && e.pointerId !== undefined) {
try {
button.setPointerCapture(e.pointerId);
} catch (err) {
/* Capture is a convenience: without it a finger that slides off the
* button still releases through the pointerup below. */
}
}
global.document.dispatchEvent(
new global.KeyboardEvent("keydown", { code: key.code, bubbles: true })
);
}
function up(e) {
if (e) e.preventDefault();
if (!button.classList.contains("is-held")) return;
button.classList.remove("is-held");
global.document.dispatchEvent(
new global.KeyboardEvent("keyup", { code: key.code, bubbles: true })
);
}
button.addEventListener("pointerdown", down);
button.addEventListener("pointerup", up);
button.addEventListener("pointercancel", up);
/* A key that is still held when the page goes away is a key that stays
* held forever. */
global.addEventListener("blur", function () {
up(null);
});
pad.appendChild(button);
});
var legend = page.querySelector(".arc-legend");
if (legend) page.insertBefore(pad, legend);
else page.appendChild(pad);
return pad;
}
/* Size the canvas to whatever room the stage has, in CSS pixels, keeping its
* aspect ratio. The canvas keeps its own small backing resolution - scaling
* it here is what makes a sixteen-pixel sprite fill a 27-inch screen without
* the game logic knowing anything about it. */
function fit(screen, canvas) {
var stage = screen.parentNode;
function apply() {
var box = stage.getBoundingClientRect();
var frame = 6; /* the cabinet's own border and its shadow */
var room = { w: box.width - frame, h: box.height - frame };
if (room.w <= 0 || room.h <= 0) return;
var scale = Math.min(room.w / canvas.width, room.h / canvas.height);
if (!isFinite(scale) || scale <= 0) return;
canvas.style.width = Math.floor(canvas.width * scale) + "px";
canvas.style.height = Math.floor(canvas.height * scale) + "px";
}
apply();
global.addEventListener("resize", apply);
global.addEventListener("orientationchange", function () {
global.setTimeout(apply, 120);
});
global.document.addEventListener("fullscreenchange", function () {
global.setTimeout(apply, 80);
});
if (global.ResizeObserver) new global.ResizeObserver(apply).observe(stage);
return apply;
}
/* Real fullscreen on top of the full-window layout: the button and F do the
* same thing, and the label follows the actual state rather than a flag of
* our own - leaving fullscreen with Escape never reaches the button. */
function fullscreen(page, button) {
function supported() {
return !!(page.requestFullscreen || page.webkitRequestFullscreen);
}
if (!supported()) {
button.hidden = true;
return;
}
/* A phone in fullscreen can be asked to stay landscape, which is the
* orientation every one of these pictures is drawn for. Desktop browsers
* and iOS both refuse, and refusing is fine. */
function landscape() {
var orientation = global.screen && global.screen.orientation;
if (!orientation || !orientation.lock) return;
try {
var locking = orientation.lock("landscape");
if (locking && locking.catch) locking.catch(function () {});
} catch (err) {
/* Not supported here. */
}
}
function toggle() {
if (global.document.fullscreenElement) {
global.document.exitFullscreen();
} else if (page.requestFullscreen) {
page.requestFullscreen().then(landscape, function () {
/* Refused by the browser - the windowed layout is already full-page,
* so there is nothing to recover from. */
});
} else {
page.webkitRequestFullscreen();
landscape();
}
}
button.addEventListener("click", toggle);
global.document.addEventListener("keydown", function (e) {
if (e.code === "KeyF" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
toggle();
}
});
global.document.addEventListener("fullscreenchange", function () {
button.textContent = global.document.fullscreenElement ? "Exit fullscreen" : "Fullscreen";
});
}
/* A fixed 60Hz step, so a 120Hz screen does not run the game at double speed
* and a background tab does not spiral when it comes back. */
function loop(update, draw) {
var acc = 0;
var last = global.performance.now();
function frame(now) {
acc += Math.min(80, now - last);
last = now;
while (acc >= 1000 / 60) {
update();
acc -= 1000 / 60;
}
draw();
global.requestAnimationFrame(frame);
}
global.requestAnimationFrame(frame);
}
global.Amigo = {
harness: Harness,
parts: PARTS,
kinds: KINDS,
repo: REPO,
sprites: sprites,
toast: toast,
unlockToast: unlockToast,
continueScreen: continueScreen,
wireContinue: wireContinue,
loop: loop,
fit: fit,
fullscreen: fullscreen,
pauser: pauser,
touchpad: touchpad,
sound: Sound,
soundButton: soundButton,
};
})(window);