Ctrl K
ComunidadGuías…

docs/arcade/shift.html

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
    <title>Night Shift - Amigo's Arcade</title>
    <meta
      name="description"
      content="A quiet control room and three open tickets. Carry what each station needs, in the right order, with one pair of hands."
    />
    <link rel="icon" href="../assets/logo.svg" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400;700&family=Inter:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="arcade.css" />
    <style>
      .arc-screen {
        background: #14161d;
      }

      .arc-screen canvas {
        aspect-ratio: 30 / 16;
      }

      .pips {
        display: flex;
        gap: 5px;
        margin-top: 4px;
      }

      .pip {
        width: 10px;
        height: 10px;
        border: 1px solid rgba(245, 244, 239, 0.35);
      }

      .pip.is-done {
        background: var(--sage);
        border-color: var(--sage);
      }

      .carry {
        color: var(--sun);
      }

      .objective {
        position: absolute;
        left: 12px;
        right: 12px;
        bottom: 12px;
        z-index: 4;
        display: flex;
        gap: 10px;
        align-items: baseline;
        font-size: 12px;
        color: var(--ink);
        text-shadow: 0 2px 0 rgba(0, 0, 0, 0.85);
        pointer-events: none;
      }

      .objective b {
        font-size: 10px;
        font-weight: 500;
        letter-spacing: 0.14em;
        text-transform: uppercase;
        color: var(--sun);
      }

      .dialog {
        position: absolute;
        left: 12px;
        right: 12px;
        bottom: 12px;
        z-index: 5;
        display: grid;
        gap: 6px;
        padding: 12px 14px;
        background: rgba(8, 9, 13, 0.95);
        border: 2px solid var(--edge);
        border-left: 3px solid var(--sun);
      }

      .dialog .who {
        font-size: 10px;
        letter-spacing: 0.16em;
        text-transform: uppercase;
        color: var(--sun);
      }

      .dialog .line {
        font-size: 13px;
        line-height: 1.6;
        max-width: 72ch;
      }

      .dialog .go {
        font-size: 11px;
        letter-spacing: 0.1em;
        color: var(--faint);
      }

      @media (max-width: 640px) {
        .pip {
          width: 8px;
          height: 8px;
        }

        .dialog {
          left: 8px;
          right: 8px;
          bottom: 8px;
          padding: 9px 11px;
        }

        .dialog .line {
          font-size: 12px;
          line-height: 1.5;
        }

        .objective {
          left: 8px;
          right: 8px;
          bottom: 8px;
          font-size: 11px;
        }
      }
    </style>
  </head>
  <body>
    <div class="arc-page is-game">
      <div class="arc-top">
        <img class="arc-mark" src="../assets/amigo.svg" alt="" />
        <h1 class="arc-title">Night Shift</h1>
        <a class="arc-back" href="./" aria-label="Back to the arcade">←<span class="label"> the arcade</span></a>
        <p class="arc-eyebrow">The whole floor, one pair of hands</p>
        <button type="button" class="arc-fs" id="sound">Sound on</button>
        <button type="button" class="arc-fs" id="fs">Fullscreen</button>
      </div>

      <div class="arc-stage">
        <div class="arc-screen" id="screen">
        <canvas
          id="game"
          width="480"
          height="256"
          aria-label="A top-down control room: Amigo walks between stations and closes tickets"
        ></canvas>

        <div class="arc-hud" id="hud" hidden>
          <div><b>Tickets</b><div class="pips" id="pips"></div></div>
          <div id="carryBox" hidden><b>Carrying</b><span class="arc-value carry" id="carryName"></span></div>
          <div class="arc-spacer"><b>Clock</b><span class="arc-value" id="clock">00:00</span></div>
        </div>

        <div class="objective" id="objective" hidden><b>Next</b><span id="objectiveText"></span></div>

        <div class="dialog" id="dialog" hidden>
          <div class="who" id="who"></div>
          <div class="line" id="line"></div>
          <div class="go">Space to continue</div>
        </div>

        <div class="arc-overlay" id="overlay">
          <h2>Night Shift</h2>
          <p>
            The console is quiet and the queue is not. Walk Amigo between the stations, carry what
            each one needs, and close every ticket - a retrieval, a tool behind a sealed credential,
            a write that waits for a human, a source to connect, an export to clean.
          </p>
          <p class="arc-hint" id="perkLine"></p>
          <div class="arc-actions">
            <button type="button" class="arc-btn is-primary" data-action="retry">Clock in</button>
          </div>
          <p class="arc-hint">Space to start · <a href="./">back to the arcade</a></p>
        </div>
        </div>
      </div>

      <p class="arc-rotate">Turn the phone sideways for a bigger picture</p>

      <div class="arc-legend">
        <div><strong><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> or arrows</strong> Walk. Hold anywhere on touch.</div>
        <div><strong><kbd>Space</kbd> / <kbd>E</kbd></strong> Use the nearest station. On touch, tap the desk itself.</div>
        <div><strong>One pair of hands</strong> Amigo carries one thing at a time, so the route is the puzzle.</div>
        <div><strong><kbd>P</kbd> <kbd>R</kbd> <kbd>F</kbd></strong> Pause, restart, fullscreen.</div>
      </div>
    </div>

    <script src="arcade.js"></script>
    <script>
      (function () {
        "use strict";

        var H = window.Amigo.harness;
        var FRAMES = window.Amigo.sprites();

        var TS = 16;
        var COLS = 30;
        var ROWS = 16;

        /* Rows are padded and the border is forced below, so a short row is a
           row of floor rather than a map that silently shifts. */
        var RAW = [
          "##############################",
          "#............##..............#",
          "#............##..............#",
          "#............##..............#",
          "#............##..............#",
          "#............##..............#",
          "#............................#",
          "#............................#",
          "#........##........##........#",
          "#........##........##........#",
          "#........##........##........#",
          "#........##........##........#",
          "#........##........##........#",
          "#........##........##........#",
          "#............................#",
          "##############################",
        ];

        var map = [];
        for (var y = 0; y < ROWS; y++) {
          var row = (RAW[y] || "").padEnd(COLS, ".").slice(0, COLS).split("");
          for (var x = 0; x < COLS; x++) {
            if (x === 0 || y === 0 || x === COLS - 1 || y === ROWS - 1) row[x] = "#";
          }
          map.push(row);
        }

        var STATIONS = [
          {
            id: "knowledge",
            name: "Knowledge base",
            x: 4,
            y: 3,
            color: "#2e8b57",
            idle: "Eighty-four documents, chunked and embedded. Ask for one and you get the passage, not the file.",
          },
          {
            id: "models",
            name: "Models",
            x: 9,
            y: 3,
            color: "#c8412b",
            idle: "Four providers wired in. Which one answers is a routing decision, not a rewrite.",
          },
          {
            id: "vault",
            name: "Vault",
            x: 18,
            y: 3,
            color: "#4b6bdd",
            idle: "Everything in here is sealed. You can carry a secret id out; you cannot carry the secret.",
          },
          {
            id: "governance",
            name: "Approvals",
            x: 24,
            y: 3,
            color: "#f2c14e",
            idle: "Anything that writes, spends or leaves the building stops here first.",
          },
          {
            id: "mcp",
            name: "MCP hub",
            x: 4,
            y: 9,
            color: "#9b59b6",
            idle: "Fourteen servers connected. Which tools an agent actually sees is a separate question.",
          },
          {
            id: "parser",
            name: "Parser",
            x: 4,
            y: 12,
            color: "#d4763a",
            idle: "Give me a file and I will give you text, tables and page numbers. Or an honest error.",
          },
          {
            id: "chat",
            name: "Chat desk",
            x: 14,
            y: 10,
            color: "#c8412b",
            idle: "This is where people talk to the agent. Everything else exists to answer what lands here.",
          },
          {
            id: "connectors",
            name: "Sources",
            x: 24,
            y: 12,
            color: "#3d9fb5",
            idle: "Drive, Notion, S3. A source syncs on a schedule and keeps its own permissions.",
          },
          {
            id: "sandbox",
            name: "Sandbox",
            x: 24,
            y: 9,
            color: "#3d9fb5",
            idle: "Give me something to run and I will run it somewhere it cannot hurt anyone.",
          },
        ];

        var byId = {};
        STATIONS.forEach(function (s) {
          byId[s.id] = s;
          map[s.y][s.x] = "S";
          map[s.y][s.x + 1] = "S";
        });

        var ITEMS = {
          doc: { name: "Q3 contract passage", color: "#2e8b57" },
          key: { name: "Linear secret id", color: "#4b6bdd" },
          tool: { name: "create_issue", color: "#9b59b6" },
          approval: { name: "Signed approval", color: "#f2c14e" },
          result: { name: "Migration result", color: "#3d9fb5" },
          drive: { name: "Drive folder link", color: "#3d9fb5" },
          indexed: { name: "Indexed sales folder", color: "#2e8b57" },
          messy: { name: "Raw CRM export", color: "#6b5334" },
          clean: { name: "Clean customer rows", color: "#d4763a" },
          swap: { name: "Qwen deployment", color: "#7b5cf0" },
        };

        /* One list, read top to bottom. Each step names the station it happens
           at, what Amigo has to be carrying, and what he leaves with. */
        var BASE_SCRIPT = [
          {
            at: "chat",
            line: "Ticket one: \u201CSummarise the Q3 supplier contract.\u201D The contract is not in my head - it is in the knowledge base.",
            hint: "Go to the knowledge base",
          },
          {
            at: "knowledge",
            gives: "doc",
            line: "Found it: clause seven, renewal terms, four chunks with the page numbers attached. Take the passage, not the PDF.",
            hint: "Carry the passage back to the chat desk",
          },
          {
            at: "chat",
            needs: "doc",
            closes: 0,
            line: "Answered, with a citation anyone can click. Ticket two: \u201COpen a Linear issue for the renewal.\u201D That needs a tool, and the tool needs a credential.",
            hint: "Fetch the credential from the vault",
          },
          {
            at: "vault",
            gives: "key",
            line: "Sealed, and handed over as an id. The plaintext key never leaves this room, and nothing you carry can print it.",
            hint: "Take the secret id to the MCP hub",
          },
          {
            at: "mcp",
            needs: "key",
            gives: "tool",
            line: "Connection probed: fourteen tools on that server, three allowed for this agent. create_issue is one of them.",
            hint: "Bring the tool back to the chat desk",
            unlock: "mcp.linear",
          },
          {
            at: "chat",
            needs: "tool",
            closes: 1,
            line: "Issue opened, link posted in the thread. Ticket three: \u201CRun the data migration.\u201D That one writes to production, so it does not just run.",
            hint: "Get it approved before anything runs",
          },
          {
            at: "governance",
            gives: "approval",
            line: "Approved, with a cap: one run, twenty cents, and it lands in the audit log whether it works or not.",
            hint: "Take the approval to the sandbox",
          },
          {
            at: "sandbox",
            needs: "approval",
            gives: "result",
            line: "Session up, migration run in isolation, forty-one rows moved, nothing touched outside the box. The session is gone in sixty seconds.",
            hint: "Report the result at the chat desk",
          },
          {
            at: "chat",
            needs: "result",
            closes: 2,
            line: "Three closed. Ticket four: \u201CSales keep asking the agent about a folder on Google Drive.\u201D It cannot read what it is not connected to.",
            hint: "Connect the drive at the sources desk",
          },
          {
            at: "connectors",
            gives: "drive",
            line: "Drive connected, read-only, syncing hourly. It inherits the folder's own sharing - nobody gets to read more through the agent than they can in Drive.",
            hint: "Take the link to the knowledge base",
            unlock: "mcp.gdrive",
          },
          {
            at: "knowledge",
            needs: "drive",
            gives: "indexed",
            line: "Two hundred and six files parsed, chunked and embedded. Slides and spreadsheets included.",
            hint: "Close it out at the chat desk",
          },
          {
            at: "chat",
            needs: "indexed",
            closes: 3,
            line: "Sales can ask now. Ticket five: \u201CThe answers about customers are wrong.\u201D They are not wrong - the export they came from is a mess.",
            hint: "Pull the raw export from the sources desk",
          },
          {
            at: "connectors",
            gives: "messy",
            line: "Here it is: forty thousand rows, duplicate accounts, null emails and a hundred test records from 2019.",
            hint: "Run it through the parser",
          },
          {
            at: "parser",
            needs: "messy",
            gives: "clean",
            line: "Deduplicated, nulls dropped, test rows out. Thirty-one thousand rows the agent can be trusted with.",
            hint: "Close it out at the chat desk",
            unlock: "data.clean",
          },
          {
            at: "chat",
            needs: "clean",
            closes: 4,
            line: "Five tickets closed, and the shift is quiet again.",
            hint: "Shift complete",
          },
        ];

        /* The sixth ticket only exists once the Linear server is connected -
           which is also the joke: the more you wire in, the more lands on you. */
        var BONUS_HANDOFF = {
          at: "chat",
          needs: "clean",
          closes: 4,
          line: "Five closed. One more, and it is from finance: \u201CThe OpenAI invoice came in at thirty thousand dollars.\u201D Nobody noticed until the card was charged.",
          hint: "Take it to the models desk",
        };

        var BONUS = [
          {
            at: "models",
            gives: "swap",
            line: "Routed: the cheap eighty per cent of the traffic moves to Qwen on our own hardware, the hard fifth stays on Claude. Same answers, a fifth of the invoice.",
            hint: "Report the new routing at the chat desk",
            unlock: "model.qwen",
          },
          {
            at: "chat",
            needs: "swap",
            closes: 5,
            line: "Six tickets closed, and next month's invoice will fit on one line.",
            hint: "Shift complete",
          },
        ];

        var cv = document.getElementById("game");
        var ctx = cv.getContext("2d");
        ctx.imageSmoothingEnabled = false;
        var W = cv.width;
        var HGT = cv.height;

        var screenEl = document.getElementById("screen");
        var els = {
          overlay: document.getElementById("overlay"),
          hud: document.getElementById("hud"),
          dialog: document.getElementById("dialog"),
          who: document.getElementById("who"),
          line: document.getElementById("line"),
          objective: document.getElementById("objective"),
          objectiveText: document.getElementById("objectiveText"),
          carryBox: document.getElementById("carryBox"),
          carryName: document.getElementById("carryName"),
          clock: document.getElementById("clock"),
          pips: document.getElementById("pips"),
        };

        var P = { w: 10, h: 10 };
        var g = null;

        function perks() {
          return {
            fast: H.has("skill.handoff"),
            /* The wiki is a route as much as the playbook is: either one is
               enough to know where to go next. */
            playbook: H.has("context.playbook") || H.has("mcp.notion"),
            route: H.has("mcp.notion"),
            bonusTicket: H.has("mcp.linear"),
          };
        }

        function script(perk) {
          if (!perk.bonusTicket) return BASE_SCRIPT.slice();
          return BASE_SCRIPT.slice(0, BASE_SCRIPT.length - 1)
            .concat([BONUS_HANDOFF])
            .concat(BONUS);
        }

        function reset() {
          var perk = perks();
          g = {
            mode: "ready",
            perk: perk,
            script: script(perk),
            tickets: perk.bonusTicket ? 6 : 5,
            t: 0,
            frames: 0,
            x: 14 * TS + 3,
            y: 12 * TS,
            face: 1,
            walking: false,
            step: 0,
            carrying: null,
            progress: 0,
            closed: [],
            dialog: null,
            sparks: [],
          };
          for (var i = 0; i < g.tickets; i++) g.closed.push(false);
        }

        function solid(px, py) {
          var gx = Math.floor(px / TS);
          var gy = Math.floor(py / TS);
          if (gx < 0 || gy < 0 || gx >= COLS || gy >= ROWS) return true;
          var t = map[gy][gx];
          return t === "#" || t === "S";
        }

        function blocked(x, y) {
          return (
            solid(x, y) ||
            solid(x + P.w - 1, y) ||
            solid(x, y + P.h - 1) ||
            solid(x + P.w - 1, y + P.h - 1)
          );
        }

        /* The nearest desk, not the last one that happened to match: two
           stations can share a room, and "whichever the loop saw last" is how a
           player ends up talking to the wrong one. */
        function nearStation() {
          var cx = g.x + P.w / 2;
          var cy = g.y + P.h / 2;
          var found = null;
          var best = Infinity;
          STATIONS.forEach(function (s) {
            var sx = s.x * TS + TS;
            var sy = s.y * TS + TS / 2;
            if (Math.abs(cx - sx) >= 30 || Math.abs(cy - sy) >= 30) return;
            var d = Math.hypot(cx - sx, cy - sy);
            if (d < best) {
              best = d;
              found = s;
            }
          });
          return found;
        }

        function fmt(secs) {
          var m = Math.floor(secs / 60);
          var s = secs % 60;
          return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
        }

        function paintHud() {
          els.pips.innerHTML = "";
          for (var i = 0; i < g.tickets; i++) {
            var pip = document.createElement("span");
            pip.className = "pip" + (g.closed[i] ? " is-done" : "");
            els.pips.appendChild(pip);
          }
          if (g.carrying) {
            els.carryBox.hidden = false;
            els.carryName.textContent = ITEMS[g.carrying].name;
          } else els.carryBox.hidden = true;
        }

        function showObjective() {
          var step = g.script[g.progress];
          if (!step || !g.perk.playbook) {
            els.objective.hidden = true;
            return;
          }
          /* Each step's `hint` describes where to go once that step is done, so
             the line for the step you are on is the previous step's hint paired
             with this step's station. Pairing a step's own hint with its own
             station reads as a contradiction: "go to the knowledge base" next
             to "Chat desk". */
          var previous = g.script[g.progress - 1];
          var text = (previous ? previous.hint : "Start at the chat desk") + " · " + byId[step.at].name;
          if (g.perk.route) {
            var after = g.script[g.progress + 1];
            if (after && after.at !== step.at) text += "   →   then " + byId[after.at].name;
          }
          els.objectiveText.textContent = text;
          els.objective.hidden = false;
        }

        function say(who, line) {
          g.dialog = true;
          window.Amigo.sound.play("blip");
          els.who.textContent = who;
          els.line.textContent = line;
          els.dialog.hidden = false;
          els.objective.hidden = true;
        }

        function closeDialog() {
          g.dialog = null;
          els.dialog.hidden = true;
          if (g.mode === "playing") showObjective();
          if (g.mode === "won") finish();
        }

        function showPerks() {
          var p = perks();
          var active = [];
          if (p.fast) active.push("Delegation speeds Amigo up");
          if (p.playbook) active.push("The playbook names the next station");
          if (p.route) active.push("Notion names the stop after next");
          if (p.bonusTicket) active.push("Linear MCP adds a sixth ticket");
          document.getElementById("perkLine").textContent = active.length
            ? "Harness active: " + active.join(" · ")
            : "Harness empty - without a playbook you work the floor from memory.";
        }

        function start() {
          reset();
          g.mode = "playing";
          els.overlay.hidden = true;
          els.hud.hidden = false;
          els.dialog.hidden = true;
          paintHud();
          showObjective();
        }

        function use() {
          var s = nearStation();
          if (!s) return;
          var step = g.script[g.progress];

          if (!step || step.at !== s.id) {
            say(s.name, s.idle);
            return;
          }

          if (step.needs && g.carrying !== step.needs) {
            window.Amigo.sound.play("deny");
            say(
              s.name,
              "Not yet - I need " +
                ITEMS[step.needs].name.toLowerCase() +
                " in hand before this goes anywhere."
            );
            return;
          }

          if (step.needs) g.carrying = null;
          if (step.gives) g.carrying = step.gives;
          if (step.closes !== undefined) {
            g.closed[step.closes] = true;
            window.Amigo.sound.play("ticket");
            for (var i = 0; i < 10; i++) {
              g.sparks.push({
                x: s.x * TS + TS,
                y: s.y * TS + 8,
                vx: Math.random() * 3 - 1.5,
                vy: -Math.random() * 2.4,
                life: 30,
              });
            }
          }
          if (step.unlock) {
            var part = H.unlock(step.unlock);
            if (part) window.Amigo.unlockToast(screenEl, part);
          }

          g.progress++;
          paintHud();
          say(s.name, step.line);
          if (g.progress >= g.script.length) g.mode = "won";
        }

        function finish() {
          var secs = Math.floor(g.frames / 60);
          window.Amigo.sound.play("win");
          var stats = H.stats();
          var earned = [];

          H.record({ shiftClears: { add: 1 } });
          if (!stats.shiftBest || secs < stats.shiftBest) H.record({ shiftBest: { set: secs } });

          earned.push(H.unlock("context.playbook"));
          if (secs <= 180) earned.push(H.unlock("skill.handoff"));
          if (H.stats().shiftClears >= 2) earned.push(H.unlock("memory.long_term"));
          H.played("shift");

          var got = earned.filter(Boolean);
          var note = got.length
            ? "Harness upgraded: " +
              got
                .map(function (part) {
                  return part.name;
                })
                .join(", ") +
              "."
            : "A retrieval, a tool call behind a sealed credential, and a write that waited for a human. That is the honest shape of an agent inside a company.";

          els.overlay.innerHTML = window.Amigo.continueScreen({
            won: true,
            title: "Shift complete",
            note: note,
            stats: [
              ["Tickets", g.tickets],
              ["Clock", fmt(secs)],
              ["Shifts worked", H.stats().shiftClears],
            ],
            retryLabel: "Take another shift",
          });
          els.overlay.hidden = false;
          window.Amigo.wireContinue(els.overlay, start);
          els.objective.hidden = true;
          if (got.length) window.Amigo.unlockToast(screenEl, got[0]);
        }

        var keys = {};
        var USE = ["Space", "KeyE", "Enter"];
        var NAV = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "KeyW", "KeyA", "KeyS", "KeyD"];

        document.addEventListener("keydown", function (e) {
          if (NAV.concat(USE).indexOf(e.code) !== -1) e.preventDefault();
          if (USE.indexOf(e.code) !== -1) {
            if (e.repeat) return;
            /* The dialog is answered first: the closing line of the last ticket
               shows while the mode is already "won", and a mode check here would
               restart the shift instead of rolling the credits. */
            if (g.dialog) {
              closeDialog();
              return;
            }
            if (g.mode !== "playing") {
              start();
              return;
            }
            use();
            return;
          }
          keys[e.code] = true;
        });
        document.addEventListener("keyup", function (e) {
          keys[e.code] = false;
        });

        var pointer = null;
        function pointAt(e) {
          var r = cv.getBoundingClientRect();
          return {
            x: ((e.clientX - r.left) / r.width) * W,
            y: ((e.clientY - r.top) / r.height) * HGT,
          };
        }

        cv.addEventListener("pointerdown", function (e) {
          if (g.dialog) {
            closeDialog();
            return;
          }
          if (g.mode !== "playing") {
            start();
            return;
          }
          pointer = pointAt(e);
          /* A tap uses a station only when it lands on the station itself.
             Anywhere else is a walk instruction - otherwise standing next to a
             desk means every tap reopens its dialog and Amigo cannot leave. */
          var near = nearStation();
          if (near) {
            var sx = near.x * TS + TS;
            var sy = near.y * TS + TS / 2;
            if (Math.hypot(pointer.x - sx, pointer.y - sy) < 28) use();
          }
        });
        cv.addEventListener("pointermove", function (e) {
          if (pointer) pointer = pointAt(e);
        });
        document.addEventListener("pointerup", function () {
          pointer = null;
        });
        els.dialog.addEventListener("pointerdown", function () {
          closeDialog();
        });

        function drainSparks() {
          for (var i = 0; i < g.sparks.length; i++) {
            var s = g.sparks[i];
            s.x += s.vx;
            s.y += s.vy;
            s.vy += 0.14;
            s.life--;
          }
          g.sparks = g.sparks.filter(function (s) {
            return s.life > 0;
          });
        }

        function update() {
          if (!g) return;
          if (pauseCtl.on) return;
          g.t++;
          if (g.mode !== "playing" || g.dialog) {
            drainSparks();
            return;
          }
          g.frames++;
          if (g.frames % 30 === 0) els.clock.textContent = fmt(Math.floor(g.frames / 60));

          var dx = 0;
          var dy = 0;
          if (keys.ArrowLeft || keys.KeyA) dx -= 1;
          if (keys.ArrowRight || keys.KeyD) dx += 1;
          if (keys.ArrowUp || keys.KeyW) dy -= 1;
          if (keys.ArrowDown || keys.KeyS) dy += 1;

          if (pointer) {
            var cx = g.x + P.w / 2;
            var cy = g.y + P.h / 2;
            if (Math.hypot(pointer.x - cx, pointer.y - cy) > 8) {
              dx = pointer.x - cx;
              dy = pointer.y - cy;
            }
          }

          var len = Math.hypot(dx, dy) || 1;
          var speed = g.perk.fast ? 1.85 : 1.45;
          var mx = (dx / len) * speed;
          var my = (dy / len) * speed;
          g.walking = Math.abs(mx) + Math.abs(my) > 0.05;
          if (mx < -0.05) g.face = -1;
          else if (mx > 0.05) g.face = 1;

          if (!blocked(g.x + mx, g.y)) g.x += mx;
          if (!blocked(g.x, g.y + my)) g.y += my;
          if (g.walking) g.step += 0.18;

          drainSparks();
        }

        /* A 3x5 bitmap alphabet: the station plates have to sit on the same grid
           as everything else, and a hinted system font at six pixels does not. */
        var GLYPHS = {
          A: "010101111101101",
          B: "110101110101110",
          C: "011100100100011",
          D: "110101101101110",
          E: "111100110100111",
          F: "111100110100100",
          G: "011100101101011",
          H: "101101111101101",
          I: "111010010010111",
          J: "001001001101010",
          K: "101101110101101",
          L: "100100100100111",
          M: "101111111101101",
          N: "101111111111101",
          O: "010101101101010",
          P: "110101110100100",
          Q: "010101101111011",
          R: "110101110101101",
          S: "011100010001110",
          T: "111010010010010",
          U: "101101101101011",
          V: "101101101010010",
          W: "101101111111101",
          X: "101101010101101",
          Y: "101101010010010",
          Z: "111001010100111",
          " ": "000000000000000",
        };

        function plate(text, cx, y, color) {
          var w = text.length * 4 - 1;
          var x = Math.round(cx - w / 2);
          ctx.fillStyle = "rgba(11,12,15,.75)";
          ctx.fillRect(x - 2, y - 1, w + 4, 7);
          ctx.fillStyle = color;
          for (var c = 0; c < text.length; c++) {
            var bits = GLYPHS[text[c]] || GLYPHS[" "];
            for (var i = 0; i < 15; i++) {
              if (bits[i] === "1") ctx.fillRect(x + (i % 3), y + Math.floor(i / 3), 1, 1);
            }
            x += 4;
          }
        }

        function drawStation(s, active) {
          var x = s.x * TS;
          var y = s.y * TS;
          if (active && g.mode === "playing") {
            var pulse = 0.1 + Math.sin(g.t * 0.09) * 0.07;
            ctx.fillStyle = "rgba(242,193,78," + pulse + ")";
            ctx.fillRect(x - 10, y - 12, TS * 2 + 20, TS + 24);
          }

          ctx.fillStyle = "#11141a";
          ctx.fillRect(x, y + 4, TS * 2, TS - 2);
          ctx.fillStyle = "#3a4354";
          ctx.fillRect(x, y - 8, TS * 2, 14);
          ctx.fillStyle = s.color;
          ctx.fillRect(x + 2, y - 6, TS * 2 - 4, 10);
          ctx.fillStyle = "rgba(11,12,15,.55)";
          for (var i = 0; i < 4; i++) {
            ctx.fillRect(x + 4, y - 4 + i * 3, (TS * 2 - 8) * (0.4 + (i % 3) * 0.2), 1);
          }
          ctx.fillStyle = "#0b0c0f";
          ctx.fillRect(x, y + TS - 2, TS * 2, 2);

          plate(s.name.toUpperCase(), x + TS, y + TS + 3, active ? "#f2c14e" : "#6b7382");

          if (active && g.mode === "playing" && g.perk.playbook) {
            var bob = Math.round(Math.sin(g.t * 0.14) * 2);
            ctx.fillStyle = "#f2c14e";
            var ax = x + TS - 3;
            var ay = y - 22 + bob;
            ctx.fillRect(ax, ay, 6, 4);
            ctx.fillRect(ax + 1, ay + 4, 4, 2);
            ctx.fillRect(ax + 2, ay + 6, 2, 2);
          }
        }

        function drawAmigo() {
          var frame = g.walking && Math.floor(g.step) % 2 === 0 ? FRAMES.step : FRAMES.stand;
          var x = Math.round(g.x - 3);
          var y = Math.round(g.y - 9);

          ctx.fillStyle = "rgba(0,0,0,.35)";
          ctx.fillRect(x + 3, y + 19, 11, 3);

          ctx.save();
          ctx.translate(x, y);
          if (g.face < 0) {
            ctx.translate(16, 0);
            ctx.scale(-1, 1);
          }
          ctx.drawImage(frame, 0, 0);
          ctx.restore();

          /* What he is carrying floats over the hat: on a sixteen-pixel sprite
             it is the only place a second object stays readable. */
          if (g.carrying) {
            var item = ITEMS[g.carrying];
            var bob = Math.round(Math.sin(g.t * 0.12) * 1.5);
            var ix = x + 4;
            var iy = y - 11 + bob;
            ctx.fillStyle = "rgba(11,12,15,.6)";
            ctx.fillRect(ix - 1, iy - 1, 10, 10);
            ctx.fillStyle = item.color;
            ctx.fillRect(ix, iy, 8, 8);
            ctx.fillStyle = "rgba(255,255,255,.7)";
            ctx.fillRect(ix + 1, iy + 1, 2, 2);
          }
        }

        function draw() {
          if (!g) return;
          ctx.fillStyle = "#171a21";
          ctx.fillRect(0, 0, W, HGT);

          for (var y = 0; y < ROWS; y++) {
            for (var x = 0; x < COLS; x++) {
              var t = map[y][x];
              var px = x * TS;
              var py = y * TS;
              if (t === "#") {
                ctx.fillStyle = "#2a3140";
                ctx.fillRect(px, py, TS, TS);
                ctx.fillStyle = "#39435a";
                ctx.fillRect(px, py, TS, 3);
                ctx.fillStyle = "#1d222c";
                ctx.fillRect(px, py + TS - 3, TS, 3);
              } else {
                ctx.fillStyle = (x + y) % 2 === 0 ? "#1b1f28" : "#191d25";
                ctx.fillRect(px, py, TS, TS);
                ctx.fillStyle = "rgba(46,139,87,.10)";
                ctx.fillRect(px, py + TS - 1, TS, 1);
                ctx.fillRect(px + TS - 1, py, 1, TS);
              }
            }
          }

          var next = g.script[g.progress];
          STATIONS.forEach(function (s) {
            drawStation(s, next && next.at === s.id);
          });

          for (var i = 0; i < g.sparks.length; i++) {
            var sp = g.sparks[i];
            ctx.fillStyle = "rgba(46,139,87," + sp.life / 30 + ")";
            ctx.fillRect(Math.round(sp.x), Math.round(sp.y), 3, 3);
          }

          drawAmigo();
        }

        reset();
        showPerks();
        window.Amigo.wireContinue(els.overlay, start);
        var pauseCtl = window.Amigo.pauser(
          screenEl,
          function () {
            return g && g.mode === "playing";
          },
          start,
          function () {
            keys = {};
            pointer = null;
          }
        );
        window.Amigo.fit(screenEl, cv);
        window.Amigo.fullscreen(document.querySelector(".arc-page"), document.getElementById("fs"));
        window.Amigo.soundButton(document.getElementById("sound"));
        window.Amigo.touchpad(document.querySelector(".arc-page"), "shift");
        window.Amigo.loop(update, draw);
      })();
    </script>
  </body>
</html>