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

forked from benjivers/val

workbenchthis machine
Files 00e29188271b60cb71e377df120e603f5649cfdb
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. Colours come out of a table built once,
11
 * the depth and fog buffers are kept between frames, and the sprite list is
12
 * sorted in a buffer the scene owns.
13
 */
14
15
import {
16
  castRay,
17
  columnOffset,
18
  hitResult,
19
  project,
20
  projection,
21
  projectResult,
22
  sliceHeight,
23
  smokeAlong,
24
  spriteColumnVisible,
25
  wallLight,
26
} from "./raycast.js";
27
28
const EYE = 40; // half a tile up: the horizon sits on the middle of the wall
29
const WALL = 80; // walls are one tile tall
30
const BODY = 58; // a person, in world units
31
const MAX_SIGHT = 1800;
32
const LEVELS = 48;
33
34
export const AGENT_COLOUR = {
35
  ember: "#f08a4b",
36
  veil: "#8f7fe8",
37
  sable: "#4fd6a0",
38
  mender: "#e0c060",
39
  rook: "#d96a8f",
40
};
41
42
// Wall colours, quantised into levels and built once, so the ray loop never
43
// builds a string. How dark a slice is comes from `wallLight`, which already
44
// knows about distance and which face was struck.
45
const SHADES = (() => {
46
  const out = [];
47
  for (let i = 0; i < LEVELS; i++) {
48
    const k = (i + 0.5) / LEVELS;
49
    out.push(`rgb(${Math.round(150 * k)},${Math.round(158 * k)},${Math.round(178 * k)})`);
50
  }
51
  return out;
52
})();
53
const FOG = "rgb(198,203,212)";
54
55
export function makeScene() {
56
  return {
57
    depth: new Float32Array(0),
58
    fog: new Float32Array(0),
59
    hit: hitResult(),
60
    proj: projectResult(),
61
    order: [],
62
    smokes: [],
63
    sky: null,
64
    skyKey: "",
65
  };
66
}
67
68
const shadeOf = (light) => SHADES[Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0))];
69
70
/**
71
 * Draw one frame.
72
 *
73
 * `world` carries the map (`solid`, `tile`), `eye` is where the camera
74
 * stands and looks, `snap` is the last snapshot the server sent, and `view`
75
 * is the frame: its pixel size, and `look(player)` for the smoothed position
76
 * to draw somebody at.
77
 */
78
export function drawScene(ctx, scene, world, eye, snap, view) {
79
  const W = view.width;
80
  const H = view.height;
81
  const step = W > 1100 ? 2 : 1;
82
  const columns = Math.ceil(W / step);
83
  if (scene.depth.length !== columns) {
84
    scene.depth = new Float32Array(columns);
85
    scene.fog = new Float32Array(columns);
86
  }
87
  const proj = projection(W);
88
  const horizon = H / 2;
89
90
  drawSky(ctx, scene, W, H, horizon);
91
92
  const smokes = scene.smokes;
93
  smokes.length = 0;
94
  for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone);
95
96
  const depth = scene.depth;
97
  const fog = scene.fog;
98
  const hit = scene.hit;
99
  for (let i = 0; i < columns; i++) {
100
    const offset = columnOffset(i * step + step / 2, W);
101
    const angle = eye.angle + offset;
102
    castRay(world.solid, eye.x, eye.y, angle, world.tile, MAX_SIGHT, hit);
103
    const perp = Math.max(1, hit.dist * Math.cos(offset));
104
    depth[i] = perp;
105
    let haze = 0;
106
    if (smokes.length) {
107
      const dx = Math.cos(angle);
108
      const dy = Math.sin(angle);
109
      for (let s = 0; s < smokes.length; s++) {
110
        const z = smokes[s];
111
        haze += smokeAlong(eye.x, eye.y, dx, dy, z.x, z.y, z.r, hit.dist);
112
        if (haze >= 1) break;
113
      }
114
      haze = Math.min(1, haze);
115
    }
116
    fog[i] = haze;
117
    if (!hit.wall) continue;
118
    const h = Math.min(H * 6, sliceHeight(perp, proj, WALL));
119
    const top = horizon - h / 2;
120
    ctx.fillStyle = shadeOf(wallLight(perp, hit.side, world.tile));
121
    ctx.fillRect(i * step, top, step + 1, h);
122
    if (haze > 0.01) {
123
      ctx.globalAlpha = haze;
124
      ctx.fillStyle = FOG;
125
      ctx.fillRect(i * step, top, step + 1, h);
126
      ctx.globalAlpha = 1;
127
    }
128
  }
129
130
  drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns);
131
}
132
133
/** The two bands: a dark ceiling above the horizon, a warmer floor below. */
134
function drawSky(ctx, scene, W, H, horizon) {
135
  const key = `${W}x${H}`;
136
  if (scene.skyKey !== key || !scene.sky) {
137
    const sky = ctx.createLinearGradient(0, 0, 0, H);
138
    sky.addColorStop(0, "#0a0c11");
139
    sky.addColorStop(0.49, "#23262f");
140
    sky.addColorStop(0.5, "#3a3026");
141
    sky.addColorStop(1, "#15120e");
142
    scene.sky = sky;
143
    scene.skyKey = key;
144
  }
145
  ctx.fillStyle = scene.sky;
146
  ctx.fillRect(0, 0, W, H);
147
  ctx.fillStyle = "rgba(255,255,255,0.09)";
148
  ctx.fillRect(0, horizon - 1, W, 2);
149
}
150
151
/**
152
 * Everything that is not a wall, far to near, each one clipped to the screen
153
 * columns where it beats the wall behind it.
154
 */
155
function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns) {
156
  const W = view.width;
157
  const things = scene.order;
158
  things.length = 0;
159
160
  for (const zone of snap.zones) {
161
    if (zone.k === "smoke") continue;
162
    things.push({ kind: "zone", zone, x: zone.x, y: zone.y });
163
  }
164
  for (const orb of snap.orbs) things.push({ kind: "orb", x: orb.x, y: orb.y });
165
  if (snap.spike.x !== null && !snap.spike.carried)
166
    things.push({ kind: "spike", x: snap.spike.x, y: snap.spike.y });
167
  for (const player of snap.players) {
168
    if (player.id === snap.me.id) continue;
169
    const at = view.look(player);
170
    things.push({ kind: "body", player, x: at.x, y: at.y, angle: at.angle });
171
  }
172
173
  const p = scene.proj;
174
  for (const thing of things) {
175
    project(eye.x, eye.y, eye.angle, thing.x, thing.y, W, proj, BODY, p);
176
    thing.perp = p.perp;
177
    thing.column = p.column;
178
    thing.size = p.size;
179
    thing.behind = p.behind;
180
  }
181
  things.sort((a, b) => b.perp - a.perp);
182
183
  for (const thing of things) {
184
    if (thing.behind) continue;
185
    const floorY = horizon + (EYE * proj) / thing.perp;
186
    const haze = hazeAt(scene, thing.column, step, columns);
187
    ctx.globalAlpha = Math.max(0, 1 - haze);
188
    if (ctx.globalAlpha < 0.05) continue;
189
    if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns);
190
    else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns);
191
    else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns);
192
    else drawZone(ctx, scene, thing, floorY, proj, step, columns);
193
    ctx.globalAlpha = 1;
194
  }
195
  ctx.globalAlpha = 1;
196
197
  drawTracers(ctx, scene, eye, snap, W, proj, horizon);
198
}
199
200
const hazeAt = (scene, column, step, columns) => {
201
  const i = Math.max(0, Math.min(columns - 1, Math.round(column / step)));
202
  return scene.fog[i] ?? 0;
203
};
204
205
/**
206
 * The visible runs of a sprite: the stretches of screen column where it is
207
 * nearer than the wall. Two rectangles at most in practice, and the caller
208
 * clips its drawing to each.
209
 */
210
function withRuns(ctx, scene, left, right, perp, step, columns, draw) {
211
  let runStart = -1;
212
  const from = Math.max(0, Math.floor(left / step));
213
  const to = Math.min(columns - 1, Math.ceil(right / step));
214
  for (let i = from; i <= to + 1; i++) {
215
    const shown = i <= to && spriteColumnVisible(perp, scene.depth[i]);
216
    if (shown && runStart < 0) runStart = i;
217
    if (!shown && runStart >= 0) {
218
      ctx.save();
219
      ctx.beginPath();
220
      ctx.rect(runStart * step - 0.5, 0, (i - runStart) * step + 1, 1e5);
221
      ctx.clip();
222
      draw();
223
      ctx.restore();
224
      runStart = -1;
225
    }
226
  }
227
}
228
229
function drawBody(ctx, scene, thing, floorY, step, columns) {
230
  const p = thing.player;
231
  const h = thing.size;
232
  const w = h * 0.46;
233
  const cx = thing.column;
234
  const top = floorY - h;
235
  const mate = p.mate;
236
  const skin = !p.alive ? "#555a63" : mate ? "#3f9fd6" : "#d8483f";
237
  const trim = AGENT_COLOUR[p.agent] || "#999";
238
239
  withRuns(ctx, scene, cx - w, cx + w * 1.4, thing.perp, step, columns, () => {
240
    // Body, head, and a shoulder band in the agent's colour.
241
    ctx.fillStyle = skin;
242
    ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72);
243
    ctx.fillStyle = trim;
244
    ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1);
245
    ctx.beginPath();
246
    ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7);
247
    ctx.fillStyle = skin;
248
    ctx.fill();
249
    ctx.lineWidth = Math.max(1, h * 0.03);
250
    ctx.strokeStyle = trim;
251
    ctx.stroke();
252
    if (!p.alive) return;
253
    if (p.spike) {
254
      ctx.fillStyle = "#f0d060";
255
      ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3);
256
    }
257
    if (p.hp < 100) {
258
      ctx.fillStyle = "#000a";
259
      ctx.fillRect(cx - w / 2, top - h * 0.1, w, h * 0.06);
260
      ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a";
261
      ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06);
262
    }
263
    // A name tag over team mates only: an enemy is a shape, never a label.
264
    if (mate) {
265
      const size = Math.max(9, Math.min(18, h * 0.16));
266
      ctx.font = `${size}px system-ui, sans-serif`;
267
      ctx.textAlign = "center";
268
      ctx.fillStyle = "rgba(10,12,16,0.65)";
269
      const tw = ctx.measureText(p.name).width + 8;
270
      ctx.fillRect(cx - tw / 2, top - h * 0.34, tw, size * 1.4);
271
      ctx.fillStyle = "#cfe8ff";
272
      ctx.fillText(p.name, cx, top - h * 0.34 + size * 1.05);
273
      ctx.textAlign = "left";
274
    }
275
  });
276
}
277
278
function drawSpike(ctx, scene, thing, floorY, snap, step, columns) {
279
  const h = thing.size * 0.42;
280
  const cx = thing.column;
281
  const planted = snap.spike.planted;
282
  const blink = planted && Math.floor(Date.now() / 220) % 2 === 0;
283
  withRuns(ctx, scene, cx - h, cx + h, thing.perp, step, columns, () => {
284
    ctx.fillStyle = planted ? (blink ? "#ff5b4a" : "#7d2a22") : "#c8b45c";
285
    ctx.fillRect(cx - h * 0.28, floorY - h, h * 0.56, h);
286
    ctx.fillStyle = planted && blink ? "#ffd7a0" : "#2a2418";
287
    ctx.fillRect(cx - h * 0.14, floorY - h * 0.85, h * 0.28, h * 0.22);
288
  });
289
}
290
291
function drawBlob(ctx, scene, thing, floorY, colour, scale, step, columns) {
292
  const r = thing.size * scale;
293
  const cx = thing.column;
294
  withRuns(ctx, scene, cx - r, cx + r, thing.perp, step, columns, () => {
295
    ctx.fillStyle = colour;
296
    ctx.beginPath();
297
    ctx.arc(cx, floorY - r, r, 0, 7);
298
    ctx.fill();
299
  });
300
}
301
302
function drawZone(ctx, scene, thing, floorY, proj, step, columns) {
303
  const z = thing.zone;
304
  const wide = (z.r * proj) / thing.perp;
305
  const cx = thing.column;
306
  if (z.k === "wall") {
307
    const h = (WALL * proj) / thing.perp;
308
    withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => {
309
      ctx.fillStyle = "rgba(120,200,255,0.45)";
310
      ctx.fillRect(cx - wide, floorY - h, wide * 2, h);
311
      ctx.strokeStyle = "rgba(180,225,255,0.8)";
312
      ctx.strokeRect(cx - wide, floorY - h, wide * 2, h);
313
    });
314
    return;
315
  }
316
  const colour =
317
    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)";
318
  withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => {
319
    ctx.fillStyle = colour;
320
    ctx.beginPath();
321
    ctx.ellipse(cx, floorY, wide, Math.max(2, wide * 0.3), 0, 0, 7);
322
    ctx.fill();
323
  });
324
}
325
326
/** Shots the server let us see, as a line through the world. */
327
function drawTracers(ctx, scene, eye, snap, W, proj, horizon) {
328
  if (!snap.shots.length) return;
329
  const p = scene.proj;
330
  ctx.lineWidth = 2;
331
  for (const shot of snap.shots) {
332
    project(eye.x, eye.y, eye.angle, shot.x1, shot.y1, W, proj, BODY, p);
333
    if (p.behind) continue;
334
    const x1 = p.column;
335
    const y1 = horizon + (EYE * proj) / p.perp - p.size * 0.6;
336
    project(eye.x, eye.y, eye.angle, shot.x2, shot.y2, W, proj, BODY, p);
337
    if (p.behind) continue;
338
    ctx.strokeStyle = shot.hit ? "rgba(255,140,120,0.5)" : "rgba(255,240,190,0.35)";
339
    ctx.beginPath();
340
    ctx.moveTo(x1, y1);
341
    ctx.lineTo(p.column, horizon + (EYE * proj) / p.perp - p.size * 0.6);
342
    ctx.stroke();
343
  }
344
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts