benjivers/val Public Public: anyone can read this; the crew can change it.

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

Keyboard shortcuts