Back to Napcraft
SOURCE / PINNED RELEASE

Made of little things.

Napcraft

Release
ee47ac6a25df…
Author-recorded commit
410dea87e109…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-52f9e22f5ce

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

src/scene.ts
import { cell, top, MATERIALS, PLAYER_COLORS } from './world';
import type { World, Gardener, Peer } from './world';
import { pickColumn } from './picking';
type Point = { x: number; y: number };
export class Scene {
  canvas: HTMLCanvasElement;
  ctx: CanvasRenderingContext2D;
  world: World;
  player: Gardener = { x: 8, z: 11, color: 0, name: 'You' };
  peers = new Map<string, Peer>();
  selected = { x: 9, z: 10 };
  active = false;
  pendingCells = new Set<number>();
  confirmedEdits = 0;
  keys = new Set<string>();
  zoom = 1;
  onSelect = () => {};
  w = 900;
  h = 600;
  unit = 22;
  clock = 0;
  motion = matchMedia('(prefers-reduced-motion: reduce)');
  last = 0;
  raf = 0;
  pan = { x: 0, y: 0 };
  particles: { x: number; z: number; y: number; t: number; color: string }[] =
    [];
  constructor(canvas: HTMLCanvasElement, world: World) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d')!;
    this.world = world;
    this.raf = requestAnimationFrame((t) => this.frame(t));
  }
  project(x: number, y: number, z: number): Point {
    return {
      x: this.w / 2 + (x - z) * this.unit + this.pan.x,
      y:
        this.h * 0.48 +
        (x + z - 17) * this.unit * 0.51 -
        y * this.unit * 0.94 +
        this.pan.y,
    };
  }
  polygon(points: Point[], fill: string, stroke?: string) {
    const c = this.ctx;
    c.beginPath();
    c.moveTo(points[0].x, points[0].y);
    for (const p of points.slice(1)) c.lineTo(p.x, p.y);
    c.closePath();
    c.fillStyle = fill;
    c.fill();
    if (stroke) {
      c.strokeStyle = stroke;
      c.lineWidth = 0.6;
      c.stroke();
    }
  }
  shade(color: string, f: number) {
    const n = parseInt(color.slice(1), 16);
    return `rgb(${((n >> 16) * f) | 0},${(((n >> 8) & 255) * f) | 0},${((n & 255) * f) | 0})`;
  }
  cube(x: number, y: number, z: number, color: string, size = 1) {
    const p = (a: number, b: number, c: number) => this.project(a, b, c);
    const a = p(x, y + size, z),
      b = p(x + size, y + size, z),
      d = p(x, y + size, z + size),
      e = p(x + size, y + size, z + size);
    this.polygon(
      [d, e, p(x + size, y, z + size), p(x, y, z + size)],
      this.shade(color, 0.72),
    );
    this.polygon(
      [b, e, p(x + size, y, z + size), p(x + size, y, z)],
      this.shade(color, 0.86),
    );
    this.polygon([a, b, e, d], color, '#44325c16');
  }
  ground(x: number, z: number) {
    return (
      top(
        this.world,
        Math.max(0, Math.min(17, Math.floor(x))),
        Math.max(0, Math.min(17, Math.floor(z))),
      ) + 1
    );
  }
  pick(sx: number, sy: number) {
    const found = pickColumn(this.world, this.project.bind(this), sx, sy);
    if (found) {
      this.selected = found;
      this.onSelect();
    }
    return !!found;
  }
  burst(x: number, y: number, z: number, material: number) {
    if (this.motion.matches) return;
    for (let i = 0; i < 8; i++)
      this.particles.push({
        x: x + 0.5 + (Math.random() - 0.5),
        z: z + 0.5 + (Math.random() - 0.5),
        y: y + 1 + Math.random(),
        t: 1,
        color: MATERIALS[material || 2].color,
      });
  }
  avatar(player: Gardener) {
    const y = this.ground(player.x, player.z),
      x = player.x - 0.24,
      z = player.z - 0.24,
      c = this.ctx;
    const shadow = this.project(player.x, y + 0.01, player.z);
    c.fillStyle = '#34254a35';
    c.beginPath();
    c.ellipse(
      shadow.x,
      shadow.y,
      this.unit * 0.52,
      this.unit * 0.22,
      0,
      0,
      Math.PI * 2,
    );
    c.fill();
    c.strokeStyle = '#fffaf7';
    c.lineWidth = 2;
    c.stroke();
    this.cube(x, y, z, PLAYER_COLORS[player.color % 6], 0.48);
    this.cube(x - 0.045, y + 0.49, z - 0.045, '#ffe4c9', 0.57);
    this.cube(
      x - 0.065,
      y + 1.04,
      z - 0.065,
      PLAYER_COLORS[player.color % 6],
      0.61,
    );
    const face = this.project(x + 0.59, y + 0.86, z + 0.34);
    c.fillStyle = '#443253';
    c.fillRect(face.x - 4, face.y, 2.5, 3.5);
    c.fillRect(face.x + 2, face.y - 3, 2.5, 3.5);
  }
  avatarLabel(player: Gardener, placed: {x:number;y:number;width:number}[]) {
    const label = player === this.player ? 'YOU' : player.name;
    const anchor = this.project(player.x, this.ground(player.x,player.z) + 1.8, player.z);
    const c = this.ctx;
    c.font = '700 11px ui-rounded, system-ui, sans-serif';
    const width = c.measureText(label).width + 16;
    const p = {x:Math.max(width/2+8,Math.min(this.w-width/2-8,anchor.x)),y:anchor.y};
    while (placed.some(r => Math.abs(r.x-p.x)<(r.width+width)/2+4 && Math.abs(r.y-p.y)<24)) p.y-=25;
    placed.push({...p,width});
    c.strokeStyle = PLAYER_COLORS[player.color];
    c.lineWidth = 2;
    c.beginPath(); c.moveTo(anchor.x,anchor.y+9); c.lineTo(p.x,p.y+7); c.stroke();
    c.fillStyle = label === 'YOU' ? '#584377' : '#ffffff';
    c.beginPath();
    c.roundRect(p.x - width / 2, p.y - 13, width, 20, 7);
    c.fill();
    c.strokeStyle = PLAYER_COLORS[player.color];
    c.lineWidth = 2;
    c.stroke();
    c.fillStyle = label === 'YOU' ? '#ffffff' : '#584377';
    c.textAlign = 'center';
    c.fillText(label, p.x, p.y + 1);
  }
  frame(t: number) {
    const dt = Math.min((t - this.last) / 1000 || 0, 0.05);
    this.last = t;
    this.clock = t;
    const c = this.ctx;
    this.w = this.canvas.clientWidth;
    this.h = this.canvas.clientHeight;
    const dpr = Math.min(devicePixelRatio, 2);
    if (
      this.canvas.width !== Math.round(this.w * dpr) ||
      this.canvas.height !== Math.round(this.h * dpr)
    ) {
      this.canvas.width = Math.round(this.w * dpr);
      this.canvas.height = Math.round(this.h * dpr);
    }
    c.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.unit =
      Math.max(
        10,
        Math.min(this.w < 600 ? this.w / 26 : this.w / 43, this.h / 29, 33),
      ) * this.zoom;
    if (this.active) {
      let sx = 0,
        sy = 0;
      if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) sy--;
      if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) sy++;
      if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) sx--;
      if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) sx++;
      if (sx || sy) {
        const k = (dt * 3) / Math.hypot(sx, sy);
        this.player.x = Math.max(
          0.5,
          Math.min(17.45, this.player.x + (sx + sy) * k),
        );
        this.player.z = Math.max(
          0.5,
          Math.min(17.45, this.player.z + (-sx + sy) * k),
        );
        this.selected = {
          x: Math.max(
            0,
            Math.min(17, Math.floor(this.player.x + (sx + sy) * 0.8)),
          ),
          z: Math.max(
            0,
            Math.min(17, Math.floor(this.player.z + (-sx + sy) * 0.8)),
          ),
        };
        this.onSelect();
      }
    }
    for (const [id, p] of this.peers) {
      if (t - p.seen > 10000) {
        this.peers.delete(id);
        continue;
      }
      p.x += (p.tx - p.x) * Math.min(1, dt * 12);
      p.z += (p.tz - p.z) * Math.min(1, dt * 12);
    }
    const bg = c.createLinearGradient(0, 0, 0, this.h);
    bg.addColorStop(0, '#dbd9f5');
    bg.addColorStop(1, '#f5e4e5');
    c.fillStyle = bg;
    c.fillRect(0, 0, this.w, this.h);
    c.fillStyle = '#ffffff70';
    for (let i = 0; i < 5; i++) {
      const x =
          ((i * 233 + (this.motion.matches ? 0 : t * 0.004)) % (this.w + 190)) -
          90,
        y = 85 + (i % 3) * 75;
      c.beginPath();
      c.roundRect(x, y, 100, 15, 8);
      c.roundRect(x + 24, y - 12, 42, 24, 10);
      c.fill();
    }
    const sh = this.project(9, -1.8, 9);
    c.fillStyle = '#9881b025';
    c.beginPath();
    c.ellipse(sh.x, sh.y, this.unit * 16, this.unit * 6, 0, 0, Math.PI * 2);
    c.fill();
    const players = [
      ...(this.active ? [this.player] : []),
      ...this.peers.values(),
    ];
    for (let sum = 0; sum < 35; sum++) {
      for (let x = 0; x < 18; x++) {
        const z = sum - x;
        if (z < 0 || z > 17) continue;
        const highest = top(this.world, x, z);
        for (let y = 0; y <= highest; y++) {
          const b = this.world.blocks[cell(x, y, z)];
          if (!b) continue;
          if (
            x < 17 &&
            z < 17 &&
            this.world.blocks[cell(x + 1, y, z)] &&
            this.world.blocks[cell(x, y, z + 1)] &&
            this.world.blocks[cell(x, y + 1, z)]
          )
            continue;
          this.cube(x, y, z, MATERIALS[b].color);
        }
        if (
          highest === 2 &&
          this.world.blocks[cell(x, 2, z)] === 1 &&
          (x * 13 + z * 7) % 31 === 0
        ) {
          const p = this.project(x + 0.5, 3.03, z + 0.5);
          c.fillStyle = '#fff4ce';
          c.fillRect(p.x - 2, p.y - 4, 4, 4);
          c.fillStyle = '#619273';
          c.fillRect(p.x, p.y, 1.5, 4);
        }
        if (this.pendingCells.has(x+z*18)) {
          const y = highest+1.04;
          c.setLineDash([4,3]);
          this.polygon([this.project(x,y,z),this.project(x+1,y,z),this.project(x+1,y,z+1),this.project(x,y,z+1)], '#fff6ce40', '#fff2a6');
          c.setLineDash([]);
        }
        if (this.active && x === this.selected.x && z === this.selected.z) {
          const y = highest + 1.025;
          const points = [
            this.project(x, y, z),
            this.project(x + 1, y, z),
            this.project(x + 1, y, z + 1),
            this.project(x, y, z + 1),
          ];
          this.polygon(points, '#ffffff35', '#ffffff');
          c.lineWidth = 2;
          c.stroke();
        }
      }
    }
    // Game pieces stay readable even behind a friend's tall build. Labels are a
    // final overlay so neither terrain nor another avatar can paint over them.
    players.sort((a,b) => a.x+a.z-b.x-b.z);
    for (const p of players) this.avatar(p);
    const labels: {x:number;y:number;width:number}[] = [];
    for (const p of players) this.avatarLabel(p, labels);
    for (const p of this.particles) {
      p.t -= dt * 2;
      p.y += dt * 2;
      const a = this.project(p.x, p.y, p.z);
      c.globalAlpha = Math.max(0, p.t);
      c.fillStyle = p.color;
      c.fillRect(a.x, a.y, 5, 5);
    }
    c.globalAlpha = 1;
    this.particles = this.particles.filter((p) => p.t > 0);
    this.canvas.dataset.localX = this.player.x.toFixed(3);
    this.canvas.dataset.localZ = this.player.z.toFixed(3);
    this.canvas.dataset.peerCount = String(this.peers.size);
    this.canvas.dataset.peerLabels = JSON.stringify([...this.peers.values()].map(p=>p.name));
    this.canvas.dataset.peerPositions = JSON.stringify(
      [...this.peers].map(([id, p]) => [
        id,
        Number(p.x.toFixed(3)),
        Number(p.z.toFixed(3)),
      ]),
    );
    this.canvas.dataset.edits = String(this.world.edits);
    this.canvas.dataset.confirmedEdits = String(this.confirmedEdits);
    this.canvas.dataset.pendingCells = String(this.pendingCells.size);
    this.canvas.dataset.selected = `${this.selected.x},${this.selected.z}`;
    this.raf = requestAnimationFrame((t) => this.frame(t));
  }
  dispose() {
    cancelAnimationFrame(this.raf);
  }
}