/** * The board from above — the drawing this game started as. * * It is three things now: 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. */ import { AGENT_COLOUR } from "./scene.js"; const TILE_COLOUR = { "#": "#20242d", A: "#2c2a22", B: "#2c2a22", a: "#2a2024", d: "#1e2630", ".": "#171a21", }; /** * Draw the whole map into `box` (canvas pixels). `look(player)` gives the * smoothed position; `opts.labels` draws site 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. */ 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; 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; 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 = TILE_COLOUR[cell] ?? "#171a21"; ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); if (labels && (cell === "A" || cell === "B")) { 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; } for (const orb of snap.orbs) { ctx.fillStyle = "#c9a0ff"; ctx.beginPath(); ctx.arc(orb.x * s, orb.y * s, Math.max(2, 9 * s), 0, 7); ctx.fill(); } for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(ctx, zone, s); if (snap.spike.x !== null) { const x = snap.spike.x * s; const y = snap.spike.y * s; const r = Math.max(2.5, 7 * s); ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c"; ctx.fillRect(x - r, y - r, r * 2, r * 2); if (snap.spike.planted && snap.spike.defuse > 0) { ctx.fillStyle = "#4fd6a0"; ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 3); } } 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); for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels); // Smoke last: on this view it hides what is under it, as it should. for (const zone of snap.zones) if (zone.k === "smoke" && zone.f <= 0) drawZone(ctx, zone, s); ctx.restore(); } function drawZone(ctx, z, s) { const x = z.x * s; const y = z.y * s; const r = z.r * s; ctx.lineWidth = Math.max(1, s); if (z.k === "smoke") { ctx.fillStyle = "rgba(190,195,205,0.72)"; disc(ctx, x, y, r, true); } else if (z.k === "molly") { ctx.fillStyle = "rgba(230,110,50,0.45)"; ctx.strokeStyle = "#f08a4b"; disc(ctx, x, y, r, true, true); } else if (z.k === "wall") { ctx.fillStyle = "rgba(120,200,255,0.55)"; disc(ctx, x, y, r, true); } else if (z.k === "flash") { ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)"; disc(ctx, x, y, z.f > 0 ? 5 * s : 30 * s, true); } else if (z.k === "dart") { ctx.strokeStyle = "rgba(90,220,170,0.7)"; disc(ctx, x, y, r, false, true); } else if (z.k === "trip") { ctx.strokeStyle = "rgba(230,200,90,0.8)"; disc(ctx, x, y, r, false, true); } else if (z.k === "heal") { ctx.fillStyle = "rgba(120,230,140,0.5)"; disc(ctx, x, y, 14 * s, true); } } /** * 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(); } 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) { 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 = AGENT_COLOUR[p.agent] || "#888"; 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"; } if (p.spike) { ctx.fillStyle = "#f0d060"; ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8); } if (p.blind) { ctx.fillStyle = "rgba(255,255,255,0.8)"; disc(ctx, x, y - r * 1.6, r * 0.35, true); } 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); } }