rook/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files d7211248ad6d576d3c93536129a0d746e3c90526
424 lines · 14.6 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 { 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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts