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

forked from benjivers/val

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

Keyboard shortcuts