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

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

Keyboard shortcuts