Back to Rail Game
SOURCE / PINNED RELEASE

Made of little things.

Rail Game

Release
ccdd5b913413…
Author-recorded commit
ddf0122b6acc…
License
LICENSE
Author’s source reference
nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-91ec90c6fdf

Archive hash verified: dad7b678e25f96a3…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/renderer.ts
import { RailGame, type Entity } from './game.js';
type Point = { x: number; y: number; s: number };
type RGB = [number, number, number];
const mix = (a: RGB, b: RGB, t: number): string => `rgb(${a.map((v, i) => Math.round(v + (b[i] - v) * t)).join(',')})`;

export class Renderer {
  private ctx: CanvasRenderingContext2D;
  width = 1; height = 1;
  private scale = 1; private horizon = 1; private depth = 1;
  // Camera sits left of the rail. Recenter the nearby squad, letting the straight
  // track converge to a vanishing point left of the foreground (no screen roll).
  private readonly cameraLeft = 1.5;
  private light = false;
  private palette = { bg: '#101d25', text: '#e9f2f2', track: '#21333d', edge: '#344b56', grid: '#33434c', scenery: '#1c2b33', top: '#30434b', accent: '#78edbb', fog: '#172831' };
  constructor(private canvas: HTMLCanvasElement) {
    const ctx = canvas.getContext('2d', { alpha: false });
    if (!ctx) throw new Error('Canvas rendering is unavailable.');
    this.ctx = ctx;
  }
  setTheme(background: string, foreground: string, primary: string): void {
    const sample = document.createElement('canvas'); sample.width = sample.height = 1;
    const c = sample.getContext('2d')!;
    const rgb = (color: string): RGB => {
      c.clearRect(0, 0, 1, 1); c.fillStyle = color; c.fillRect(0, 0, 1, 1);
      const d = c.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2]];
    };
    const bg = rgb(background), fg = rgb(foreground);
    this.light = (bg[0] * .2126 + bg[1] * .7152 + bg[2] * .0722) > 150;
    this.palette = { bg: background, text: foreground, accent: primary,
      track: mix(bg, fg, .09), edge: mix(bg, fg, .22), grid: mix(bg, fg, .14),
      scenery: mix(bg, fg, .055), top: mix(bg, fg, .13), fog: mix(bg, fg, .025) };
  }
  resize(): void {
    const bounds = this.canvas.getBoundingClientRect(); this.width = bounds.width; this.height = bounds.height;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    this.canvas.width = Math.round(this.width * dpr); this.canvas.height = Math.round(this.height * dpr);
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.scale = Math.min(this.width * .125, this.height * .23);
    this.horizon = this.height * .14; this.depth = this.height * .59;
  }
  worldX(clientX: number): number { return (clientX - this.canvas.getBoundingClientRect().left - this.width / 2) / this.scale; }
  private project(x: number, z: number, y = 0): Point {
    const perspective = 28 / (28 + Math.max(-10, z));
    return { x: this.width / 2 + (x + this.cameraLeft) * this.scale * perspective - this.cameraLeft * this.scale,
      y: this.horizon + this.depth * perspective - y * this.scale * perspective, s: this.scale * perspective };
  }
  private poly(points: Point[], fill: string): void {
    const c = this.ctx; c.beginPath(); points.forEach((p, i) => i ? c.lineTo(p.x, p.y) : c.moveTo(p.x, p.y)); c.closePath();
    c.fillStyle = fill; c.fill();
  }
  private ground(x1: number, x2: number, z1: number, z2: number, fill: string): void {
    this.poly([this.project(x1, z1), this.project(x2, z1), this.project(x2, z2), this.project(x1, z2)], fill);
  }
  private box(x: number, z: number, width: number, depth: number, height: number, colors: [string, string, string], base = 0): void {
    const a = this.project(x - width / 2, z - depth / 2, base), b = this.project(x + width / 2, z - depth / 2, base);
    const at = this.project(x - width / 2, z - depth / 2, base + height), bt = this.project(x + width / 2, z - depth / 2, base + height);
    const ct = this.project(x + width / 2, z + depth / 2, base + height), dt = this.project(x - width / 2, z + depth / 2, base + height);
    if (x > -this.cameraLeft) this.poly([a, this.project(x - width / 2, z + depth / 2, base), dt, at], colors[1]);
    else this.poly([b, this.project(x + width / 2, z + depth / 2, base), ct, bt], colors[1]);
    this.poly([a, b, bt, at], colors[0]); this.poly([at, bt, ct, dt], colors[2]);
  }
  private label(text: string, x: number, y: number, size: number, color: string): void {
    this.ctx.fillStyle = color; this.ctx.font = `700 ${size}px ui-monospace, "SFMono-Regular", Consolas, monospace`;
    this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'middle'; this.ctx.fillText(text, x, y);
  }
  private shadow(x: number, z: number, width: number): void {
    const p = this.project(x, z); this.ctx.fillStyle = this.light ? '#152f3920' : '#00000038';
    this.ctx.beginPath(); this.ctx.ellipse(p.x + p.s * .1, p.y + 2, width * p.s, .14 * p.s, -.1, 0, Math.PI * 2); this.ctx.fill();
  }
  private soldier(x: number, z: number, time: number, index: number, quiet: boolean): void {
    const size = .44;
    const bob = quiet ? 0 : Math.sin(time * 17 + index * 1.8) * .02;
    const stride = quiet ? 0 : Math.sin(time * 17 + index * 1.8) * .05;
    const part = (dx: number, dz: number, width: number, depth: number, height: number,
      colors: [string, string, string], base = 0): void =>
      this.box(x + dx * size, z + dz * size, width * size, depth * size, height * size, colors, base * size + bob);
    this.shadow(x, z, .12);
    const dark: [string, string, string] = ['#2589a2', '#17647b', '#55cbd4'];
    part(-.07, stride, .075, .12, .2, dark); part(.07, -stride, .075, .12, .2, dark);
    part(0, 0, .24, .17, .29, ['#52d7cf', '#288b9d', '#a1f5df'], .18);
    part(0, 0, .21, .2, .19, index === 0 ? ['#f6cf7b', '#bf9556', '#fff0b3'] : ['#8cf1de', '#48bfc9', '#ccffed'], .49);
    part(0, -.12, .14, .1, .18, dark, .22);
    part(.14, .18, .075, .44, .08, ['#b5d7d5', '#4c717b', '#e5fff4'], .35);
  }
  private entity(e: Entity, time: number, reduced: boolean): void {
    const c = this.ctx, p = this.project(e.x, e.z); this.shadow(e.x, e.z, e.width * .54);
    if (e.kind === 'enemy' || e.kind === 'boss') {
      const boss = e.kind === 'boss', size = boss ? 2 : .3, height = boss ? 1 : .7;
      const bob = reduced ? 0 : Math.sin(time * 5 + e.id) * .025;
      const colors: [string, string, string] = e.hit > 0 ? ['#fff5e4', '#ffe0c6', '#ffffff'] : ['#df6f69', '#aa444d', '#ffaaa0'];
      this.box(e.x - size * .26, e.z, size * .22, .4, .28, ['#743c49', '#492d3d', '#ba6669']);
      this.box(e.x + size * .26, e.z, size * .22, .4, .28, ['#743c49', '#492d3d', '#ba6669']);
      this.box(e.x, e.z, size, boss ? .85 : .4, (boss ? 1.15 : .62) * height, colors, .22 + bob);
      this.box(e.x, e.z, size * .65, .32, (boss ? .48 : .3) * height, colors, (boss ? 1.42 : .66) + bob);
      const face = this.project(e.x, e.z - .21, (boss ? 1.65 : .78) + bob);
      c.fillStyle = '#522e39'; c.fillRect(face.x - size * face.s * .24, face.y, size * face.s * .48, face.s * .09);
      c.fillStyle = '#fff3c1'; c.fillRect(face.x - size * face.s * .19, face.y + face.s * .02, size * face.s * .1, face.s * .035);
      c.fillRect(face.x + size * face.s * .09, face.y + face.s * .02, size * face.s * .1, face.s * .035);
      // Full-health crowds keep clean silhouettes; wounded units reveal a bar.
      if (boss || e.hp < e.maxHp) {
        const bar = this.project(e.x, e.z, boss ? 2.24 : 1.1);
        c.fillStyle = this.palette.bg; c.fillRect(bar.x - p.s * size * .6, bar.y, p.s * size * 1.2, boss ? 5 : 3);
        c.fillStyle = '#fb8e7f'; c.fillRect(bar.x - p.s * size * .6, bar.y, p.s * size * 1.2 * Math.max(0, e.hp / e.maxHp), boss ? 5 : 3);
        if (boss && e.z < 65) this.label('WARDEN', bar.x, bar.y - 10, 12, this.palette.text);
      }
      return;
    }
    if (e.kind === 'barrier') {
      this.box(e.x, e.z, 1.65, .65, .73, ['#5f6570', '#343e49', '#8b8c91']);
      for (let i = 0; i < 5; i++) {
        const x = e.x - .77 + i * .32;
        this.poly([this.project(x, e.z - .333, .08), this.project(x + .18, e.z - .333, .08), this.project(x + .44, e.z - .333, .65), this.project(x + .26, e.z - .333, .65)], '#ffcf78');
      }
      return;
    }
    const gate = e.kind === 'recruit' || e.kind === 'multiply';
    const color = e.kind === 'recruit' ? '#77dadd' : e.kind === 'multiply' ? '#a0edac' : e.kind === 'damage' ? '#ffd179' : e.kind === 'rapid' ? '#c3a1ef' : '#88bdff';
    const dark = e.kind === 'recruit' ? '#244e5a' : e.kind === 'multiply' ? '#335347' : e.kind === 'damage' ? '#65523d' : e.kind === 'rapid' ? '#4a3d65' : '#304c6c';
    if (gate) {
      this.ground(e.x - .84, e.x + .84, e.z - .3, e.z + .3, color + '40');
      this.poly([this.project(e.x - .78, e.z, .12), this.project(e.x + .78, e.z, .12), this.project(e.x + .78, e.z, 1.7), this.project(e.x - .78, e.z, 1.7)], color + (this.light ? '36' : '18'));
      for (const side of [-1, 1]) this.box(e.x + side * .81, e.z, .07, .15, 1.75, [color, dark, color]);
      this.box(e.x, e.z, 1.7, .17, .63, [dark, dark, color], 1.35);
      const sign = this.project(e.x, e.z - .1, 1.68);
      this.label(e.kind === 'multiply' ? `×${e.value}` : `+${e.value}`, sign.x, sign.y, Math.min(38, p.s * .62), color);
      if (e.z < 55) {
        const sub = this.project(e.x, e.z, 1.02);
        this.label(e.kind === 'multiply' ? 'MULTIPLY' : 'RECRUIT', sub.x, sub.y, Math.max(8, Math.min(10, p.s * .17)), color);
      }
    } else {
      const float = reduced ? .25 : .25 + Math.sin(time * 3 + e.id) * .1;
      this.box(e.x, e.z, .72, .6, .68, [dark, dark, color], float);
      const center = this.project(e.x, e.z - .31, float + .36);
      this.label(e.kind === 'damage' ? '↑' : e.kind === 'rapid' ? '»' : '◇', center.x, center.y, p.s * .48, color);
      if (e.z < 55) {
        const name = this.project(e.x, e.z, 1.24);
        this.label(e.kind === 'damage' ? 'DAMAGE' : e.kind === 'rapid' ? 'RAPID' : 'SHIELD', name.x, name.y, Math.max(9, Math.min(12, p.s * .23)), color);
      }
    }
  }
  draw(game: RailGame, time: number): void {
    const c = this.ctx, w = this.width, h = this.height, pal = this.palette;
    c.fillStyle = pal.bg; c.fillRect(0, 0, w, h);
    const wash = c.createLinearGradient(0, 0, 0, h);
    wash.addColorStop(0, pal.bg); wash.addColorStop(.45, pal.fog); wash.addColorStop(1, pal.bg); c.fillStyle = wash; c.fillRect(0, 0, w, h);
    for (let i = 15; i >= 0; i--) {
      const z = ((i * 13 + 200 - game.distance * .65) % 208 + 208) % 208 - 12;
      for (const side of [-1, 1]) this.box(side * (4.2 + (i % 4) * 1.7), z, .8 + (i % 3) * .8, 2.5 + i % 3, .15 + (i % 4) * .34, [pal.scenery, pal.scenery, pal.top]);
    }
    this.ground(-3.13, 3.13, -10, 180, pal.edge); this.ground(-3, 3, -10, 180, pal.track);
    for (let z = 160 - game.distance % 8; z > -10; z -= 8) this.ground(-3, 3, z, z + .07, pal.grid);
    for (let z = 160 - game.distance % 10; z > -10; z -= 10) {
      for (const x of [-1, 1]) this.ground(x - .014, x + .014, z, z + 3, pal.edge);
      for (const x of [-3.04, 3.04]) this.ground(x - .025, x + .025, z, z + 2, pal.accent);
    }
    for (const x of [-2.96, 2.96]) this.ground(x - .017, x + .017, -10, 180, pal.accent);
    if (game.definition.length - game.distance < 100) {
      const z = game.definition.length - game.distance + 28;
      for (const x of [-2.9, 2.9]) this.box(x, z, .15, .4, 3, [pal.edge, pal.scenery, pal.top]);
      this.box(0, z, 5.95, .4, .3, [pal.edge, pal.scenery, pal.top], 2.9);
      const p = this.project(0, z, 3.05); this.label('END OF LINE', p.x, p.y, Math.max(9, p.s * .22), pal.text);
    }
    for (const e of game.entities.filter(e => e.alive && e.z < 130 && e.z > -2).sort((a, b) => b.z - a.z)) this.entity(e, time, game.reducedMotion);
    for (const b of game.bullets) {
      const p = this.project(b.x, b.z, b.hostile ? .46 : .25), tail = this.project(b.x, b.z - (b.hostile ? -1.1 : .9), b.hostile ? .46 : .25);
      c.strokeStyle = b.hostile ? '#ff786b' : b.damage > 1 ? '#ffd67c' : '#bcffe1'; c.lineWidth = Math.max(b.hostile ? 3 : 1, p.s * (b.hostile ? .11 : .02)); c.lineCap = 'round';
      c.beginPath(); c.moveTo(tail.x, tail.y); c.lineTo(p.x, p.y); c.stroke();
    }
    if (game.shield) {
      const p = this.project(game.x, -1, .3); c.fillStyle = '#88bdff12'; c.strokeStyle = '#88bdff'; c.lineWidth = 1.5;
      c.beginPath(); c.ellipse(p.x, p.y, p.s * .78, p.s * .62, 0, 0, Math.PI * 2); c.fill(); c.stroke();
    }
    c.globalAlpha = game.invincible > 0 && Math.floor(game.elapsed * 18) % 2 === 0 ? .45 : 1;
    for (let i = 0; i < game.squad; i++) { const o = game.formation(i); this.soldier(game.x + o.x, o.z, time, i, game.reducedMotion || game.phase !== 'playing'); }
    c.globalAlpha = 1;
    if (game.squad > 0 && w >= 480 && h >= 500) {
      const p = this.project(game.x, -5.1); c.fillStyle = this.light ? '#ffffffd0' : '#101d25d0';
      c.beginPath(); c.roundRect(p.x - 22, p.y - 11, 44, 22, 4); c.fill(); this.label(String(game.squad).padStart(2, '0'), p.x, p.y, 13, pal.text);
    }
    for (const p of game.particles) {
      const at = this.project(p.x, p.z, Math.max(0, p.y)); c.globalAlpha = Math.min(1, p.life * 2); c.fillStyle = p.color;
      c.fillRect(at.x, at.y, Math.max(2, at.s * .06), Math.max(2, at.s * .06));
    }
    c.globalAlpha = 1;
    const fog = c.createLinearGradient(0, this.horizon, 0, this.horizon + h * .14);
    fog.addColorStop(0, pal.bg); fog.addColorStop(1, this.light ? '#ffffff00' : '#101d2500'); c.fillStyle = fog; c.fillRect(0, 0, w, this.horizon + h * .14);
  }
}