quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files d7211248ad6d576d3c93536129a0d746e3c90526
| 1 | /** |
| 2 | * The board from above. |
| 3 | * |
| 4 | * It is three things: 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 | * Nothing here knows what the game is. The tiles, the shots the server let |
| 9 | * us see, the last-seen marks and the bodies are generic; what a letter on |
| 10 | * the grid is coloured, what else lies on the floor and what a body wears |
| 11 | * for a badge, the game says through `opts` — see `drawTopdown`. |
| 12 | */ |
| 13 | |
| 14 | const WALL_COLOUR = "#20242d"; |
| 15 | const FLOOR_COLOUR = "#171a21"; |
| 16 | |
| 17 | const plainTile = (cell) => (cell === "#" ? WALL_COLOUR : FLOOR_COLOUR); |
| 18 | const never = () => false; |
| 19 | const nothing = () => {}; |
| 20 | const grey = () => "#888"; |
| 21 | |
| 22 | /** |
| 23 | * Draw the whole map into `box` (canvas pixels). `look(player)` gives the |
| 24 | * smoothed position; `opts.labels` draws the grid's letters and names, which |
| 25 | * only fit on the big version; `opts.marks` are the last-seen rings from |
| 26 | * `presence.js`, which answer "where did they go" without claiming anybody |
| 27 | * is there now; `opts.tiles: false` leaves out the map itself, for a caller |
| 28 | * that has already drawn it once and kept it. |
| 29 | * |
| 30 | * The game's holes, each optional: `opts.tile(cell)` colours a letter, |
| 31 | * `opts.label(cell)` says whether the letter is written on the big map, |
| 32 | * `opts.under(ctx, snap, s)` draws what lies on the floor beneath the bodies, |
| 33 | * `opts.over(ctx, snap, s)` what hides them from above, `opts.trim(player)` |
| 34 | * is the ring round a body and `opts.badges(ctx, player, x, y, r)` whatever it |
| 35 | * wears. `s` is world units to canvas pixels. |
| 36 | * |
| 37 | * Called with no snapshot it draws the tiles and stops, which is exactly |
| 38 | * that layer. |
| 39 | */ |
| 40 | export function drawTopdown(ctx, map, snap, look, box, opts = {}) { |
| 41 | const cols = map.rows[0].length; |
| 42 | const s = box.w / (cols * map.tile); |
| 43 | const labels = opts.labels !== false; |
| 44 | const tiles = opts.tiles !== false; |
| 45 | const tileColour = opts.tile || plainTile; |
| 46 | const label = opts.label || never; |
| 47 | ctx.save(); |
| 48 | ctx.beginPath(); |
| 49 | ctx.rect(box.x, box.y, box.w, box.h); |
| 50 | ctx.clip(); |
| 51 | ctx.translate(box.x, box.y); |
| 52 | |
| 53 | const t = map.tile * s; |
| 54 | if (tiles) |
| 55 | for (let row = 0; row < map.rows.length; row++) |
| 56 | for (let col = 0; col < map.rows[row].length; col++) { |
| 57 | const cell = map.rows[row][col]; |
| 58 | ctx.fillStyle = tileColour(cell); |
| 59 | ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6); |
| 60 | if (labels && label(cell)) { |
| 61 | ctx.fillStyle = "#6b5f3a"; |
| 62 | ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`; |
| 63 | ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68); |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | if (!snap) { |
| 68 | ctx.restore(); |
| 69 | return; |
| 70 | } |
| 71 | |
| 72 | (opts.under || nothing)(ctx, snap, s); |
| 73 | |
| 74 | for (const shot of snap.shots) { |
| 75 | ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.5)"; |
| 76 | ctx.lineWidth = Math.max(1, 2 * s); |
| 77 | ctx.beginPath(); |
| 78 | ctx.moveTo(shot.x1 * s, shot.y1 * s); |
| 79 | ctx.lineTo(shot.x2 * s, shot.y2 * s); |
| 80 | ctx.stroke(); |
| 81 | } |
| 82 | |
| 83 | // Under the living: a ring where somebody was last seen is never allowed |
| 84 | // to sit on top of a body that is actually in sight. |
| 85 | if (opts.marks) for (const mark of opts.marks) drawMark(ctx, mark, s); |
| 86 | const trim = opts.trim || grey; |
| 87 | const badges = opts.badges || nothing; |
| 88 | for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels, trim, badges); |
| 89 | (opts.over || nothing)(ctx, snap, s); |
| 90 | ctx.restore(); |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Where somebody was last seen: a hollow ring and the way they were facing, |
| 95 | * dimming away. Hollow because a filled dot on this map means a body, and |
| 96 | * this is a memory of one. |
| 97 | */ |
| 98 | function drawMark(ctx, m, s) { |
| 99 | const x = m.x * s; |
| 100 | const y = m.y * s; |
| 101 | const r = Math.max(2.5, 16 * s); |
| 102 | ctx.save(); |
| 103 | ctx.globalAlpha = 0.8 * m.fade; |
| 104 | ctx.strokeStyle = m.mate ? "#4fb3d9" : "#e35d5b"; |
| 105 | ctx.lineWidth = Math.max(1, 2 * s); |
| 106 | disc(ctx, x, y, r, false, true); |
| 107 | ctx.beginPath(); |
| 108 | ctx.moveTo(x, y); |
| 109 | ctx.lineTo(x + Math.cos(m.angle) * r * 1.7, y + Math.sin(m.angle) * r * 1.7); |
| 110 | ctx.stroke(); |
| 111 | ctx.restore(); |
| 112 | } |
| 113 | |
| 114 | export function disc(ctx, x, y, r, fill, stroke) { |
| 115 | ctx.beginPath(); |
| 116 | ctx.arc(x, y, r, 0, 7); |
| 117 | if (fill) ctx.fill(); |
| 118 | if (stroke) ctx.stroke(); |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Everybody is a dot and you are an arrow — but a team mate is on the map |
| 123 | * whether you can see them or not, with their initial in the dot and a cross |
| 124 | * when they are down, and an enemy is only ever drawn while they are alive |
| 125 | * and in sight. The map stays honest about vision and legible about your own. |
| 126 | */ |
| 127 | function drawPlayer(ctx, p, pos, s, me, labels, trim, badges) { |
| 128 | const mine = p.id === me.id; |
| 129 | if (!p.alive && !p.mate) return; // a corpse is not a threat and not a call-out |
| 130 | const x = pos.x * s; |
| 131 | const y = pos.y * s; |
| 132 | const r = Math.max(2.5, 16 * s); |
| 133 | const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; |
| 134 | |
| 135 | if (mine && p.alive) { |
| 136 | ctx.save(); |
| 137 | ctx.translate(x, y); |
| 138 | ctx.rotate(pos.angle); |
| 139 | ctx.fillStyle = body; |
| 140 | ctx.beginPath(); |
| 141 | ctx.moveTo(r * 1.5, 0); |
| 142 | ctx.lineTo(-r, r * 0.9); |
| 143 | ctx.lineTo(-r * 0.4, 0); |
| 144 | ctx.lineTo(-r, -r * 0.9); |
| 145 | ctx.closePath(); |
| 146 | ctx.fill(); |
| 147 | ctx.restore(); |
| 148 | return; |
| 149 | } |
| 150 | |
| 151 | // The dead of your own team keep their place on the map, crossed out. |
| 152 | if (!p.alive) { |
| 153 | ctx.strokeStyle = "#6a707c"; |
| 154 | ctx.lineWidth = Math.max(1, 2 * s); |
| 155 | ctx.beginPath(); |
| 156 | ctx.moveTo(x - r * 0.8, y - r * 0.8); |
| 157 | ctx.lineTo(x + r * 0.8, y + r * 0.8); |
| 158 | ctx.moveTo(x + r * 0.8, y - r * 0.8); |
| 159 | ctx.lineTo(x - r * 0.8, y + r * 0.8); |
| 160 | ctx.stroke(); |
| 161 | if (labels) { |
| 162 | ctx.fillStyle = "rgba(180,185,195,0.6)"; |
| 163 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; |
| 164 | ctx.textAlign = "center"; |
| 165 | ctx.fillText(p.name, x, y - r * 1.9); |
| 166 | ctx.textAlign = "left"; |
| 167 | } |
| 168 | return; |
| 169 | } |
| 170 | |
| 171 | ctx.fillStyle = body; |
| 172 | ctx.beginPath(); |
| 173 | ctx.arc(x, y, r, 0, 7); |
| 174 | ctx.fill(); |
| 175 | ctx.strokeStyle = trim(p); |
| 176 | ctx.lineWidth = Math.max(1, 3 * s); |
| 177 | ctx.stroke(); |
| 178 | ctx.strokeStyle = body; |
| 179 | ctx.beginPath(); |
| 180 | ctx.moveTo(x, y); |
| 181 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); |
| 182 | ctx.stroke(); |
| 183 | |
| 184 | // The initial inside the dot, when the dot is big enough to hold one. |
| 185 | if (p.mate && r >= 6) { |
| 186 | ctx.fillStyle = "#0c1016"; |
| 187 | ctx.font = `${Math.round(r * 1.2)}px system-ui, sans-serif`; |
| 188 | ctx.textAlign = "center"; |
| 189 | ctx.fillText((p.name[0] || "?").toUpperCase(), x, y + r * 0.45); |
| 190 | ctx.textAlign = "left"; |
| 191 | } |
| 192 | badges(ctx, p, x, y, r); |
| 193 | if (labels) { |
| 194 | ctx.fillStyle = "rgba(235,235,235,0.75)"; |
| 195 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; |
| 196 | ctx.textAlign = "center"; |
| 197 | ctx.fillText(p.name, x, y - r * 1.9); |
| 198 | ctx.textAlign = "left"; |
| 199 | } |
| 200 | if (p.hp < 100) { |
| 201 | ctx.fillStyle = "#0008"; |
| 202 | ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); |
| 203 | ctx.fillStyle = "#6fcf6f"; |
| 204 | ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3); |
| 205 | } |
| 206 | } |