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

forked from benjivers/val

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

Keyboard shortcuts