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.
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);
}
}
