rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files ad62f806cbbb0d37daa89167b8c4dd87c1d77999
| 1 | /** |
| 2 | * The board from above — the drawing this game started as. |
| 3 | * |
| 4 | * It is three things now: the minimap in the corner, the full-screen map Tab |
| 5 | * holds open, and the view a phone plays in, where a raycaster with touch |
| 6 | * look would be miserable. One function, drawn into whatever box it is given. |
| 7 | */ |
| 8 | |
| 9 | import { AGENT_COLOUR } from "./scene.js"; |
| 10 | |
| 11 | const TILE_COLOUR = { |
| 12 | "#": "#20242d", |
| 13 | A: "#2c2a22", |
| 14 | B: "#2c2a22", |
| 15 | a: "#2a2024", |
| 16 | d: "#1e2630", |
| 17 | ".": "#171a21", |
| 18 | }; |
| 19 | |
| 20 | /** |
| 21 | * Draw the whole map into `box` (canvas pixels). `look(player)` gives the |
| 22 | * smoothed position; `opts.labels` draws site letters and names, which only |
| 23 | * fit on the big version; `opts.marks` are the last-seen rings from |
| 24 | * `presence.js`, which answer "where did they go" without claiming anybody |
| 25 | * is there now; `opts.tiles: false` leaves out the map itself, for a caller |
| 26 | * that has already drawn it once and kept it — it is 570 rectangles of a |
| 27 | * map that never changes. |
| 28 | * |
| 29 | * Called with no snapshot it draws the tiles and stops, which is exactly |
| 30 | * that layer. |
| 31 | */ |
| 32 | export function drawTopdown(ctx, map, snap, look, box, opts = {}) { |
| 33 | const cols = map.rows[0].length; |
| 34 | const s = box.w / (cols * map.tile); |
| 35 | const labels = opts.labels !== false; |
| 36 | const tiles = opts.tiles !== false; |
| 37 | ctx.save(); |
| 38 | ctx.beginPath(); |
| 39 | ctx.rect(box.x, box.y, box.w, box.h); |
| 40 | ctx.clip(); |
| 41 | ctx.translate(box.x, box.y); |
| 42 | |
| 43 | const t = map.tile * s; |
| 44 | if (tiles) |
| 45 | for (let row = 0; row < map.rows.length; row++) |
| 46 | for (let col = 0; col < map.rows[row].length; col++) { |
| 47 | const cell = map.rows[row][col]; |
| 48 | ctx.fillStyle = TILE_COLOUR[cell] ?? "#171a21"; |
| 49 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); |
| 50 | if (labels && (cell === "A" || cell === "B")) { |
| 51 | ctx.fillStyle = "#6b5f3a"; |
| 52 | ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`; |
| 53 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | if (!snap) { |
| 58 | ctx.restore(); |
| 59 | return; |
| 60 | } |
| 61 | |
| 62 | for (const orb of snap.orbs) { |
| 63 | ctx.fillStyle = "#c9a0ff"; |
| 64 | ctx.beginPath(); |
| 65 | ctx.arc(orb.x * s, orb.y * s, Math.max(2, 9 * s), 0, 7); |
| 66 | ctx.fill(); |
| 67 | } |
| 68 | for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(ctx, zone, s); |
| 69 | |
| 70 | if (snap.spike.x !== null) { |
| 71 | const x = snap.spike.x * s; |
| 72 | const y = snap.spike.y * s; |
| 73 | const r = Math.max(2.5, 7 * s); |
| 74 | ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c"; |
| 75 | ctx.fillRect(x - r, y - r, r * 2, r * 2); |
| 76 | if (snap.spike.planted && snap.spike.defuse > 0) { |
| 77 | ctx.fillStyle = "#4fd6a0"; |
| 78 | ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 3); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | for (const shot of snap.shots) { |
| 83 | ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.5)"; |
| 84 | ctx.lineWidth = Math.max(1, 2 * s); |
| 85 | ctx.beginPath(); |
| 86 | ctx.moveTo(shot.x1 * s, shot.y1 * s); |
| 87 | ctx.lineTo(shot.x2 * s, shot.y2 * s); |
| 88 | ctx.stroke(); |
| 89 | } |
| 90 | |
| 91 | // Under the living: a ring where somebody was last seen is never allowed |
| 92 | // to sit on top of a body that is actually in sight. |
| 93 | if (opts.marks) for (const mark of opts.marks) drawMark(ctx, mark, s); |
| 94 | for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels); |
| 95 | // Smoke last: on this view it hides what is under it, as it should. |
| 96 | for (const zone of snap.zones) if (zone.k === "smoke" && zone.f <= 0) drawZone(ctx, zone, s); |
| 97 | ctx.restore(); |
| 98 | } |
| 99 | |
| 100 | function drawZone(ctx, z, s) { |
| 101 | const x = z.x * s; |
| 102 | const y = z.y * s; |
| 103 | const r = z.r * s; |
| 104 | ctx.lineWidth = Math.max(1, s); |
| 105 | if (z.k === "smoke") { |
| 106 | ctx.fillStyle = "rgba(190,195,205,0.72)"; |
| 107 | disc(ctx, x, y, r, true); |
| 108 | } else if (z.k === "molly") { |
| 109 | ctx.fillStyle = "rgba(230,110,50,0.45)"; |
| 110 | ctx.strokeStyle = "#f08a4b"; |
| 111 | disc(ctx, x, y, r, true, true); |
| 112 | } else if (z.k === "wall") { |
| 113 | ctx.fillStyle = "rgba(120,200,255,0.55)"; |
| 114 | disc(ctx, x, y, r, true); |
| 115 | } else if (z.k === "flash") { |
| 116 | ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)"; |
| 117 | disc(ctx, x, y, z.f > 0 ? 5 * s : 30 * s, true); |
| 118 | } else if (z.k === "dart") { |
| 119 | ctx.strokeStyle = "rgba(90,220,170,0.7)"; |
| 120 | disc(ctx, x, y, r, false, true); |
| 121 | } else if (z.k === "trip") { |
| 122 | ctx.strokeStyle = "rgba(230,200,90,0.8)"; |
| 123 | disc(ctx, x, y, r, false, true); |
| 124 | } else if (z.k === "heal") { |
| 125 | ctx.fillStyle = "rgba(120,230,140,0.5)"; |
| 126 | disc(ctx, x, y, 14 * s, true); |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Where somebody was last seen: a hollow ring and the way they were facing, |
| 132 | * dimming away. Hollow because a filled dot on this map means a body, and |
| 133 | * this is a memory of one. |
| 134 | */ |
| 135 | function drawMark(ctx, m, s) { |
| 136 | const x = m.x * s; |
| 137 | const y = m.y * s; |
| 138 | const r = Math.max(2.5, 16 * s); |
| 139 | ctx.save(); |
| 140 | ctx.globalAlpha = 0.8 * m.fade; |
| 141 | ctx.strokeStyle = m.mate ? "#4fb3d9" : "#e35d5b"; |
| 142 | ctx.lineWidth = Math.max(1, 2 * s); |
| 143 | disc(ctx, x, y, r, false, true); |
| 144 | ctx.beginPath(); |
| 145 | ctx.moveTo(x, y); |
| 146 | ctx.lineTo(x + Math.cos(m.angle) * r * 1.7, y + Math.sin(m.angle) * r * 1.7); |
| 147 | ctx.stroke(); |
| 148 | ctx.restore(); |
| 149 | } |
| 150 | |
| 151 | function disc(ctx, x, y, r, fill, stroke) { |
| 152 | ctx.beginPath(); |
| 153 | ctx.arc(x, y, r, 0, 7); |
| 154 | if (fill) ctx.fill(); |
| 155 | if (stroke) ctx.stroke(); |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * Everybody is a dot and you are an arrow — but a team mate is on the map |
| 160 | * whether you can see them or not, with their initial in the dot and a cross |
| 161 | * when they are down, and an enemy is only ever drawn while they are alive |
| 162 | * and in sight. The map stays honest about vision and legible about your own. |
| 163 | */ |
| 164 | function drawPlayer(ctx, p, pos, s, me, labels) { |
| 165 | const mine = p.id === me.id; |
| 166 | if (!p.alive && !p.mate) return; // a corpse is not a threat and not a call-out |
| 167 | const x = pos.x * s; |
| 168 | const y = pos.y * s; |
| 169 | const r = Math.max(2.5, 16 * s); |
| 170 | const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; |
| 171 | |
| 172 | if (mine && p.alive) { |
| 173 | ctx.save(); |
| 174 | ctx.translate(x, y); |
| 175 | ctx.rotate(pos.angle); |
| 176 | ctx.fillStyle = body; |
| 177 | ctx.beginPath(); |
| 178 | ctx.moveTo(r * 1.5, 0); |
| 179 | ctx.lineTo(-r, r * 0.9); |
| 180 | ctx.lineTo(-r * 0.4, 0); |
| 181 | ctx.lineTo(-r, -r * 0.9); |
| 182 | ctx.closePath(); |
| 183 | ctx.fill(); |
| 184 | ctx.restore(); |
| 185 | return; |
| 186 | } |
| 187 | |
| 188 | // The dead of your own team keep their place on the map, crossed out. |
| 189 | if (!p.alive) { |
| 190 | ctx.strokeStyle = "#6a707c"; |
| 191 | ctx.lineWidth = Math.max(1, 2 * s); |
| 192 | ctx.beginPath(); |
| 193 | ctx.moveTo(x - r * 0.8, y - r * 0.8); |
| 194 | ctx.lineTo(x + r * 0.8, y + r * 0.8); |
| 195 | ctx.moveTo(x + r * 0.8, y - r * 0.8); |
| 196 | ctx.lineTo(x - r * 0.8, y + r * 0.8); |
| 197 | ctx.stroke(); |
| 198 | if (labels) { |
| 199 | ctx.fillStyle = "rgba(180,185,195,0.6)"; |
| 200 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; |
| 201 | ctx.textAlign = "center"; |
| 202 | ctx.fillText(p.name, x, y - r * 1.9); |
| 203 | ctx.textAlign = "left"; |
| 204 | } |
| 205 | return; |
| 206 | } |
| 207 | |
| 208 | ctx.fillStyle = body; |
| 209 | ctx.beginPath(); |
| 210 | ctx.arc(x, y, r, 0, 7); |
| 211 | ctx.fill(); |
| 212 | ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888"; |
| 213 | ctx.lineWidth = Math.max(1, 3 * s); |
| 214 | ctx.stroke(); |
| 215 | ctx.strokeStyle = body; |
| 216 | ctx.beginPath(); |
| 217 | ctx.moveTo(x, y); |
| 218 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); |
| 219 | ctx.stroke(); |
| 220 | |
| 221 | // The initial inside the dot, when the dot is big enough to hold one. |
| 222 | if (p.mate && r >= 6) { |
| 223 | ctx.fillStyle = "#0c1016"; |
| 224 | ctx.font = `${Math.round(r * 1.2)}px system-ui, sans-serif`; |
| 225 | ctx.textAlign = "center"; |
| 226 | ctx.fillText((p.name[0] || "?").toUpperCase(), x, y + r * 0.45); |
| 227 | ctx.textAlign = "left"; |
| 228 | } |
| 229 | if (p.spike) { |
| 230 | ctx.fillStyle = "#f0d060"; |
| 231 | ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8); |
| 232 | } |
| 233 | if (p.blind) { |
| 234 | ctx.fillStyle = "rgba(255,255,255,0.8)"; |
| 235 | disc(ctx, x, y - r * 1.6, r * 0.35, true); |
| 236 | } |
| 237 | if (labels) { |
| 238 | ctx.fillStyle = "rgba(235,235,235,0.75)"; |
| 239 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; |
| 240 | ctx.textAlign = "center"; |
| 241 | ctx.fillText(p.name, x, y - r * 1.9); |
| 242 | ctx.textAlign = "left"; |
| 243 | } |
| 244 | if (p.hp < 100) { |
| 245 | ctx.fillStyle = "#0008"; |
| 246 | ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); |
| 247 | ctx.fillStyle = "#6fcf6f"; |
| 248 | ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); |
| 249 | } |
| 250 | } |