/** * The world, drawn from inside the player's head. * * One pass of rays across the screen builds the walls and a depth buffer; * everything else — bodies, the spike, ability zones, tracers — is a * billboard tested against that buffer, so a body behind a corner is not * drawn at all. Smoke is measured along each ray, not painted as a disc: * cross one and everything past it fogs. * * The ray loop allocates nothing and draws nothing: it writes a shade level, * a face and a slice into the column arrays, and `slices.js` collapses those * into runs so a flat wall costs one `fillRect` and not one a column. * Colours come out of a table built once, the depth and column buffers are * kept between frames, and the sprite list is sorted in a buffer the scene * owns. */ import { swell } from "./presence.js"; import { makeColumns, SKY, wallRuns } from "./slices.js"; import { castRay, columnOffset, hitResult, project, projection, projectResult, sliceHeight, smokeAlong, spriteColumnVisible, wallLight, } from "./raycast.js"; const EYE = 40; // half a tile up: the horizon sits on the middle of the wall const WALL = 80; // walls are one tile tall const BODY = 58; // a person, in world units const MAX_SIGHT = 1800; const LEVELS = 48; export const AGENT_COLOUR = { ember: "#f08a4b", veil: "#8f7fe8", sable: "#4fd6a0", mender: "#e0c060", rook: "#d96a8f", }; // Wall colours, quantised into levels and built once, so the ray loop never // builds a string. How dark a slice is comes from `wallLight`, which already // knows about distance and which face was struck. const SHADES = (() => { const out = []; for (let i = 0; i < LEVELS; i++) { const k = (i + 0.5) / LEVELS; out.push(`rgb(${Math.round(150 * k)},${Math.round(158 * k)},${Math.round(178 * k)})`); } return out; })(); const FOG = "rgb(198,203,212)"; export function makeScene() { return { depth: new Float32Array(0), cols: makeColumns(0), runs: [], hit: hitResult(), proj: projectResult(), order: [], smokes: [], sky: null, skyKey: "", }; } const levelOf = (light) => Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0)); /** * The walls, a rectangle a run rather than a rectangle a column. The runs * are stretches where the shade level, the face, the fog and the geometry * all agree — see `slices.js` — so this is the same picture the per-column * loop drew, in a fraction of the calls. */ function drawWalls(ctx, scene, step) { const runs = scene.runs; const used = wallRuns(scene.cols, runs); for (let r = 0; r < used; r++) { const run = runs[r]; const x = run.from * step; const wide = (run.to - run.from + 1) * step + 1; ctx.fillStyle = SHADES[run.level]; ctx.fillRect(x, run.top, wide, run.high); if (run.haze > 0.01) { ctx.globalAlpha = run.haze; ctx.fillStyle = FOG; ctx.fillRect(x, run.top, wide, run.high); ctx.globalAlpha = 1; } } } /** * Draw one frame. * * `world` carries the map (`solid`, `tile`), `eye` is where the camera * stands and looks, `snap` is the last snapshot the server sent, and `view` * is the frame: its pixel size, and `look(player)` for the smoothed position * to draw somebody at. */ export function drawScene(ctx, scene, world, eye, snap, view) { const W = view.width; const H = view.height; // One ray every other column of the buffer, and the wall slice two columns // wide: flat shading does not reward a ray a pixel, and the sprites are // still drawn at the buffer's own resolution. Below that width a picture // this coarse stops being one. const step = W >= 640 ? 2 : 1; const columns = Math.ceil(W / step); if (scene.depth.length !== columns) { scene.depth = new Float32Array(columns); scene.cols = makeColumns(columns); } const proj = projection(W); const horizon = H / 2; drawSky(ctx, scene, W, H, horizon); const smokes = scene.smokes; smokes.length = 0; for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone); const depth = scene.depth; const cols = scene.cols; const fog = cols.haze; const hit = scene.hit; for (let i = 0; i < columns; i++) { const offset = columnOffset(i * step + step / 2, W); const angle = eye.angle + offset; castRay(world.solid, eye.x, eye.y, angle, world.tile, MAX_SIGHT, hit); const perp = Math.max(1, hit.dist * Math.cos(offset)); depth[i] = perp; let haze = 0; if (smokes.length) { const dx = Math.cos(angle); const dy = Math.sin(angle); for (let s = 0; s < smokes.length; s++) { const z = smokes[s]; haze += smokeAlong(eye.x, eye.y, dx, dy, z.x, z.y, z.r, hit.dist); if (haze >= 1) break; } haze = Math.min(1, haze); } fog[i] = haze; if (!hit.wall) { cols.level[i] = SKY; continue; } const h = Math.min(H * 6, sliceHeight(perp, proj, WALL)); cols.level[i] = levelOf(wallLight(perp, hit.side, world.tile)); cols.side[i] = hit.side; cols.top[i] = horizon - h / 2; cols.high[i] = h; } drawWalls(ctx, scene, step); drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns); } /** The two bands: a dark ceiling above the horizon, a warmer floor below. */ function drawSky(ctx, scene, W, H, horizon) { const key = `${W}x${H}`; if (scene.skyKey !== key || !scene.sky) { const sky = ctx.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, "#0a0c11"); sky.addColorStop(0.49, "#23262f"); sky.addColorStop(0.5, "#3a3026"); sky.addColorStop(1, "#15120e"); scene.sky = sky; scene.skyKey = key; } ctx.fillStyle = scene.sky; ctx.fillRect(0, 0, W, H); ctx.fillStyle = "rgba(255,255,255,0.09)"; ctx.fillRect(0, horizon - 1, W, 2); } /** * Everything that is not a wall, far to near, each one clipped to the screen * columns where it beats the wall behind it. */ function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns) { const W = view.width; const things = scene.order; things.length = 0; for (const zone of snap.zones) { if (zone.k === "smoke") continue; things.push({ kind: "zone", zone, x: zone.x, y: zone.y }); } for (const orb of snap.orbs) things.push({ kind: "orb", x: orb.x, y: orb.y }); if (snap.spike.x !== null && !snap.spike.carried) things.push({ kind: "spike", x: snap.spike.x, y: snap.spike.y }); for (const player of snap.players) { if (player.id === snap.me.id) continue; const at = view.look(player); // How far into sight this body is: 0 the moment it appears, 1 solid. // See presence.js — a body that pops into existence reads as a bug. const shown = view.shown ? view.shown(player) : 1; things.push({ kind: "body", player, shown, x: at.x, y: at.y, angle: at.angle }); } // Where somebody was last seen, dimming away. It stands where they were, // so a wall between you and that spot hides it like anything else. if (view.marks) for (const mark of view.marks) things.push({ kind: "mark", mark, x: mark.x, y: mark.y }); const p = scene.proj; for (const thing of things) { project(eye.x, eye.y, eye.angle, thing.x, thing.y, W, proj, BODY, p); thing.perp = p.perp; thing.column = p.column; thing.size = p.size; thing.behind = p.behind; } things.sort((a, b) => b.perp - a.perp); for (const thing of things) { if (thing.behind) continue; const floorY = horizon + (EYE * proj) / thing.perp; const haze = hazeAt(scene, thing.column, step, columns); const arriving = thing.kind === "body" ? thing.shown : thing.kind === "mark" ? thing.mark.fade : 1; ctx.globalAlpha = Math.max(0, 1 - haze) * arriving; if (ctx.globalAlpha < 0.05) continue; if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns); else if (thing.kind === "mark") drawMark(ctx, scene, thing, floorY, step, columns); else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns); else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns); else drawZone(ctx, scene, thing, floorY, proj, step, columns); ctx.globalAlpha = 1; } ctx.globalAlpha = 1; drawTracers(ctx, scene, eye, snap, W, proj, horizon); } const hazeAt = (scene, column, step, columns) => { const i = Math.max(0, Math.min(columns - 1, Math.round(column / step))); return scene.cols.haze[i] ?? 0; }; /** * The visible runs of a sprite: the stretches of screen column where it is * nearer than the wall. Two rectangles at most in practice, and the caller * clips its drawing to each. */ function withRuns(ctx, scene, left, right, perp, step, columns, draw) { let runStart = -1; const from = Math.max(0, Math.floor(left / step)); const to = Math.min(columns - 1, Math.ceil(right / step)); for (let i = from; i <= to + 1; i++) { const shown = i <= to && spriteColumnVisible(perp, scene.depth[i]); if (shown && runStart < 0) runStart = i; if (!shown && runStart >= 0) { ctx.save(); ctx.beginPath(); ctx.rect(runStart * step - 0.5, 0, (i - runStart) * step + 1, 1e5); ctx.clip(); draw(); ctx.restore(); runStart = -1; } } } function drawBody(ctx, scene, thing, floorY, step, columns) { const p = thing.player; // A body arriving grows from its feet as it fades in, so it reads as // somebody coming round the corner rather than one appearing there. const h = thing.size * swell(thing.shown ?? 1); const w = h * 0.46; const cx = thing.column; const top = floorY - h; const mate = p.mate; // The dead lie down. No tag, no chevron, no agent colour: if something is // upright with a marker over it, it is alive and it can shoot you. if (!p.alive) { const lie = h * 0.16; withRuns(ctx, scene, cx - w * 1.1, cx + w * 1.1, thing.perp, step, columns, () => { ctx.fillStyle = "#4a4e57"; ctx.beginPath(); ctx.ellipse(cx, floorY - lie * 0.4, w * 1.05, lie, 0, 0, 7); ctx.fill(); ctx.strokeStyle = "#23262d"; ctx.lineWidth = Math.max(1, h * 0.02); ctx.stroke(); }); return; } const skin = mate ? "#3f9fd6" : "#d8483f"; const trim = AGENT_COLOUR[p.agent] || "#999"; const edge = Math.max(1.2, h * 0.05); withRuns(ctx, scene, cx - w * 1.2, cx + w * 1.5, thing.perp, step, columns, () => { // Body and head, each with a hard dark edge: the silhouette is what // carries across a dim room, where the colour does not. ctx.fillStyle = skin; ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72); ctx.strokeStyle = "#0b0d11"; ctx.lineWidth = edge; ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72); ctx.fillStyle = trim; ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1); ctx.beginPath(); ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7); ctx.fillStyle = skin; ctx.fill(); ctx.strokeStyle = "#0b0d11"; ctx.stroke(); if (p.spike) { ctx.fillStyle = "#f0d060"; ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3); } if (p.hp < 100) { ctx.fillStyle = "#000a"; ctx.fillRect(cx - w / 2, top - h * 0.1, w, h * 0.06); ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a"; ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06); } if (mate) tag(ctx, p, cx, top, h); else chevron(ctx, cx, top, h); }); } /** * A mark where somebody was last seen: an empty outline and a scuff on the * floor, dimming away over a second. Deliberately not a body — nothing is * filled, there is no head and no chevron — because it is a memory of a * sighting and must never be mistaken for one. */ function drawMark(ctx, scene, thing, floorY, step, columns) { const h = thing.size * 0.9; const w = h * 0.46; const cx = thing.column; const top = floorY - h; withRuns(ctx, scene, cx - w * 1.2, cx + w * 1.2, thing.perp, step, columns, () => { ctx.strokeStyle = thing.mark.mate ? "rgba(120,180,230,0.85)" : "rgba(235,150,120,0.9)"; ctx.lineWidth = Math.max(1, h * 0.035); ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72); ctx.fillStyle = "rgba(10,12,16,0.45)"; ctx.fillRect(cx - w * 0.7, floorY - h * 0.04, w * 1.4, h * 0.05); }); } /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read * red and blue the same way. */ function chevron(ctx, cx, top, h) { const size = Math.max(4, h * 0.16); const y = top - h * 0.16; ctx.beginPath(); ctx.moveTo(cx - size, y - size); ctx.lineTo(cx + size, y - size); ctx.lineTo(cx, y); ctx.closePath(); ctx.fillStyle = "#ff6f5e"; ctx.fill(); ctx.lineWidth = Math.max(1, h * 0.02); ctx.strokeStyle = "#140a08"; ctx.stroke(); } /** A team mate wears their name and a pip of their health — never an enemy. */ function tag(ctx, p, cx, top, h) { const size = Math.max(9, Math.min(18, h * 0.16)); const y = top - h * 0.34; ctx.font = `${size}px system-ui, sans-serif`; ctx.textAlign = "center"; const width = ctx.measureText(p.name).width + size * 1.4; ctx.fillStyle = "rgba(10,12,16,0.65)"; ctx.fillRect(cx - width / 2, y, width, size * 1.4); ctx.fillStyle = p.hp > 60 ? "#6fcf6f" : p.hp > 25 ? "#e0a040" : "#e05a4a"; ctx.fillRect(cx - width / 2 + size * 0.25, y + size * 0.35, size * 0.45, size * 0.7); ctx.fillStyle = "#cfe8ff"; ctx.fillText(p.name, cx + size * 0.4, y + size * 1.05); ctx.textAlign = "left"; } function drawSpike(ctx, scene, thing, floorY, snap, step, columns) { const h = thing.size * 0.42; const cx = thing.column; const planted = snap.spike.planted; const blink = planted && Math.floor(Date.now() / 220) % 2 === 0; withRuns(ctx, scene, cx - h, cx + h, thing.perp, step, columns, () => { ctx.fillStyle = planted ? (blink ? "#ff5b4a" : "#7d2a22") : "#c8b45c"; ctx.fillRect(cx - h * 0.28, floorY - h, h * 0.56, h); ctx.fillStyle = planted && blink ? "#ffd7a0" : "#2a2418"; ctx.fillRect(cx - h * 0.14, floorY - h * 0.85, h * 0.28, h * 0.22); }); } function drawBlob(ctx, scene, thing, floorY, colour, scale, step, columns) { const r = thing.size * scale; const cx = thing.column; withRuns(ctx, scene, cx - r, cx + r, thing.perp, step, columns, () => { ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(cx, floorY - r, r, 0, 7); ctx.fill(); }); } function drawZone(ctx, scene, thing, floorY, proj, step, columns) { const z = thing.zone; const wide = (z.r * proj) / thing.perp; const cx = thing.column; if (z.k === "wall") { const h = (WALL * proj) / thing.perp; withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => { ctx.fillStyle = "rgba(120,200,255,0.45)"; ctx.fillRect(cx - wide, floorY - h, wide * 2, h); ctx.strokeStyle = "rgba(180,225,255,0.8)"; ctx.strokeRect(cx - wide, floorY - h, wide * 2, h); }); return; } const colour = z.k === "molly" ? "rgba(235,120,50,0.6)" : z.k === "flash" ? "rgba(255,235,170,0.9)" : z.k === "heal" ? "rgba(120,230,140,0.55)" : z.k === "trip" ? "rgba(230,200,90,0.75)" : "rgba(90,220,170,0.5)"; withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => { ctx.fillStyle = colour; ctx.beginPath(); ctx.ellipse(cx, floorY, wide, Math.max(2, wide * 0.3), 0, 0, 7); ctx.fill(); }); } /** Shots the server let us see, as a line through the world. */ function drawTracers(ctx, scene, eye, snap, W, proj, horizon) { if (!snap.shots.length) return; const p = scene.proj; ctx.lineWidth = 2; for (const shot of snap.shots) { project(eye.x, eye.y, eye.angle, shot.x1, shot.y1, W, proj, BODY, p); if (p.behind) continue; const x1 = p.column; const y1 = horizon + (EYE * proj) / p.perp - p.size * 0.6; project(eye.x, eye.y, eye.angle, shot.x2, shot.y2, W, proj, BODY, p); if (p.behind) continue; ctx.strokeStyle = shot.hit ? "rgba(255,140,120,0.5)" : "rgba(255,240,190,0.35)"; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(p.column, horizon + (EYE * proj) / p.perp - p.size * 0.6); ctx.stroke(); } }