/** * The board from above. * * It is three things: the minimap in the corner, the full-screen map Tab * holds open, and the view a phone plays in, where a raycaster with touch * look would be miserable. One function, drawn into whatever box it is given. * * Nothing here knows what the game is. The tiles, the shots the server let * us see, the last-seen marks and the bodies are generic; what a letter on * the grid is coloured, what else lies on the floor and what a body wears * for a badge, the game says through `opts` — see `drawTopdown`. */ const WALL_COLOUR = "#20242d"; const FLOOR_COLOUR = "#171a21"; const plainTile = (cell) => (cell === "#" ? WALL_COLOUR : FLOOR_COLOUR); const never = () => false; const nothing = () => {}; const grey = () => "#888"; /** * Draw the whole map into `box` (canvas pixels). `look(player)` gives the * smoothed position; `opts.labels` draws the grid's letters and names, which * only fit on the big version; `opts.marks` are the last-seen rings from * `presence.js`, which answer "where did they go" without claiming anybody * is there now; `opts.tiles: false` leaves out the map itself, for a caller * that has already drawn it once and kept it. * * The game's holes, each optional: `opts.tile(cell)` colours a letter, * `opts.label(cell)` says whether the letter is written on the big map, * `opts.under(ctx, snap, s)` draws what lies on the floor beneath the bodies, * `opts.over(ctx, snap, s)` what hides them from above, `opts.trim(player)` * is the ring round a body and `opts.badges(ctx, player, x, y, r)` whatever it * wears. `s` is world units to canvas pixels. * * Called with no snapshot it draws the tiles and stops, which is exactly * that layer. */ export function drawTopdown(ctx, map, snap, look, box, opts = {}) { const cols = map.rows[0].length; const s = box.w / (cols * map.tile); const labels = opts.labels !== false; const tiles = opts.tiles !== false; const tileColour = opts.tile || plainTile; const label = opts.label || never; ctx.save(); ctx.beginPath(); ctx.rect(box.x, box.y, box.w, box.h); ctx.clip(); ctx.translate(box.x, box.y); const t = map.tile * s; if (tiles) for (let row = 0; row < map.rows.length; row++) for (let col = 0; col < map.rows[row].length; col++) { const cell = map.rows[row][col]; ctx.fillStyle = tileColour(cell); ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); if (labels && label(cell)) { ctx.fillStyle = "#6b5f3a"; ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`; ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); } } if (!snap) { ctx.restore(); return; } (opts.under || nothing)(ctx, snap, s); for (const shot of snap.shots) { ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.5)"; ctx.lineWidth = Math.max(1, 2 * s); ctx.beginPath(); ctx.moveTo(shot.x1 * s, shot.y1 * s); ctx.lineTo(shot.x2 * s, shot.y2 * s); ctx.stroke(); } // Under the living: a ring where somebody was last seen is never allowed // to sit on top of a body that is actually in sight. if (opts.marks) for (const mark of opts.marks) drawMark(ctx, mark, s); const trim = opts.trim || grey; const badges = opts.badges || nothing; for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels, trim, badges); (opts.over || nothing)(ctx, snap, s); ctx.restore(); } /** * Where somebody was last seen: a hollow ring and the way they were facing, * dimming away. Hollow because a filled dot on this map means a body, and * this is a memory of one. */ function drawMark(ctx, m, s) { const x = m.x * s; const y = m.y * s; const r = Math.max(2.5, 16 * s); ctx.save(); ctx.globalAlpha = 0.8 * m.fade; ctx.strokeStyle = m.mate ? "#4fb3d9" : "#e35d5b"; ctx.lineWidth = Math.max(1, 2 * s); disc(ctx, x, y, r, false, true); ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(m.angle) * r * 1.7, y + Math.sin(m.angle) * r * 1.7); ctx.stroke(); ctx.restore(); } export function disc(ctx, x, y, r, fill, stroke) { ctx.beginPath(); ctx.arc(x, y, r, 0, 7); if (fill) ctx.fill(); if (stroke) ctx.stroke(); } /** * Everybody is a dot and you are an arrow — but a team mate is on the map * whether you can see them or not, with their initial in the dot and a cross * when they are down, and an enemy is only ever drawn while they are alive * and in sight. The map stays honest about vision and legible about your own. */ function drawPlayer(ctx, p, pos, s, me, labels, trim, badges) { const mine = p.id === me.id; if (!p.alive && !p.mate) return; // a corpse is not a threat and not a call-out const x = pos.x * s; const y = pos.y * s; const r = Math.max(2.5, 16 * s); const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; if (mine && p.alive) { ctx.save(); ctx.translate(x, y); ctx.rotate(pos.angle); ctx.fillStyle = body; ctx.beginPath(); ctx.moveTo(r * 1.5, 0); ctx.lineTo(-r, r * 0.9); ctx.lineTo(-r * 0.4, 0); ctx.lineTo(-r, -r * 0.9); ctx.closePath(); ctx.fill(); ctx.restore(); return; } // The dead of your own team keep their place on the map, crossed out. if (!p.alive) { ctx.strokeStyle = "#6a707c"; ctx.lineWidth = Math.max(1, 2 * s); ctx.beginPath(); ctx.moveTo(x - r * 0.8, y - r * 0.8); ctx.lineTo(x + r * 0.8, y + r * 0.8); ctx.moveTo(x + r * 0.8, y - r * 0.8); ctx.lineTo(x - r * 0.8, y + r * 0.8); ctx.stroke(); if (labels) { ctx.fillStyle = "rgba(180,185,195,0.6)"; ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; ctx.textAlign = "center"; ctx.fillText(p.name, x, y - r * 1.9); ctx.textAlign = "left"; } return; } ctx.fillStyle = body; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill(); ctx.strokeStyle = trim(p); ctx.lineWidth = Math.max(1, 3 * s); ctx.stroke(); ctx.strokeStyle = body; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); ctx.stroke(); // The initial inside the dot, when the dot is big enough to hold one. if (p.mate && r >= 6) { ctx.fillStyle = "#0c1016"; ctx.font = `${Math.round(r * 1.2)}px system-ui, sans-serif`; ctx.textAlign = "center"; ctx.fillText((p.name[0] || "?").toUpperCase(), x, y + r * 0.45); ctx.textAlign = "left"; } badges(ctx, p, x, y, r); if (labels) { ctx.fillStyle = "rgba(235,235,235,0.75)"; ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; ctx.textAlign = "center"; ctx.fillText(p.name, x, y - r * 1.9); ctx.textAlign = "left"; } if (p.hp < 100) { ctx.fillStyle = "#0008"; ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); ctx.fillStyle = "#6fcf6f"; ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); } }