quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files f9684ed1bb99e56b05e36b22256d41a87611661b
| 1 | /** |
| 2 | * The world, drawn from inside the player's head. |
| 3 | * |
| 4 | * One pass of rays across the screen builds the walls and a depth buffer; |
| 5 | * everything else — bodies, the game's own props, tracers — is a billboard |
| 6 | * tested against that buffer, so a body behind a corner is not drawn at all. |
| 7 | * Fog is measured along each ray, not painted as a disc: cross one and |
| 8 | * everything past it fogs. |
| 9 | * |
| 10 | * The ray loop allocates nothing and draws nothing: it writes a shade level, |
| 11 | * a face and a slice into the column arrays, and `slices.js` collapses those |
| 12 | * into runs so a flat wall costs one `fillRect` and not one a column. |
| 13 | * Colours come out of a table built once, the depth and column buffers are |
| 14 | * kept between frames, and the sprite list is sorted in a buffer the scene |
| 15 | * owns. |
| 16 | * |
| 17 | * Nothing here knows what the game is. What a body is and how it hides is |
| 18 | * generic; what stands on the floor besides bodies, and what fogs a ray, the |
| 19 | * game says through `view` — see `drawScene`. |
| 20 | */ |
| 21 | |
| 22 | import { swell } from "./presence.js"; |
| 23 | import { makeColumns, SKY, wallRuns } from "./slices.js"; |
| 24 | import { |
| 25 | castRay, |
| 26 | columnOffset, |
| 27 | hitResult, |
| 28 | project, |
| 29 | projection, |
| 30 | projectResult, |
| 31 | sliceHeight, |
| 32 | smokeAlong, |
| 33 | spriteColumnVisible, |
| 34 | wallLight, |
| 35 | } from "./raycast.js"; |
| 36 | |
| 37 | export const EYE = 40; // half a tile up: the horizon sits on the middle of the wall |
| 38 | export const WALL = 80; // walls are one tile tall |
| 39 | const BODY = 58; // a person, in world units |
| 40 | const MAX_SIGHT = 1800; |
| 41 | const LEVELS = 48; |
| 42 | |
| 43 | // Wall colours, quantised into levels and built once, so the ray loop never |
| 44 | // builds a string. How dark a slice is comes from `wallLight`, which already |
| 45 | // knows about distance and which face was struck. |
| 46 | const SHADES = (() => { |
| 47 | const out = []; |
| 48 | for (let i = 0; i < LEVELS; i++) { |
| 49 | const k = (i + 0.5) / LEVELS; |
| 50 | out.push(`rgb(${Math.round(150 * k)},${Math.round(158 * k)},${Math.round(178 * k)})`); |
| 51 | } |
| 52 | return out; |
| 53 | })(); |
| 54 | const FOG = "rgb(198,203,212)"; |
| 55 | |
| 56 | const NO_FOG = () => {}; |
| 57 | const NO_PROPS = () => {}; |
| 58 | const PLAIN = () => "#999"; |
| 59 | const NO_BADGE = () => null; |
| 60 | |
| 61 | export function makeScene() { |
| 62 | return { |
| 63 | depth: new Float32Array(0), |
| 64 | cols: makeColumns(0), |
| 65 | runs: [], |
| 66 | hit: hitResult(), |
| 67 | proj: projectResult(), |
| 68 | order: [], |
| 69 | smokes: [], |
| 70 | sky: null, |
| 71 | skyKey: "", |
| 72 | }; |
| 73 | } |
| 74 | |
| 75 | const levelOf = (light) => Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0)); |
| 76 | |
| 77 | /** |
| 78 | * The walls, a rectangle a run rather than a rectangle a column. The runs |
| 79 | * are stretches where the shade level, the face, the fog and the geometry |
| 80 | * all agree — see `slices.js` — so this is the same picture the per-column |
| 81 | * loop drew, in a fraction of the calls. |
| 82 | */ |
| 83 | function drawWalls(ctx, scene, step) { |
| 84 | const runs = scene.runs; |
| 85 | const used = wallRuns(scene.cols, runs); |
| 86 | for (let r = 0; r < used; r++) { |
| 87 | const run = runs[r]; |
| 88 | const x = run.from * step; |
| 89 | const wide = (run.to - run.from + 1) * step + 1; |
| 90 | ctx.fillStyle = SHADES[run.level]; |
| 91 | ctx.fillRect(x, run.top, wide, run.high); |
| 92 | if (run.haze > 0.01) { |
| 93 | ctx.globalAlpha = run.haze; |
| 94 | ctx.fillStyle = FOG; |
| 95 | ctx.fillRect(x, run.top, wide, run.high); |
| 96 | ctx.globalAlpha = 1; |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * Draw one frame. |
| 103 | * |
| 104 | * `world` carries the map (`solid`, `tile`), `eye` is where the camera |
| 105 | * stands and looks, `snap` is the last snapshot the server sent — it must |
| 106 | * carry `me`, `players` and `shots` — and `view` is the frame: its pixel |
| 107 | * size, `look(player)` for the smoothed position to draw somebody at, and |
| 108 | * the game's own holes in the picture, each optional: |
| 109 | * |
| 110 | * - `fog(snap, out)` pushes the discs `{x, y, r}` a ray fogs through. |
| 111 | * - `props(snap, out)` pushes what stands on the floor besides bodies, each |
| 112 | * `{x, y, draw}`; `draw(ctx, thing, floorY, proj, clip)` is handed the |
| 113 | * projected thing (`column`, `perp`, `size`) and a `clip(thing, left, |
| 114 | * right, fn)` that runs `fn` only where the thing beats the wall. |
| 115 | * - `trim(player)` is the colour of the stripe across a body. |
| 116 | * - `badge(player)` is a colour to mark a body with, or null. |
| 117 | */ |
| 118 | export function drawScene(ctx, scene, world, eye, snap, view) { |
| 119 | const W = view.width; |
| 120 | const H = view.height; |
| 121 | // One ray every other column of the buffer, and the wall slice two columns |
| 122 | // wide: flat shading does not reward a ray a pixel, and the sprites are |
| 123 | // still drawn at the buffer's own resolution. Below that width a picture |
| 124 | // this coarse stops being one. |
| 125 | const step = W >= 640 ? 2 : 1; |
| 126 | const columns = Math.ceil(W / step); |
| 127 | if (scene.depth.length !== columns) { |
| 128 | scene.depth = new Float32Array(columns); |
| 129 | scene.cols = makeColumns(columns); |
| 130 | } |
| 131 | const proj = projection(W); |
| 132 | const horizon = H / 2; |
| 133 | |
| 134 | drawSky(ctx, scene, W, H, horizon); |
| 135 | |
| 136 | const smokes = scene.smokes; |
| 137 | smokes.length = 0; |
| 138 | (view.fog || NO_FOG)(snap, smokes); |
| 139 | |
| 140 | const depth = scene.depth; |
| 141 | const cols = scene.cols; |
| 142 | const fog = cols.haze; |
| 143 | const hit = scene.hit; |
| 144 | for (let i = 0; i < columns; i++) { |
| 145 | const offset = columnOffset(i * step + step / 2, W); |
| 146 | const angle = eye.angle + offset; |
| 147 | castRay(world.solid, eye.x, eye.y, angle, world.tile, MAX_SIGHT, hit); |
| 148 | const perp = Math.max(1, hit.dist * Math.cos(offset)); |
| 149 | depth[i] = perp; |
| 150 | let haze = 0; |
| 151 | if (smokes.length) { |
| 152 | const dx = Math.cos(angle); |
| 153 | const dy = Math.sin(angle); |
| 154 | for (let s = 0; s < smokes.length; s++) { |
| 155 | const z = smokes[s]; |
| 156 | haze += smokeAlong(eye.x, eye.y, dx, dy, z.x, z.y, z.r, hit.dist); |
| 157 | if (haze >= 1) break; |
| 158 | } |
| 159 | haze = Math.min(1, haze); |
| 160 | } |
| 161 | fog[i] = haze; |
| 162 | if (!hit.wall) { |
| 163 | cols.level[i] = SKY; |
| 164 | continue; |
| 165 | } |
| 166 | const h = Math.min(H * 6, sliceHeight(perp, proj, WALL)); |
| 167 | cols.level[i] = levelOf(wallLight(perp, hit.side, world.tile)); |
| 168 | cols.side[i] = hit.side; |
| 169 | cols.top[i] = horizon - h / 2; |
| 170 | cols.high[i] = h; |
| 171 | } |
| 172 | drawWalls(ctx, scene, step); |
| 173 | |
| 174 | drawThings(ctx, scene, eye, snap, view, proj, horizon, step, columns); |
| 175 | } |
| 176 | |
| 177 | /** The two bands: a dark ceiling above the horizon, a warmer floor below. */ |
| 178 | function drawSky(ctx, scene, W, H, horizon) { |
| 179 | const key = `${W}x${H}`; |
| 180 | if (scene.skyKey !== key || !scene.sky) { |
| 181 | const sky = ctx.createLinearGradient(0, 0, 0, H); |
| 182 | sky.addColorStop(0, "#0a0c11"); |
| 183 | sky.addColorStop(0.49, "#23262f"); |
| 184 | sky.addColorStop(0.5, "#3a3026"); |
| 185 | sky.addColorStop(1, "#15120e"); |
| 186 | scene.sky = sky; |
| 187 | scene.skyKey = key; |
| 188 | } |
| 189 | ctx.fillStyle = scene.sky; |
| 190 | ctx.fillRect(0, 0, W, H); |
| 191 | ctx.fillStyle = "rgba(255,255,255,0.09)"; |
| 192 | ctx.fillRect(0, horizon - 1, W, 2); |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * Everything that is not a wall, far to near, each one clipped to the screen |
| 197 | * columns where it beats the wall behind it. The game's props go in first, |
| 198 | * then bodies, then marks: a tie in depth keeps that order. |
| 199 | */ |
| 200 | function drawThings(ctx, scene, eye, snap, view, proj, horizon, step, columns) { |
| 201 | const W = view.width; |
| 202 | const things = scene.order; |
| 203 | things.length = 0; |
| 204 | |
| 205 | (view.props || NO_PROPS)(snap, things); |
| 206 | const trim = view.trim || PLAIN; |
| 207 | const badge = view.badge || NO_BADGE; |
| 208 | for (const player of snap.players) { |
| 209 | if (player.id === snap.me.id) continue; |
| 210 | const at = view.look(player); |
| 211 | // How far into sight this body is: 0 the moment it appears, 1 solid. |
| 212 | // See presence.js — a body that pops into existence reads as a bug. |
| 213 | const shown = view.shown ? view.shown(player) : 1; |
| 214 | things.push({ kind: "body", player, shown, x: at.x, y: at.y, angle: at.angle }); |
| 215 | } |
| 216 | // Where somebody was last seen, dimming away. It stands where they were, |
| 217 | // so a wall between you and that spot hides it like anything else. |
| 218 | if (view.marks) for (const mark of view.marks) things.push({ kind: "mark", mark, x: mark.x, y: mark.y }); |
| 219 | |
| 220 | const p = scene.proj; |
| 221 | for (const thing of things) { |
| 222 | project(eye.x, eye.y, eye.angle, thing.x, thing.y, W, proj, BODY, p); |
| 223 | thing.perp = p.perp; |
| 224 | thing.column = p.column; |
| 225 | thing.size = p.size; |
| 226 | thing.behind = p.behind; |
| 227 | } |
| 228 | things.sort((a, b) => b.perp - a.perp); |
| 229 | |
| 230 | const clip = (thing, left, right, fn) => withRuns(ctx, scene, left, right, thing.perp, step, columns, fn); |
| 231 | for (const thing of things) { |
| 232 | if (thing.behind) continue; |
| 233 | const floorY = horizon + (EYE * proj) / thing.perp; |
| 234 | const haze = hazeAt(scene, thing.column, step, columns); |
| 235 | const arriving = thing.kind === "body" ? thing.shown : thing.kind === "mark" ? thing.mark.fade : 1; |
| 236 | ctx.globalAlpha = Math.max(0, 1 - haze) * arriving; |
| 237 | if (ctx.globalAlpha < 0.05) continue; |
| 238 | if (thing.kind === "body") drawBody(ctx, thing, floorY, clip, trim, badge); |
| 239 | else if (thing.kind === "mark") drawMark(ctx, thing, floorY, clip); |
| 240 | else thing.draw(ctx, thing, floorY, proj, clip); |
| 241 | ctx.globalAlpha = 1; |
| 242 | } |
| 243 | ctx.globalAlpha = 1; |
| 244 | |
| 245 | drawTracers(ctx, scene, eye, snap, W, proj, horizon); |
| 246 | } |
| 247 | |
| 248 | const hazeAt = (scene, column, step, columns) => { |
| 249 | const i = Math.max(0, Math.min(columns - 1, Math.round(column / step))); |
| 250 | return scene.cols.haze[i] ?? 0; |
| 251 | }; |
| 252 | |
| 253 | /** |
| 254 | * The visible runs of a sprite: the stretches of screen column where it is |
| 255 | * nearer than the wall. Two rectangles at most in practice, and the caller |
| 256 | * clips its drawing to each. |
| 257 | */ |
| 258 | function withRuns(ctx, scene, left, right, perp, step, columns, draw) { |
| 259 | let runStart = -1; |
| 260 | const from = Math.max(0, Math.floor(left / step)); |
| 261 | const to = Math.min(columns - 1, Math.ceil(right / step)); |
| 262 | for (let i = from; i <= to + 1; i++) { |
| 263 | const shown = i <= to && spriteColumnVisible(perp, scene.depth[i]); |
| 264 | if (shown && runStart < 0) runStart = i; |
| 265 | if (!shown && runStart >= 0) { |
| 266 | ctx.save(); |
| 267 | ctx.beginPath(); |
| 268 | ctx.rect(runStart * step - 0.5, 0, (i - runStart) * step + 1, 1e5); |
| 269 | ctx.clip(); |
| 270 | draw(); |
| 271 | ctx.restore(); |
| 272 | runStart = -1; |
| 273 | } |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | function drawBody(ctx, thing, floorY, clip, trim, badge) { |
| 278 | const p = thing.player; |
| 279 | // A body arriving grows from its feet as it fades in, so it reads as |
| 280 | // somebody coming round the corner rather than one appearing there. |
| 281 | const h = thing.size * swell(thing.shown ?? 1); |
| 282 | const w = h * 0.46; |
| 283 | const cx = thing.column; |
| 284 | const top = floorY - h; |
| 285 | const mate = p.mate; |
| 286 | |
| 287 | // The dead lie down. No tag, no chevron, no trim colour: if something is |
| 288 | // upright with a marker over it, it is alive and it can shoot you. |
| 289 | if (!p.alive) { |
| 290 | const lie = h * 0.16; |
| 291 | clip(thing, cx - w * 1.1, cx + w * 1.1, () => { |
| 292 | ctx.fillStyle = "#4a4e57"; |
| 293 | ctx.beginPath(); |
| 294 | ctx.ellipse(cx, floorY - lie * 0.4, w * 1.05, lie, 0, 0, 7); |
| 295 | ctx.fill(); |
| 296 | ctx.strokeStyle = "#23262d"; |
| 297 | ctx.lineWidth = Math.max(1, h * 0.02); |
| 298 | ctx.stroke(); |
| 299 | }); |
| 300 | return; |
| 301 | } |
| 302 | |
| 303 | const skin = mate ? "#3f9fd6" : "#d8483f"; |
| 304 | const stripe = trim(p); |
| 305 | const mark = badge(p); |
| 306 | const edge = Math.max(1.2, h * 0.05); |
| 307 | |
| 308 | clip(thing, cx - w * 1.2, cx + w * 1.5, () => { |
| 309 | // Body and head, each with a hard dark edge: the silhouette is what |
| 310 | // carries across a dim room, where the colour does not. |
| 311 | ctx.fillStyle = skin; |
| 312 | ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72); |
| 313 | ctx.strokeStyle = "#0b0d11"; |
| 314 | ctx.lineWidth = edge; |
| 315 | ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72); |
| 316 | ctx.fillStyle = stripe; |
| 317 | ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1); |
| 318 | ctx.beginPath(); |
| 319 | ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7); |
| 320 | ctx.fillStyle = skin; |
| 321 | ctx.fill(); |
| 322 | ctx.strokeStyle = "#0b0d11"; |
| 323 | ctx.stroke(); |
| 324 | |
| 325 | if (mark) { |
| 326 | ctx.fillStyle = mark; |
| 327 | ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3); |
| 328 | } |
| 329 | if (p.hp < 100) { |
| 330 | ctx.fillStyle = "#000a"; |
| 331 | ctx.fillRect(cx - w / 2, top - h * 0.1, w, h * 0.06); |
| 332 | ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a"; |
| 333 | ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06); |
| 334 | } |
| 335 | |
| 336 | if (mate) tag(ctx, p, cx, top, h); |
| 337 | else chevron(ctx, cx, top, h); |
| 338 | }); |
| 339 | } |
| 340 | |
| 341 | /** |
| 342 | * A mark where somebody was last seen: an empty outline and a scuff on the |
| 343 | * floor, dimming away over a second. Deliberately not a body — nothing is |
| 344 | * filled, there is no head and no chevron — because it is a memory of a |
| 345 | * sighting and must never be mistaken for one. |
| 346 | */ |
| 347 | function drawMark(ctx, thing, floorY, clip) { |
| 348 | const h = thing.size * 0.9; |
| 349 | const w = h * 0.46; |
| 350 | const cx = thing.column; |
| 351 | const top = floorY - h; |
| 352 | clip(thing, cx - w * 1.2, cx + w * 1.2, () => { |
| 353 | ctx.strokeStyle = thing.mark.mate ? "rgba(120,180,230,0.85)" : "rgba(235,150,120,0.9)"; |
| 354 | ctx.lineWidth = Math.max(1, h * 0.035); |
| 355 | ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72); |
| 356 | ctx.fillStyle = "rgba(10,12,16,0.45)"; |
| 357 | ctx.fillRect(cx - w * 0.7, floorY - h * 0.04, w * 1.4, h * 0.05); |
| 358 | }); |
| 359 | } |
| 360 | |
| 361 | /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read |
| 362 | * red and blue the same way. */ |
| 363 | function chevron(ctx, cx, top, h) { |
| 364 | const size = Math.max(4, h * 0.16); |
| 365 | const y = top - h * 0.16; |
| 366 | ctx.beginPath(); |
| 367 | ctx.moveTo(cx - size, y - size); |
| 368 | ctx.lineTo(cx + size, y - size); |
| 369 | ctx.lineTo(cx, y); |
| 370 | ctx.closePath(); |
| 371 | ctx.fillStyle = "#ff6f5e"; |
| 372 | ctx.fill(); |
| 373 | ctx.lineWidth = Math.max(1, h * 0.02); |
| 374 | ctx.strokeStyle = "#140a08"; |
| 375 | ctx.stroke(); |
| 376 | } |
| 377 | |
| 378 | /** A team mate wears their name and a pip of their health — never an enemy. */ |
| 379 | function tag(ctx, p, cx, top, h) { |
| 380 | const size = Math.max(9, Math.min(18, h * 0.16)); |
| 381 | const y = top - h * 0.34; |
| 382 | ctx.font = `${size}px system-ui, sans-serif`; |
| 383 | ctx.textAlign = "center"; |
| 384 | const width = ctx.measureText(p.name).width + size * 1.4; |
| 385 | ctx.fillStyle = "rgba(10,12,16,0.65)"; |
| 386 | ctx.fillRect(cx - width / 2, y, width, size * 1.4); |
| 387 | ctx.fillStyle = p.hp > 60 ? "#6fcf6f" : p.hp > 25 ? "#e0a040" : "#e05a4a"; |
| 388 | ctx.fillRect(cx - width / 2 + size * 0.25, y + size * 0.35, size * 0.45, size * 0.7); |
| 389 | ctx.fillStyle = "#cfe8ff"; |
| 390 | ctx.fillText(p.name, cx + size * 0.4, y + size * 1.05); |
| 391 | ctx.textAlign = "left"; |
| 392 | } |
| 393 | |
| 394 | /** A round thing on the floor, for a game to draw its pickups with. */ |
| 395 | export function drawBlob(ctx, thing, floorY, colour, scale, clip) { |
| 396 | const r = thing.size * scale; |
| 397 | const cx = thing.column; |
| 398 | clip(thing, cx - r, cx + r, () => { |
| 399 | ctx.fillStyle = colour; |
| 400 | ctx.beginPath(); |
| 401 | ctx.arc(cx, floorY - r, r, 0, 7); |
| 402 | ctx.fill(); |
| 403 | }); |
| 404 | } |
| 405 | |
| 406 | /** Shots the server let us see, as a line through the world. */ |
| 407 | function drawTracers(ctx, scene, eye, snap, W, proj, horizon) { |
| 408 | if (!snap.shots.length) return; |
| 409 | const p = scene.proj; |
| 410 | ctx.lineWidth = 2; |
| 411 | for (const shot of snap.shots) { |
| 412 | project(eye.x, eye.y, eye.angle, shot.x1, shot.y1, W, proj, BODY, p); |
| 413 | if (p.behind) continue; |
| 414 | const x1 = p.column; |
| 415 | const y1 = horizon + (EYE * proj) / p.perp - p.size * 0.6; |
| 416 | project(eye.x, eye.y, eye.angle, shot.x2, shot.y2, W, proj, BODY, p); |
| 417 | if (p.behind) continue; |
| 418 | ctx.strokeStyle = shot.hit ? "rgba(255,140,120,0.5)" : "rgba(255,240,190,0.35)"; |
| 419 | ctx.beginPath(); |
| 420 | ctx.moveTo(x1, y1); |
| 421 | ctx.lineTo(p.column, horizon + (EYE * proj) / p.perp - p.size * 0.6); |
| 422 | ctx.stroke(); |
| 423 | } |
| 424 | } |