-
-
Save simonw/7c78184476fccd4b70b02f7f9048dffa to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>DoomQL — frame_pixels viewer</title> | |
| <style> | |
| :root { | |
| --bg: #07090f; | |
| --line: #1d2740; | |
| --text: #e8eef5; | |
| --dim: #8fa3bf; | |
| --accent: #ffbe4a; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| padding: 1.25rem 1rem 2rem; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| header { text-align: center; margin-bottom: 1rem; } | |
| h1 { | |
| margin: 0 0 .3rem; | |
| font-size: 1rem; | |
| letter-spacing: .22em; | |
| text-transform: uppercase; | |
| color: var(--accent); | |
| } | |
| header p { margin: 0; color: var(--dim); font-size: .75rem; } | |
| .stage { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 1rem; | |
| justify-content: center; | |
| align-items: flex-start; | |
| } | |
| .viewport { width: min(92vw, 700px); } | |
| .screen-wrap { | |
| position: relative; | |
| border: 1px solid var(--line); | |
| background: #000; | |
| line-height: 0; | |
| box-shadow: 0 0 48px rgba(0, 0, 0, .65); | |
| } | |
| .screen-wrap::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| background: repeating-linear-gradient(to bottom, rgba(0,0,0,.18) 0 1px, transparent 1px 3px); | |
| } | |
| #screen { | |
| display: block; | |
| width: 100%; | |
| image-rendering: pixelated; | |
| } | |
| .hud { width: 100%; font-size: .74rem; white-space: pre; overflow-x: auto; } | |
| .hud div { padding: .34rem .6rem; } | |
| #hud-main { background: rgb(10,14,22); color: rgb(232,238,245); } | |
| #hud-message { background: rgb(18,9,13); color: rgb(255,190,74); } | |
| #hud-footer { background: rgb(6,8,13); color: rgb(116,199,214); } | |
| .minimap-panel { | |
| width: min(92vw, 300px); | |
| border: 1px solid var(--line); | |
| background: #0a0d15; | |
| padding: .55rem; | |
| } | |
| .panel-title { | |
| font-size: .66rem; | |
| letter-spacing: .2em; | |
| color: var(--dim); | |
| text-align: center; | |
| margin-bottom: .5rem; | |
| } | |
| #minimap { display: block; width: 100%; background: #05070d; } | |
| .legend { | |
| margin-top: .55rem; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: .25rem .7rem; | |
| font-size: .62rem; | |
| color: var(--dim); | |
| } | |
| .legend i { | |
| display: inline-block; | |
| width: .58rem; | |
| height: .58rem; | |
| border-radius: 2px; | |
| margin-right: .3rem; | |
| vertical-align: -1px; | |
| } | |
| .statusbar { | |
| margin-top: 1rem; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: .6rem 1rem; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: .8rem; | |
| } | |
| .badge { | |
| padding: .15rem .55rem; | |
| border-radius: 3px; | |
| font-weight: 700; | |
| letter-spacing: .08em; | |
| background: #333; | |
| } | |
| #stats { color: var(--dim); font-size: .74rem; } | |
| #error { | |
| margin-top: .8rem; | |
| max-width: min(92vw, 900px); | |
| background: #2a1013; | |
| border: 1px solid #703038; | |
| color: #ffb3b8; | |
| padding: .6rem .8rem; | |
| font-size: .78rem; | |
| white-space: pre-wrap; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>DoomQL</h1> | |
| <p>auto-refreshing once a second · frame and tactical map straight from SQL</p> | |
| </header> | |
| <div class="stage"> | |
| <div class="viewport"> | |
| <div class="screen-wrap"><canvas id="screen" width="120" height="80"></canvas></div> | |
| <div class="hud"> | |
| <div id="hud-main"> …</div> | |
| <div id="hud-message"> …</div> | |
| <div id="hud-footer"> READ-ONLY VIEWER · SELECT x, y, r, g, b FROM frame_pixels</div> | |
| </div> | |
| </div> | |
| <aside class="minimap-panel"> | |
| <div class="panel-title">TACTICAL MAP</div> | |
| <canvas id="minimap"></canvas> | |
| <div class="legend"> | |
| <span><i style="background:#ecf5ff"></i>you</span> | |
| <span><i style="background:#ff5054"></i>enemy</span> | |
| <span><i style="background:#ffd257"></i>pickup</span> | |
| <span><i style="background:#e0483f"></i>locked door</span> | |
| <span><i style="background:#37c2a8"></i>door</span> | |
| <span><i style="background:#3ddc84"></i>exit</span> | |
| </div> | |
| </aside> | |
| </div> | |
| <div class="statusbar"> | |
| <span class="badge" id="state">—</span> | |
| <span id="stats">Rendering first frame…</span> | |
| </div> | |
| <div id="error" hidden></div> | |
| <script> | |
| (function () { | |
| const DB = "doomql"; | |
| const REFRESH_MS = 1000; | |
| const canvas = document.getElementById("screen"); | |
| const ctx = canvas.getContext("2d"); | |
| const mm = document.getElementById("minimap"); | |
| const mctx = mm.getContext("2d"); | |
| const $ = (id) => document.getElementById(id); | |
| const META_SQL = [ | |
| "select s.screen_width as w, s.screen_height as h, s.horizontal_fov as fov,", | |
| " p.x as px, p.y as py, p.angle,", | |
| " p.health, p.max_health, p.ammo, p.has_token,", | |
| " gs.score, gs.state, gs.message, gs.level_id, gc.tick", | |
| "from settings as s", | |
| "join player as p on p.id = 1", | |
| "join game_status as gs on gs.id = 1", | |
| "join game_clock as gc on gc.id = 1", | |
| "where s.id = 1" | |
| ].join("\n"); | |
| // One row per scanline keeps the result far below any row limits. | |
| const FRAME_SQL = [ | |
| "select y, group_concat(x || ',' || r || ',' || g || ',' || b, ';') as line", | |
| "from frame_pixels", | |
| "group by y", | |
| "order by y" | |
| ].join("\n"); | |
| // Static level geometry, compressed the same way (one row per map row). | |
| const MAP_SQL = [ | |
| "select mc.y, group_concat(", | |
| " mc.x || ',' || mc.solid || ',' || mc.is_exit || ',' ||", | |
| " m.base_r || ',' || m.base_g || ',' || m.base_b, ';') as line", | |
| "from map_cells as mc", | |
| "join materials as m on m.id = mc.material_id", | |
| "group by mc.y", | |
| "order by mc.y" | |
| ].join("\n"); | |
| // Dynamic minimap overlay: doors + live entities in one query. | |
| const OVERLAY_SQL = [ | |
| "select 'door' as k, x * 1.0 as x, y * 1.0 as y, open_fraction as a, locked as b", | |
| "from doors", | |
| "union all", | |
| "select case when et.kind = 'pickup' then 'pickup'", | |
| " when et.is_boss = 1 then 'boss'", | |
| " else 'enemy' end as k,", | |
| " e.x, e.y, 0.0 as a, 0 as b", | |
| "from entities as e", | |
| "join entity_types as et on et.id = e.type_id", | |
| "where e.active = 1" | |
| ].join("\n"); | |
| const STATE_COLORS = { | |
| running: "#1d7a44", | |
| paused: "#8a6414", | |
| dead: "#8a1d24", | |
| won: "#155e6b" | |
| }; | |
| let mapData = null; | |
| let mapLevel = null; | |
| let baseLayer = null; | |
| const pad = (n, len) => String(n ?? 0).padStart(len, "0"); | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| function drawFrame(w, h, rows) { | |
| if (canvas.width !== w) canvas.width = w; | |
| if (canvas.height !== h) canvas.height = h; | |
| const img = ctx.createImageData(w, h); | |
| const d = img.data; | |
| for (let i = 3; i < d.length; i += 4) d[i] = 255; // opaque black base | |
| let painted = 0; | |
| for (const row of rows) { | |
| const y = Number(row.y); | |
| if (!Number.isInteger(y) || y < 0 || y >= h || !row.line) continue; | |
| const cells = String(row.line).split(";"); | |
| for (const cell of cells) { | |
| const parts = cell.split(","); | |
| const x = Number(parts[0]); | |
| if (!(x >= 0 && x < w)) continue; | |
| const i = (y * w + x) * 4; | |
| d[i] = Number(parts[1]) | 0; | |
| d[i + 1] = Number(parts[2]) | 0; | |
| d[i + 2] = Number(parts[3]) | 0; | |
| painted++; | |
| } | |
| } | |
| ctx.putImageData(img, 0, 0); | |
| return painted; | |
| } | |
| function parseMap(rows) { | |
| const cells = []; | |
| let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; | |
| for (const row of rows) { | |
| const y = Number(row.y); | |
| if (!row.line) continue; | |
| for (const chunk of String(row.line).split(";")) { | |
| const p = chunk.split(","); | |
| const x = Number(p[0]); | |
| cells.push({ x, y, solid: +p[1], exit: +p[2], r: +p[3], g: +p[4], b: +p[5] }); | |
| if (x < minX) minX = x; if (x > maxX) maxX = x; | |
| if (y < minY) minY = y; if (y > maxY) maxY = y; | |
| } | |
| } | |
| if (!cells.length) throw new Error("map_cells is empty"); | |
| const w = maxX - minX + 1; | |
| const h = maxY - minY + 1; | |
| const solid = new Uint8Array(w * h); | |
| for (const c of cells) if (c.solid) solid[(c.y - minY) * w + (c.x - minX)] = 1; | |
| const cell = Math.max(6, Math.min(24, Math.round(560 / w))); // ~2x the CSS size, crisp | |
| return { cells, minX, minY, w, h, solid, cell }; | |
| } | |
| function solidAt(x, y) { | |
| const { w, h, minX, minY, solid } = mapData; | |
| const gx = x - minX, gy = y - minY; | |
| if (gx < 0 || gy < 0 || gx >= w || gy >= h) return 0; | |
| return solid[gy * w + gx]; | |
| } | |
| function buildBaseLayer(doorKeys) { | |
| const { cells, minX, minY, w, h, cell } = mapData; | |
| baseLayer = document.createElement("canvas"); | |
| baseLayer.width = w * cell; | |
| baseLayer.height = h * cell; | |
| const g = baseLayer.getContext("2d"); | |
| g.fillStyle = "#05070d"; | |
| g.fillRect(0, 0, baseLayer.width, baseLayer.height); | |
| for (const c of cells) { | |
| const px = (c.x - minX) * cell, py = (c.y - minY) * cell; | |
| if (c.solid && !doorKeys.has(c.x + "," + c.y)) { | |
| g.fillStyle = "rgb(" + Math.round(c.r * 0.9) + "," + Math.round(c.g * 0.9) + "," + Math.round(c.b * 0.9) + ")"; | |
| g.fillRect(px, py, cell, cell); | |
| } else { | |
| g.fillStyle = "#10141d"; | |
| g.fillRect(px, py, cell - 1, cell - 1); // 1px gap = subtle floor grid | |
| if (c.exit) { | |
| g.fillStyle = "rgba(61,220,132,.3)"; | |
| g.fillRect(px, py, cell - 1, cell - 1); | |
| g.strokeStyle = "rgba(61,220,132,.85)"; | |
| g.lineWidth = 1; | |
| g.strokeRect(px + .5, py + .5, cell - 2, cell - 2); | |
| } | |
| } | |
| } | |
| } | |
| function dot(x, y, r, color) { | |
| mctx.fillStyle = color; | |
| mctx.beginPath(); | |
| mctx.arc(x, y, r, 0, Math.PI * 2); | |
| mctx.fill(); | |
| } | |
| function drawMinimap(meta, overlay) { | |
| if (!mapData) return; | |
| const { minX, minY, w, h, cell } = mapData; | |
| const W = w * cell, H = h * cell; | |
| if (mm.width !== W) mm.width = W; | |
| if (mm.height !== H) mm.height = H; | |
| const doors = [], enemies = [], pickups = []; | |
| const doorKeys = new Set(); | |
| for (const r of overlay) { | |
| if (r.k === "door") { doors.push(r); doorKeys.add(r.x + "," + r.y); } | |
| else if (r.k === "pickup") pickups.push(r); | |
| else enemies.push(r); | |
| } | |
| if (!baseLayer) buildBaseLayer(doorKeys); | |
| mctx.drawImage(baseLayer, 0, 0); | |
| // Doors: sliding bars, split apart as open_fraction grows. | |
| for (const d of doors) { | |
| const px = (d.x - minX) * cell, py = (d.y - minY) * cell; | |
| const horizontal = solidAt(d.x - 1, d.y) && solidAt(d.x + 1, d.y); | |
| const t = Math.max(2, cell * 0.34); | |
| const half = (cell * (1 - d.a)) / 2; | |
| if (half <= 0.4) continue; // fully open | |
| mctx.fillStyle = d.b ? "#e0483f" : "#37c2a8"; | |
| if (horizontal) { | |
| const yb = py + (cell - t) / 2; | |
| mctx.fillRect(px, yb, half, t); | |
| mctx.fillRect(px + cell - half, yb, half, t); | |
| } else { | |
| const xb = px + (cell - t) / 2; | |
| mctx.fillRect(xb, py, t, half); | |
| mctx.fillRect(xb, py + cell - half, t, half); | |
| } | |
| } | |
| for (const p of pickups) { | |
| dot((p.x - minX) * cell, (p.y - minY) * cell, cell * 0.22, "#ffd257"); | |
| } | |
| for (const e of enemies) { | |
| const x = (e.x - minX) * cell, y = (e.y - minY) * cell; | |
| if (e.k === "boss") { | |
| dot(x, y, cell * 0.5, "#ff5054"); | |
| mctx.strokeStyle = "#fff"; | |
| mctx.lineWidth = Math.max(1, cell * 0.1); | |
| mctx.beginPath(); | |
| mctx.arc(x, y, cell * 0.5, 0, Math.PI * 2); | |
| mctx.stroke(); | |
| } else { | |
| dot(x, y, cell * 0.3, "#ff5054"); | |
| } | |
| } | |
| // Player: FOV cone + heading triangle. | |
| const px = (meta.px - minX) * cell, py = (meta.py - minY) * cell; | |
| const a = Number(meta.angle) || 0; | |
| const f = Number(meta.fov) || 1.2; | |
| const L = cell * 4.5; | |
| mctx.fillStyle = "rgba(148,205,255,.16)"; | |
| mctx.beginPath(); | |
| mctx.moveTo(px, py); | |
| mctx.lineTo(px + Math.cos(a - f / 2) * L, py + Math.sin(a - f / 2) * L); | |
| mctx.lineTo(px + Math.cos(a + f / 2) * L, py + Math.sin(a + f / 2) * L); | |
| mctx.closePath(); | |
| mctx.fill(); | |
| const s = cell * 0.62; | |
| mctx.fillStyle = "#ecf5ff"; | |
| mctx.strokeStyle = "#0a0f18"; | |
| mctx.lineWidth = 1; | |
| mctx.beginPath(); | |
| mctx.moveTo(px + Math.cos(a) * s, py + Math.sin(a) * s); | |
| mctx.lineTo(px + Math.cos(a + 2.6) * s * 0.8, py + Math.sin(a + 2.6) * s * 0.8); | |
| mctx.lineTo(px + Math.cos(a - 2.6) * s * 0.8, py + Math.sin(a - 2.6) * s * 0.8); | |
| mctx.closePath(); | |
| mctx.fill(); | |
| mctx.stroke(); | |
| } | |
| function updateHud(m) { | |
| $("hud-main").textContent = | |
| " HP " + pad(m.health, 3) + "/" + pad(m.max_health, 3) + | |
| " AMMO " + pad(m.ammo, 3) + | |
| " SCORE " + pad(m.score, 5) + | |
| " INDEX " + (m.has_token ? "ONLINE " : "MISSING") + | |
| " TICK " + pad(m.tick, 7); | |
| $("hud-message").textContent = " " + (m.message || ""); | |
| const badge = $("state"); | |
| badge.textContent = String(m.state || "?").toUpperCase(); | |
| badge.style.background = STATE_COLORS[m.state] || "#333"; | |
| } | |
| async function refresh() { | |
| const t0 = performance.now(); | |
| const [metaR, frameR, overlayR] = await Promise.all([ | |
| datasette.query(DB, META_SQL), | |
| datasette.query(DB, FRAME_SQL), | |
| datasette.query(DB, OVERLAY_SQL) | |
| ]); | |
| const meta = metaR.rows[0]; | |
| if (!meta) throw new Error("settings / player / game_status rows not found"); | |
| if (!mapData || mapLevel !== meta.level_id) { | |
| const mapR = await datasette.query(DB, MAP_SQL); | |
| mapData = parseMap(mapR.rows); | |
| mapLevel = meta.level_id; | |
| baseLayer = null; | |
| } | |
| const queryMs = performance.now() - t0; | |
| const painted = drawFrame(meta.w, meta.h, frameR.rows); | |
| updateHud(meta); | |
| drawMinimap(meta, overlayR.rows); | |
| const hostiles = overlayR.rows.filter((r) => r.k === "enemy" || r.k === "boss").length; | |
| const expected = meta.w * meta.h; | |
| $("stats").textContent = | |
| meta.w + "×" + meta.h + | |
| " · " + painted.toLocaleString() + | |
| (painted === expected ? "" : " of " + expected.toLocaleString()) + | |
| " pixels · " + hostiles + (hostiles === 1 ? " hostile" : " hostiles") + | |
| " · query " + Math.round(queryMs).toLocaleString() + " ms" + | |
| " · refreshing every " + (REFRESH_MS / 1000) + " s"; | |
| } | |
| async function loop() { | |
| for (;;) { | |
| const started = performance.now(); | |
| try { | |
| await refresh(); | |
| $("error").hidden = true; | |
| } catch (err) { | |
| $("error").hidden = false; | |
| $("error").textContent = err && err.message ? err.message : String(err); | |
| } | |
| const elapsed = performance.now() - started; | |
| await sleep(Math.max(0, REFRESH_MS - elapsed)); | |
| } | |
| } | |
| loop(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Claude 4.6 thinks you write nice code.