Public: anyone can read this; the crew can change it.

mobs. make the world come alive #6

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/voxel:vesper/5-mobs-make-the-world-come-alive → main 3 commits fork

  • Merged. vesper/5-mobs-make-the-world-come-alive was merged into main as c454002.

Review

Sign in to review this change.
11 files changed +1548 −52
README.md +25 −5
@@ -23,6 +23,11 @@ far you can see, from 64 to 192 metres.
2323 The sun crosses the sky every twenty minutes; caves and the undersides of
2424 things stay dark through the day, and the stars come out at night.
2525
26 Sheep, pigs and chickens wander the grass, fish keep to the water, and
27 fireflies come out once it is properly dark. Nothing in this world can be
28 hurt or killed: walk into one and it bolts, swing at one and it bolts, and
29 the block behind it breaks anyway. They are not stored — see below.
30
2631 ## How it is put together
2732
2833 - `src/world/` is the model, and it is the same code on both sides of the
@@ -30,9 +35,13 @@ things stay dark through the day, and the stars come out at night.
3035 trees), `chunk.ts`, `store.ts` (chunks plus the blocks people changed),
3136 `mesher.ts` (faces, ambient occlusion, a daylight bit), `physics.ts`,
3237 `raycast.ts`, `daylight.ts`.
38 `mobs.ts` is the table of creatures — the boxes each one is made of, how it
39 walks, swims or hovers, and where it is willing to live — and `herd.ts` is
40 the creatures a world is holding right now.
3341 - `src/client/` is the browser: `worker.ts` generates and meshes chunks off
34 the main thread, `renderer.ts` draws them, `input.ts` reads a keyboard or a
35 thumb, `main.ts` is the loop.
42 the main thread, `renderer.ts` draws them, `mobmesh.ts` packs the whole herd
43 into one buffer for one draw call, `input.ts` reads a keyboard or a thumb,
44 `main.ts` is the loop.
3645 - `src/server.ts` routes and holds the security floor — every response
3746 carries it, including the 404, the 403, the 413, the 429 and the 500, and
3847 the page's one stylesheet runs on a nonce minted per response so the policy
@@ -41,14 +50,23 @@ things stay dark through the day, and the stars come out at night.
4150 - `src/bundle.ts` builds the two client bundles at boot and serves them from
4251 memory at a name that is a hash of their contents, cached for a year.
4352
44 What is real: the terrain, the physics, the lighting and the saving. Nothing
45 is faked or pre-baked — a world is a seed and a list of changed blocks, so
53 What is real: the terrain, the physics, the lighting, the creatures and the
54 saving. An animal is a body with a gait, run by the same collision code the
55 player is, so it falls, jumps a step, stops at a wall and turns back at a
56 cliff rather than following a path laid out for it. Nothing is faked or
57 pre-baked — a world is a seed and a list of changed blocks, so
4658 storage stays small no matter how far you walk. There is no multiplayer: two
4759 people in one world will not see each other, though they do change the same
4860 blocks. Chunks are 16 x 64 x 16 and the world is 64 blocks tall.
4961
5062 ## What it keeps
5163
64 Not the creatures. A chunk hashes its own animals out of the seed when it
65 arrives and forgets them when it goes, so the herd is bounded by what is
66 loaded around you (at most 40 at once) and a world is still only a seed and a
67 list of changed blocks however long anything walks around in it. Two people
68 in one world see different sheep, the same way they do not see each other.
69
5270 A world is a name and a seed. Only the blocks you change are stored, in
5371 SQLite under `FORGE_APP_DATA`. Where you are standing is kept per browser
5472 against a session cookie (HttpOnly, SameSite=Lax) — two people in the same
@@ -86,7 +104,9 @@ the host; `<name>--<pull>@<forge>` for a preview's). It opens `src/tty.ts` on
86104 a real pty over the same worlds: `j`/`k` and `enter` to open one, `n` to make
87105 one, `d` to delete one — typing its name to confirm — then the same ground
88106 seen from above, where the cursor moves with `h j k l`, `x` breaks the top
89 block and `1`–`9` place one.
107 block and `1`–`9` place one. The creatures walk across that map while you
108 watch it, on the same twenty-minute clock, and the line under it says what is
109 about.
90110
91111 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`
92112 (`production` or `preview`), `FORGE_APP_URL` and `FORGE_APP_COMMIT`
src/client/main.ts +58 −6
@@ -6,8 +6,10 @@
66
77 import { ChunkField } from "./chunks.ts";
88 import { Input } from "./input.ts";
9 import { MobSink, packMobs } from "./mobmesh.ts";
910 import { Renderer } from "./renderer.ts";
1011 import { Save } from "./save.ts";
12 import { Herd } from "../world/herd.ts";
1113 import { identity, forward, lookRotation, multiply, perspective } from "../math/mat4.ts";
1214 import { DAY_SECONDS, daylight, timeName } from "../world/daylight.ts";
1315 import { EYE_HEIGHT, PLAYER_HEIGHT, PLAYER_WIDTH, settle, step, type Body } from "../world/physics.ts";
@@ -52,8 +54,15 @@ async function boot(): Promise<void> {
5254 const stored = Number(localStorage.getItem("voxel.distance"));
5355 const radius = stored >= 4 && stored <= 12 ? stored : 7;
5456 hud.distance.value = String(radius);
55 const field = new ChunkField(worker, radius, (cx, cz, mesh) =>
56 renderer.upload(chunkKey(cx, cz), cx, cz, mesh));
57 const field = new ChunkField(worker, radius, (cx, cz, mesh) => {
58 renderer.upload(chunkKey(cx, cz), cx, cz, mesh);
59 // A chunk brings its creatures with it and takes them away again, so the
60 // herd is only ever as big as what is loaded around you. The worker
61 // answers no sooner than the next tick, so `herd` exists by then.
62 herd.spawn(cx, cz);
63 });
64 const herd = new Herd(field, state.seed);
65 const mobSink = new MobSink();
5766 field.open(state.seed, state.edits);
5867
5968 const save = new Save(world);
@@ -72,7 +81,10 @@ async function boot(): Promise<void> {
7281 const value = Number(hud.distance.value);
7382 localStorage.setItem("voxel.distance", String(value));
7483 field.radius = value;
75 for (const key of field.update(body.x, body.z, true)) renderer.remove(key);
84 for (const key of field.update(body.x, body.z, true)) {
85 renderer.remove(key);
86 herd.drop(key);
87 }
7688 });
7789
7890 const body: Body = {
@@ -109,8 +121,23 @@ async function boot(): Promise<void> {
109121 && block[2] + 1 > body.z - half && block[2] < body.z + half;
110122 };
111123
124 /**
125 * A loop that throws throws every frame, and all the page shows for it is
126 * whatever it was saying before. Say what happened instead, once, and stop.
127 */
128 let stopped = false;
112129 function frame(now: number): void {
130 if (stopped) return;
113131 requestAnimationFrame(frame);
132 try {
133 simulate(now);
134 } catch (error) {
135 stopped = true;
136 fail(error);
137 }
138 }
139
140 function simulate(now: number): void {
114141 const elapsed = now - last;
115142 last = now;
116143 const dt = Math.min(elapsed / 1000, 0.1);
@@ -135,7 +162,10 @@ async function boot(): Promise<void> {
135162 body.y = spawnHeight(Math.round(body.x), Math.round(body.z), state.seed);
136163 body.vy = 0;
137164 }
138 for (const key of field.update(body.x, body.z)) renderer.remove(key);
165 for (const key of field.update(body.x, body.z)) {
166 renderer.remove(key);
167 herd.drop(key);
168 }
139169 hud.loading.hidden = true;
140170 }
141171
@@ -143,7 +173,20 @@ async function boot(): Promise<void> {
143173 const dir = forward(controls.yaw, controls.pitch);
144174 const hit = raycast(eye[0], eye[1], eye[2], dir[0], dir[1], dir[2], 6, field.targets);
145175
176 const light = daylight(time);
177 const night = 1 - light.brightness;
178 if (standing) herd.tick(dt, { x: body.x, y: body.y, z: body.z }, night);
179
146180 const actions = input.takeActions();
181 // A swing scares whatever it passes rather than hurting it — nothing in
182 // this world can be killed — and the block behind it still breaks, so an
183 // animal wandering past never costs you a pickaxe swing.
184 if (actions.breaks > 0) {
185 herd.startleAlong(
186 { x: eye[0], y: eye[1], z: eye[2] }, dir[0], dir[1], dir[2],
187 hit ? Math.min(hit.distance, 5) : 5,
188 );
189 }
147190 if (standing && hit) {
148191 for (let i = 0; i < actions.breaks; i++) {
149192 if (hit.y > 0 && field.set(hit.x, hit.y, hit.z, 0)) save.edit(hit.x, hit.y, hit.z, 0);
@@ -160,7 +203,6 @@ async function boot(): Promise<void> {
160203 }
161204 }
162205
163 const light = daylight(time);
164206 const aspect = canvas.width / Math.max(1, canvas.height);
165207 const fogFar = field.radius * 16 - 12;
166208 perspective(projection, (70 * Math.PI) / 180, aspect, 0.08, Math.max(220, fogFar + 80));
@@ -175,6 +217,7 @@ async function boot(): Promise<void> {
175217 fogFar,
176218 underwater: isLiquid(field.get(Math.floor(eye[0]), Math.floor(eye[1]), Math.floor(eye[2]))),
177219 highlight: hit ? [hit.x, hit.y, hit.z] : null,
220 mobs: packMobs(mobSink, herd.mobs, eye, fogFar + 24, night),
178221 });
179222
180223 frames++;
@@ -185,6 +228,7 @@ async function boot(): Promise<void> {
185228 hud.stats.textContent =
186229 `${Math.round(1000 / Math.max(ms, 0.1))} fps · ${ms.toFixed(1)} ms · ` +
187230 `${stats.chunks} chunks · ${(stats.quads / 1000).toFixed(0)}k faces · ` +
231 `${herd.awake(night).length} creatures · ` +
188232 `${Math.round(body.x)}, ${Math.round(body.y)}, ${Math.round(body.z)} · ${timeName(time)}` +
189233 (save.trouble ? " · not saving" : "");
190234 frames = 0;
@@ -214,4 +258,12 @@ function cornerRays(out: Float32Array, yaw: number, pitch: number, aspect: numbe
214258 });
215259 }
216260
217 void boot();
261 /** Whatever went wrong, the page says so rather than sitting there carving. */
262 function fail(error: unknown): void {
263 hud.loading.hidden = false;
264 hud.loading.textContent =
265 `Something went wrong: ${error instanceof Error ? error.message : String(error)}`;
266 throw error;
267 }
268
269 boot().catch(fail);
src/client/mobmesh.ts added +164
@@ -0,0 +1,164 @@
1 /**
2 * Creatures into triangles. Every part of every creature is a box, and a box
3 * is six quads, so mobs ride the same index buffer the chunks do: four
4 * vertices a quad, six indices, one draw call for the whole herd.
5 *
6 * Twenty bytes a vertex: the position as floats, because a creature is not on
7 * the block grid; a normal, a sky-light level, a colour and a glow flag.
8 */
9
10 import { KINDS, poseMob, type Mob, type Posed } from "../world/mobs.ts";
11
12 export const MOB_VERTEX_BYTES = 20;
13
14 export type MobMesh = { data: Uint8Array; quads: number };
15
16 /** The six faces of a box: normal, then four corners as ±1 signs. */
17 const FACES: { n: [number, number, number]; corners: [number, number, number][] }[] = [
18 { n: [1, 0, 0], corners: [[1, -1, 1], [1, -1, -1], [1, 1, -1], [1, 1, 1]] },
19 { n: [-1, 0, 0], corners: [[-1, -1, -1], [-1, -1, 1], [-1, 1, 1], [-1, 1, -1]] },
20 { n: [0, 1, 0], corners: [[-1, 1, 1], [1, 1, 1], [1, 1, -1], [-1, 1, -1]] },
21 { n: [0, -1, 0], corners: [[-1, -1, -1], [1, -1, -1], [1, -1, 1], [-1, -1, 1]] },
22 { n: [0, 0, 1], corners: [[-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1]] },
23 { n: [0, 0, -1], corners: [[1, -1, -1], [-1, -1, -1], [-1, 1, -1], [1, 1, -1]] },
24 ];
25
26 /**
27 * A growable buffer of mob vertices. It is kept between frames — the herd is
28 * about the same size every frame, so after a moment it stops allocating.
29 */
30 export class MobSink {
31 private buffer = new ArrayBuffer(1 << 14);
32 private floats = new Float32Array(this.buffer);
33 private bytes = new Uint8Array(this.buffer);
34 private signed = new Int8Array(this.buffer);
35 private length = 0;
36
37 /** Make room for a whole box at once, so the vertices can just be written. */
38 room(need: number): void {
39 if (this.length + need <= this.buffer.byteLength) return;
40 let size = this.buffer.byteLength;
41 while (size < this.length + need) size *= 2;
42 const next = new ArrayBuffer(size);
43 new Uint8Array(next).set(this.bytes.subarray(0, this.length));
44 this.buffer = next;
45 this.floats = new Float32Array(next);
46 this.bytes = new Uint8Array(next);
47 this.signed = new Int8Array(next);
48 }
49
50 vertex(
51 x: number, y: number, z: number,
52 nx: number, ny: number, nz: number,
53 light: number, r: number, g: number, b: number, glow: number,
54 ): void {
55 const at = this.length;
56 const f = at >> 2;
57 this.floats[f] = x;
58 this.floats[f + 1] = y;
59 this.floats[f + 2] = z;
60 this.signed[at + 12] = Math.round(nx * 127);
61 this.signed[at + 13] = Math.round(ny * 127);
62 this.signed[at + 14] = Math.round(nz * 127);
63 this.bytes[at + 15] = Math.round(light * 255);
64 this.bytes[at + 16] = r;
65 this.bytes[at + 17] = g;
66 this.bytes[at + 18] = b;
67 this.bytes[at + 19] = glow;
68 this.length = at + MOB_VERTEX_BYTES;
69 }
70
71 reset(): void {
72 this.length = 0;
73 }
74
75 taken(): MobMesh {
76 return {
77 data: this.bytes.subarray(0, this.length),
78 quads: this.length / (MOB_VERTEX_BYTES * 4),
79 };
80 }
81 }
82
83 /**
84 * Pack every creature worth drawing into one buffer. Anything past `far` is
85 * skipped: it is a handful of pixels inside the fog, and this runs on the
86 * main thread every frame.
87 */
88 export function packMobs(
89 sink: MobSink,
90 mobs: Mob[],
91 camera: [number, number, number],
92 far: number,
93 night = 1,
94 ): MobMesh {
95 sink.reset();
96 const posed: Posed[] = [];
97 for (const mob of mobs) {
98 const kind = KINDS[mob.kind]!;
99 const dx = mob.x - camera[0], dy = mob.y - camera[1], dz = mob.z - camera[2];
100 if (dx * dx + dy * dy + dz * dz > far * far) continue;
101 // A firefly is only a lamp, and only once it is properly dark.
102 const glow = kind.nocturnal === true ? Math.max(0, Math.min(1, (night - 0.3) / 0.3)) : 1;
103 if (kind.nocturnal === true && glow <= 0) continue;
104 poseMob(mob, posed);
105 for (const part of posed) box(sink, part, glow);
106 }
107 return sink.taken();
108 }
109
110 /**
111 * Where a model point lands: swung about the part's own axis, turned to face
112 * the way the creature does. Written into these three rather than returned,
113 * because this runs six thousand times a frame and nothing here is worth
114 * allocating.
115 */
116 let tx = 0, ty = 0, tz = 0;
117
118 function place(
119 lx: number, ly: number, lz: number,
120 cs: number, ss: number, cy: number, sy: number, swingX: boolean,
121 ): void {
122 let x = lx, y = ly, z = lz;
123 if (swingX) {
124 const ny = y * cs - z * ss;
125 z = y * ss + z * cs;
126 y = ny;
127 } else {
128 const nx = x * cs + z * ss;
129 z = -x * ss + z * cs;
130 x = nx;
131 }
132 tx = x * cy + z * sy;
133 ty = y;
134 tz = -x * sy + z * cy;
135 }
136
137 /** One posed box: swing about its own axis, turn with the creature, place. */
138 function box(sink: MobSink, part: Posed, glow: number): void {
139 const cs = Math.cos(part.swing), ss = Math.sin(part.swing);
140 const cy = Math.cos(part.yaw), sy = Math.sin(part.yaw);
141 const swingX = part.axis === "x";
142 const [r, g, b] = part.color;
143 const emit = part.glow ? Math.round(glow * 255) : 0;
144 // The part's light is dimmed by the shade it stands in; a lamp ignores it.
145 const light = part.glow ? 1 : part.light;
146
147 sink.room(6 * 4 * MOB_VERTEX_BYTES);
148 for (const face of FACES) {
149 place(face.n[0], face.n[1], face.n[2], cs, ss, cy, sy, swingX);
150 const nx = tx, ny = ty, nz = tz;
151 for (const corner of face.corners) {
152 place(
153 part.bx + corner[0] * part.hx,
154 part.by + corner[1] * part.hy,
155 part.bz + corner[2] * part.hz,
156 cs, ss, cy, sy, swingX,
157 );
158 sink.vertex(
159 part.px + tx, part.py + ty, part.pz + tz,
160 nx, ny, nz, light, r, g, b, emit,
161 );
162 }
163 }
164 }
src/client/renderer.ts +55 −1
@@ -9,8 +9,10 @@
99 import { compile, describe, uniforms } from "./gl.ts";
1010 import { uploadTiles } from "./textures.ts";
1111 import {
12 OUTLINE_FRAGMENT, OUTLINE_VERTEX, SKY_FRAGMENT, SKY_VERTEX, TERRAIN_FRAGMENT, TERRAIN_VERTEX,
12 MOB_FRAGMENT, MOB_VERTEX, OUTLINE_FRAGMENT, OUTLINE_VERTEX, SKY_FRAGMENT, SKY_VERTEX,
13 TERRAIN_FRAGMENT, TERRAIN_VERTEX,
1314 } from "./shaders.ts";
15 import { MOB_VERTEX_BYTES, type MobMesh } from "./mobmesh.ts";
1416 import { VERTEX_BYTES, type Mesh } from "../world/mesher.ts";
1517 import { CHUNK_X, CHUNK_Y, CHUNK_Z } from "../world/chunk.ts";
1618 import { boxInFrustum, frustumPlanes, type Mat4 } from "../math/mat4.ts";
@@ -27,6 +29,8 @@ export type Scene = {
2729 fogFar: number;
2830 underwater: boolean;
2931 highlight: [number, number, number] | null;
32 /** Every creature in one buffer, already packed by the page. */
33 mobs: MobMesh | null;
3034 };
3135
3236 export type Stats = { chunks: number; quads: number };
@@ -52,6 +56,10 @@ export class Renderer {
5256 private outline: WebGLProgram;
5357 private outlineU: Record<string, WebGLUniformLocation>;
5458 private outlineVao: WebGLVertexArrayObject;
59 private mobs: WebGLProgram;
60 private mobsU: Record<string, WebGLUniformLocation>;
61 private mobVao: WebGLVertexArrayObject;
62 private mobBuffer: WebGLBuffer;
5563 private emptyVao: WebGLVertexArrayObject;
5664 private indices: WebGLBuffer;
5765 private indexQuads = 0;
@@ -77,6 +85,23 @@ export class Renderer {
7785 describe(gl, this.outline, edges, 12, [{ name: "aPos", size: 3, type: gl.FLOAT, offset: 0 }]);
7886 gl.bindVertexArray(null);
7987
88 // The herd is one buffer that is rewritten every frame — the animals move
89 // and there is nothing to keep — and it shares the chunks' index buffer.
90 this.mobs = compile(gl, MOB_VERTEX, MOB_FRAGMENT);
91 this.mobsU = uniforms(gl, this.mobs);
92 this.mobVao = gl.createVertexArray()!;
93 this.mobBuffer = gl.createBuffer()!;
94 gl.bindVertexArray(this.mobVao);
95 describe(gl, this.mobs, this.mobBuffer, MOB_VERTEX_BYTES, [
96 { name: "aPos", size: 3, type: gl.FLOAT, offset: 0 },
97 { name: "aNormal", size: 3, type: gl.BYTE, offset: 12, normalized: true },
98 { name: "aLight", size: 1, type: gl.UNSIGNED_BYTE, offset: 15, normalized: true },
99 { name: "aColor", size: 3, type: gl.UNSIGNED_BYTE, offset: 16, normalized: true },
100 { name: "aGlow", size: 1, type: gl.UNSIGNED_BYTE, offset: 19, normalized: true },
101 ]);
102 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indices);
103 gl.bindVertexArray(null);
104
80105 gl.enable(gl.DEPTH_TEST);
81106 gl.enable(gl.CULL_FACE);
82107 gl.cullFace(gl.BACK);
@@ -152,6 +177,31 @@ export class Renderer {
152177 return this.meshes.has(key);
153178 }
154179
180 /** The whole herd in one call: upload what the page packed, then draw it. */
181 private drawMobs(scene: Scene, mesh: MobMesh): void {
182 const gl = this.gl;
183 // Growing the shared index buffer touches whichever vertex array is
184 // bound, so nothing is bound while it happens.
185 gl.bindVertexArray(null);
186 if (mesh.quads > this.indexQuads) this.growIndices(Math.ceil(mesh.quads * 1.5));
187 gl.useProgram(this.mobs);
188 const u = this.mobsU;
189 gl.uniformMatrix4fv(u.uViewProj!, false, scene.viewProj);
190 gl.uniform3fv(u.uCam!, scene.camera);
191 gl.uniform3fv(u.uSunDir!, scene.light.sun);
192 gl.uniform3fv(u.uSunColor!, scene.light.sunColor);
193 gl.uniform3fv(u.uAmbient!, scene.light.ambient);
194 gl.uniform3fv(u.uFogColor!, scene.underwater ? [0.09, 0.26, 0.44] : scene.light.horizon);
195 gl.uniform1f(u.uFogNear!, scene.underwater ? 2 : scene.fogFar * 0.55);
196 gl.uniform1f(u.uFogFar!, scene.underwater ? 22 : scene.fogFar);
197 gl.uniform1f(u.uUnderwater!, scene.underwater ? 1 : 0);
198 gl.bindVertexArray(this.mobVao);
199 gl.bindBuffer(gl.ARRAY_BUFFER, this.mobBuffer);
200 gl.bufferData(gl.ARRAY_BUFFER, mesh.data, gl.DYNAMIC_DRAW);
201 gl.drawElements(gl.TRIANGLES, mesh.quads * 6, gl.UNSIGNED_INT, 0);
202 gl.useProgram(this.terrain);
203 }
204
155205 draw(scene: Scene): Stats {
156206 const gl = this.gl;
157207 const { light } = scene;
@@ -205,6 +255,10 @@ export class Renderer {
205255 quads += mesh.opaque.quads;
206256 }
207257
258 // The creatures go between the two terrain passes: they hide behind
259 // stone and water hides them, which is the order the world is in.
260 if (scene.mobs && scene.mobs.quads > 0) this.drawMobs(scene, scene.mobs);
261
208262 gl.enable(gl.BLEND);
209263 gl.depthMask(false);
210264 gl.disable(gl.CULL_FACE);
src/client/shaders.ts +51
@@ -102,6 +102,57 @@ void main() {
102102 fragColor = vec4(mix(sky, vec3(0.09, 0.26, 0.44), uUnderwater * 0.85), 1.0);
103103 }`;
104104
105 export const MOB_VERTEX = `#version 300 es
106 precision highp float;
107 in vec3 aPos;
108 in vec3 aNormal;
109 in float aLight;
110 in vec3 aColor;
111 in float aGlow;
112 uniform mat4 uViewProj;
113 uniform vec3 uCam;
114 out vec3 vNormal;
115 out vec3 vColor;
116 out float vLight;
117 out float vGlow;
118 out float vDist;
119 void main() {
120 vNormal = normalize(aNormal);
121 vColor = aColor;
122 vLight = aLight;
123 vGlow = aGlow;
124 vDist = distance(aPos, uCam);
125 gl_Position = uViewProj * vec4(aPos, 1.0);
126 }`;
127
128 export const MOB_FRAGMENT = `#version 300 es
129 precision highp float;
130 in vec3 vNormal;
131 in vec3 vColor;
132 in float vLight;
133 in float vGlow;
134 in float vDist;
135 uniform vec3 uSunDir;
136 uniform vec3 uSunColor;
137 uniform vec3 uAmbient;
138 uniform vec3 uFogColor;
139 uniform float uFogNear;
140 uniform float uFogFar;
141 uniform float uUnderwater;
142 out vec4 fragColor;
143 void main() {
144 vec3 n = gl_FrontFacing ? vNormal : -vNormal;
145 float sky = mix(0.34, 1.0, vLight);
146 float diffuse = max(dot(n, uSunDir), 0.0) * mix(0.12, 1.0, vLight);
147 // A little extra along the sides so a white sheep still reads as a box.
148 float shape = 0.86 + 0.14 * abs(n.y);
149 vec3 color = vColor * (uAmbient * sky + uSunColor * diffuse) * shape;
150 color = mix(color, vColor * 1.35 + 0.25, vGlow);
151 color = mix(color, uFogColor, smoothstep(uFogNear, uFogFar, vDist) * (1.0 - vGlow * 0.6));
152 color = mix(color, vec3(0.09, 0.26, 0.44), uUnderwater * clamp(vDist / 22.0, 0.0, 0.86));
153 fragColor = vec4(color, 1.0);
154 }`;
155
105156 export const OUTLINE_VERTEX = `#version 300 es
106157 precision highp float;
107158 in vec3 aPos;
src/pages/home.ts +4
@@ -100,6 +100,10 @@ export function homePage(
100100 Click to look around; left click breaks a block, right click places the one in your hand,
101101 <code>1</code>–<code>9</code> and the scroll wheel choose it. On a phone: left thumb walks,
102102 right thumb looks, tap to break, buttons to jump and place.</p>
103 <p>Sheep, pigs and chickens wander the grass, fish keep to the water, and
104 fireflies come out after dark. Nothing can be hurt: swing at one and it
105 bolts. They are not saved — every chunk hashes its own animals out of the
106 seed — so the herd is only ever as big as what is around you.</p>
103107 <p>Everything you change is saved to the world's name, so you can come back to it.</p>
104108 <p>${ssh
105109 ? `The same worlds in a terminal: <code>${escape(ssh)}</code>`
src/tty.ts +95 −11
@@ -7,15 +7,18 @@
77 * to own a world with, so it takes the confirmation the web page takes from
88 * anyone else. Inside a world you get the same ground the browser draws, seen
99 * from above, and the same hotbar: move the cursor, x breaks the top block,
10 * 1 to 9 puts one down. Plain ANSI, no dependencies.
10 * 1 to 9 puts one down. The creatures walk about on it while you watch, on
11 * the same clock the browser keeps. Plain ANSI, no dependencies.
1112 */
1213
1314 import { Store, open } from "./db.ts";
1415 import { World } from "./world/store.ts";
15 import { CHUNK_Y, SEA_LEVEL } from "./world/chunk.ts";
16 import { CHUNK_Y, SEA_LEVEL, chunkKey, chunkOf } from "./world/chunk.ts";
1617 import { HOTBAR, blockName } from "./world/blocks.ts";
1718 import { findSpawn } from "./world/terrain.ts";
18 import { timeName } from "./world/daylight.ts";
19 import { DAY_SECONDS, daylight, timeName } from "./world/daylight.ts";
20 import { Herd } from "./world/herd.ts";
21 import { KINDS } from "./world/mobs.ts";
1922 import { seedFrom, worldName } from "./valid.ts";
2023
2124 const store = new Store(open());
@@ -36,6 +39,20 @@ const COLOR: Record<string, number> = {
3639 const paint = (id: number, glyph: string): string =>
3740 `${esc}38;5;${COLOR[blockName(id)] ?? 240}m${glyph}${esc}0m`;
3841
42 /** The nearest colour in the 256-colour cube, so a creature keeps its coat. */
43 const cube = (v: number): number => Math.round((v / 255) * 5);
44 const near256 = (r: number, g: number, b: number): number =>
45 16 + 36 * cube(r) + 6 * cube(g) + cube(b);
46 /** Every kind wears the colour of its first box and the letter it is named by. */
47 const MOB_PAINT = KINDS.map((kind) => {
48 const [r, g, b] = kind.parts[0]!.color;
49 return `${esc}1;38;5;${near256(r, g, b)}m${kind.glyph}${esc}0m`;
50 });
51
52 /** The terminal keeps the same clock the browser does: twenty minutes a day. */
53 const timeNow = (): number => (Date.now() / 1000 / DAY_SECONDS) % 1;
54 const nightNow = (): number => 1 - daylight(timeNow()).brightness;
55
3956 type Screen = { kind: "list" } | { kind: "map"; name: string };
4057 let screen: Screen = { kind: "list" };
4158 let cursor = 0;
@@ -47,6 +64,9 @@ let worldId = 0;
4764 let at = { x: 0, z: 0 };
4865 let slot = 0;
4966 let says = "";
67 let herd: Herd | null = null;
68 /** When the creatures were last moved, so they move by real seconds. */
69 let livedAt = 0;
5070
5171 const size = (): [number, number] => [process.stdout.columns ?? 80, process.stdout.rows ?? 24];
5272
@@ -71,24 +91,62 @@ function listScreen(): string[] {
7191 return lines;
7292 }
7393
94 /**
95 * Move the world on by however long it has been since the last frame, and
96 * hold the creatures of whatever chunks the map is showing.
97 */
98 function liven(width: number, height: number): void {
99 if (!herd) return;
100 const keys: string[] = [];
101 const from = { x: chunkOf(at.x - width / 2), z: chunkOf(at.z - height / 2) };
102 const to = { x: chunkOf(at.x + width / 2), z: chunkOf(at.z + height / 2) };
103 for (let cz = from.z; cz <= to.z; cz++)
104 for (let cx = from.x; cx <= to.x; cx++) keys.push(chunkKey(cx, cz));
105 herd.retain(keys);
106 const now = Date.now();
107 const dt = Math.min((now - livedAt) / 1000, 0.5);
108 livedAt = now;
109 if (dt > 0) herd.tick(dt, null, nightNow());
110 }
111
74112 /** The top of each column, straight down, the way the sun sees it. */
75113 function mapScreen(name: string): string[] {
76114 const [columns, rows] = size();
77115 const width = Math.max(20, Math.min(columns - 2, 120));
78 const height = Math.max(8, rows - 8);
79 const lines = [`${bold(name)} ${dim(`${where} · seed ${world!.seed}`)}`, ""];
116 const height = Math.max(8, rows - 9);
80117 const half = { x: Math.floor(width / 2), z: Math.floor(height / 2) };
118 liven(width, height);
119 const time = timeNow();
120 const lines = [
121 `${bold(name)} ${dim(`${where} · seed ${world!.seed} · ${timeName(time)}`)}`, "",
122 ];
123 // Whatever is walking about, one cell each, over the ground it stands on.
124 const creatures = new Map<number, string>();
125 for (const mob of herd?.awake(nightNow()) ?? []) {
126 const column = Math.round(mob.x) - at.x + half.x;
127 const row = Math.round(mob.z) - at.z + half.z;
128 if (column < 0 || column >= width || row < 0 || row >= height) continue;
129 creatures.set(row * width + column, MOB_PAINT[mob.kind]!);
130 }
81131 for (let row = 0; row < height; row++) {
82132 let line = "";
83133 for (let column = 0; column < width; column++) {
84134 const x = at.x + column - half.x;
85135 const z = at.z + row - half.z;
136 const here = column === half.x && row === half.z;
137 if (here) {
138 line += `${esc}1;37m✛${esc}0m`;
139 continue;
140 }
141 const creature = creatures.get(row * width + column);
142 if (creature !== undefined) {
143 line += creature;
144 continue;
145 }
86146 let y = CHUNK_Y - 1;
87147 while (y > 0 && world!.get(x, y, z) === 0) y--;
88148 const id = world!.get(x, y, z);
89 const here = column === half.x && row === half.z;
90 const glyph = here ? "✛" : y > SEA_LEVEL + 14 ? "▓" : y > SEA_LEVEL ? "█" : "▒";
91 line += here ? `${esc}1;37m✛${esc}0m` : paint(id, glyph);
149 line += paint(id, y > SEA_LEVEL + 14 ? "▓" : y > SEA_LEVEL ? "█" : "▒");
92150 }
93151 lines.push(line);
94152 }
@@ -99,14 +157,23 @@ function mapScreen(name: string): string[] {
99157 `${bold(`${at.x}, ${at.z}`)} ${dim(`· top is ${blockName(world!.get(at.x, y, at.z))} at y ${y}`)}` +
100158 ` ${dim("holding")} ${bold(blockName(HOTBAR[slot]!))}`,
101159 );
160 const census = herd?.census(nightNow()) ?? [];
161 lines.push(census.length === 0
162 ? dim("nothing is stirring here")
163 : dim(`about: ${census.map((c) => `${c.count} ${c.count === 1 ? c.name : c.plural}`).join(", ")}`));
102164 lines.push(dim("h/j/k/l or arrows move · x breaks the top · 1-9 places · q back"));
103165 if (says) lines.push(dim(says));
104166 return lines;
105167 }
106168
169 /**
170 * Home the cursor and overwrite, clearing each line as it goes rather than
171 * wiping the screen first: the map redraws several times a second now that
172 * there is something moving on it, and a wipe would flicker.
173 */
107174 function draw(): void {
108175 const lines = screen.kind === "list" ? listScreen() : mapScreen(screen.name);
109 out(`${esc}2J${esc}H` + lines.join("\r\n"));
176 out(`${esc}H` + lines.map((line) => `${line}${esc}K`).join("\r\n") + `${esc}J`);
110177 }
111178
112179 function openWorld(name: string): void {
@@ -117,7 +184,9 @@ function openWorld(name: string): void {
117184 const spawn = findSpawn(row.seed);
118185 at = { x: Math.round(spawn.x), z: Math.round(spawn.z) };
119186 screen = { kind: "map", name };
120 says = `it is ${timeName(0.26)} here`;
187 herd = new Herd(world, row.seed);
188 livedAt = Date.now();
189 says = `it is ${timeName(timeNow())} here`;
121190 }
122191
123192 /** One block changed, in memory and in the database, like the browser does. */
@@ -130,8 +199,15 @@ function edit(x: number, y: number, z: number, block: number): void {
130199 process.stdin.setRawMode?.(true);
131200 process.stdin.resume();
132201 process.stdin.setEncoding("utf8");
202 out(`${esc}2J`);
133203 draw();
134204
205 // The map is a live world, so it redraws itself a few times a second while
206 // you are standing in one. The list holds still, and costs nothing.
207 setInterval(() => {
208 if (screen.kind === "map" && prompt === undefined) draw();
209 }, 220).unref?.();
210
135211 process.stdin.on("data", (key: string) => {
136212 if (prompt !== undefined) {
137213 if (key === "\r") {
@@ -170,7 +246,15 @@ process.stdin.on("data", (key: string) => {
170246 }
171247
172248 const step = 4;
173 if (key === "q") { screen = { kind: "list" }; world = null; says = ""; draw(); return; }
249 if (key === "q") {
250 screen = { kind: "list" };
251 world = null;
252 herd = null;
253 says = "";
254 out(`${esc}2J`);
255 draw();
256 return;
257 }
174258 if (key === "h" || key === "\x1b[D") at.x -= step;
175259 if (key === "l" || key === "\x1b[C") at.x += step;
176260 if (key === "k" || key === "\x1b[A") at.z -= step;
src/world/herd.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * The creatures a world is holding right now. A chunk arriving brings its
3 * animals with it and a chunk going away takes them, so the herd is bounded
4 * by what you can see and nothing is ever written down.
5 *
6 * The browser and the terminal both use this: all it asks of a world is the
7 * blocks, and all it hands back is a list of mobs to draw.
8 */
9
10 import { chunkKey } from "./chunk.ts";
11 import { KINDS, spawnChunk, startle, stepMob, type Ground, type Mob, type Watcher } from "./mobs.ts";
12
13 /** A world that can say which chunks it actually has in memory. */
14 export type Loaded = Ground & { ready?: (x: number, z: number) => boolean };
15
16 /** How many creatures one page will hold at once. */
17 export const HERD_CAP = 40;
18 /** How dark it has to be before the nocturnal ones are about. */
19 export const NIGHT_ENOUGH = 0.35;
20
21 export class Herd {
22 private ground: Loaded;
23 private seed: number;
24 private cap: number;
25 private byChunk = new Map<string, Mob[]>();
26 /** Every mob, flat, in the order the chunks arrived. */
27 mobs: Mob[] = [];
28
29 constructor(ground: Loaded, seed: number, cap = HERD_CAP) {
30 this.ground = ground;
31 this.seed = seed >>> 0;
32 this.cap = cap;
33 }
34
35 get count(): number {
36 return this.mobs.length;
37 }
38
39 /** The creatures born in a chunk, once. Quiet when the herd is full. */
40 spawn(cx: number, cz: number): void {
41 const key = chunkKey(cx, cz);
42 if (this.byChunk.has(key)) return;
43 const room = this.cap - this.mobs.length;
44 const born = room > 0 ? spawnChunk(cx, cz, this.seed, this.ground, room) : [];
45 this.byChunk.set(key, born);
46 if (born.length > 0) this.flatten();
47 }
48
49 /** Let a chunk's creatures go, by the key the chunk field uses. */
50 drop(key: string): void {
51 if (!this.byChunk.has(key)) return;
52 const had = this.byChunk.get(key)!.length;
53 this.byChunk.delete(key);
54 if (had > 0) this.flatten();
55 }
56
57 /** Hold exactly these chunks: spawn what is missing, drop what is not. */
58 retain(keys: Iterable<string>): void {
59 const want = new Set(keys);
60 for (const key of [...this.byChunk.keys()]) if (!want.has(key)) this.drop(key);
61 for (const key of want) {
62 const [cx, cz] = key.split(",").map(Number) as [number, number];
63 this.spawn(cx, cz);
64 }
65 }
66
67 private flatten(): void {
68 this.mobs = [];
69 for (const group of this.byChunk.values()) this.mobs.push(...group);
70 }
71
72 /**
73 * Move everything by one frame. A creature standing in a chunk the world
74 * has not got is left where it is rather than dropped through the floor,
75 * and the ones that only come out in the dark sit still through the day.
76 */
77 tick(dt: number, player: Watcher | null = null, night = 1): void {
78 for (const mob of this.mobs) {
79 if (KINDS[mob.kind]!.nocturnal === true && night < NIGHT_ENOUGH) continue;
80 // Asked through the ground, never lifted off it: the chunk field
81 // answers this out of its own state, so it needs to be its own `this`.
82 if (this.ground.ready?.(mob.x, mob.z) === false) continue;
83 stepMob(mob, dt, this.ground, player);
84 }
85 }
86
87 /** The creatures you would see right now: the day sends some of them away. */
88 awake(night = 1): Mob[] {
89 if (night >= NIGHT_ENOUGH) return this.mobs;
90 return this.mobs.filter((mob) => KINDS[mob.kind]!.nocturnal !== true);
91 }
92
93 /**
94 * Whatever a swing along this ray comes closest to, within `reach` — it
95 * bolts. Nothing is hurt here; the world only gets out of the way.
96 */
97 startleAlong(
98 from: Watcher, dx: number, dy: number, dz: number, reach = 5, radius = 0.9,
99 ): Mob | null {
100 const length = Math.hypot(dx, dy, dz) || 1;
101 const ux = dx / length, uy = dy / length, uz = dz / length;
102 let best: Mob | null = null;
103 let nearest = reach;
104 for (const mob of this.mobs) {
105 const height = KINDS[mob.kind]!.gait.height;
106 const vx = mob.x - from.x, vy = mob.y + height / 2 - from.y, vz = mob.z - from.z;
107 const along = vx * ux + vy * uy + vz * uz;
108 if (along < 0 || along > nearest) continue;
109 const ox = vx - ux * along, oy = vy - uy * along, oz = vz - uz * along;
110 if (Math.hypot(ox, oy, oz) > radius + height / 2) continue;
111 best = mob;
112 nearest = along;
113 }
114 if (best) startle(best, from);
115 return best;
116 }
117
118 /** How many of each kind are about, for anything that says so out loud. */
119 census(night = 1): { name: string; plural: string; count: number }[] {
120 const counts = new Map<number, number>();
121 for (const mob of this.awake(night)) counts.set(mob.kind, (counts.get(mob.kind) ?? 0) + 1);
122 return [...counts.entries()]
123 .sort((a, b) => b[1] - a[1])
124 .map(([kind, count]) => ({
125 name: KINDS[kind]!.name,
126 plural: KINDS[kind]!.plural,
127 count,
128 }));
129 }
130 }
src/world/mobs.test.ts added +368
@@ -0,0 +1,368 @@
1 import { expect, test } from "bun:test";
2 import {
3 KINDS, LEASH, footing, kindNamed, makeMob, poseMob, spawnChunk, startle, stepMob,
4 type Ground, type Mob,
5 } from "./mobs.ts";
6 import { Herd, HERD_CAP, NIGHT_ENOUGH } from "./herd.ts";
7 import { MobSink, MOB_VERTEX_BYTES, packMobs } from "../client/mobmesh.ts";
8 import { World } from "./store.ts";
9 import { CHUNK_X, CHUNK_Y, CHUNK_Z, SEA_LEVEL, chunkKey } from "./chunk.ts";
10 import { isSolid } from "./blocks.ts";
11
12 /** A world of one material: ground up to `top`, air over it, sea if asked. */
13 const ground = (top: number, sea = -1): Ground => ({
14 get: (_x, y, _z) => (y <= top ? 1 : y <= sea ? 10 : 0),
15 solid: (_x, y, _z) => y <= top,
16 liquid: (_x, y, _z) => y > top && y <= sea,
17 });
18
19 /** Ground with a hole in it: everything outside the square is a void. */
20 const platform = (top: number, reach: number): Ground => ({
21 get: (x, y, z) => (y <= top && Math.abs(x) <= reach && Math.abs(z) <= reach ? 1 : 0),
22 solid: (x, y, z) => y <= top && Math.abs(x) <= reach && Math.abs(z) <= reach,
23 liquid: () => false,
24 });
25
26 const walkers = KINDS.map((k, i) => i).filter((i) => KINDS[i]!.habitat === "land");
27
28 test("every kind is a creature something can be made of", () => {
29 for (const kind of KINDS) {
30 expect(kind.parts.length).toBeGreaterThan(0);
31 expect(kind.gait.width).toBeGreaterThan(0);
32 expect(kind.gait.height).toBeGreaterThan(0);
33 expect(kind.group[0]).toBeLessThanOrEqual(kind.group[1]);
34 expect(kind.glyph.length).toBe(1);
35 for (const part of kind.parts) {
36 for (const c of part.color) expect(c).toBeGreaterThanOrEqual(0);
37 for (const c of part.color) expect(c).toBeLessThanOrEqual(255);
38 }
39 }
40 expect(kindNamed("sheep")).toBeGreaterThanOrEqual(0);
41 expect(kindNamed("nothing")).toBe(-1);
42 });
43
44 test("a chunk's creatures come out of the seed, the same ones every time", () => {
45 const world = new World(9001);
46 const once = spawnChunk(2, -3, 9001, world);
47 const again = spawnChunk(2, -3, 9001, world);
48 expect(again.map((m) => [m.kind, m.x, m.y, m.z]))
49 .toEqual(once.map((m) => [m.kind, m.x, m.y, m.z]));
50 // Somewhere in a hundred chunks of a real world there is something alive.
51 let alive = 0;
52 for (let cx = 0; cx < 10; cx++)
53 for (let cz = 0; cz < 10; cz++) alive += spawnChunk(cx, cz, 9001, world).length;
54 expect(alive).toBeGreaterThan(0);
55 // A different seed is a different meadow.
56 const other = new World(4242);
57 let elsewhere = 0;
58 for (let cx = 0; cx < 10; cx++)
59 for (let cz = 0; cz < 10; cz++) elsewhere += spawnChunk(cx, cz, 4242, other).length;
60 expect(elsewhere).not.toBe(alive);
61 });
62
63 test("creatures are born standing on the ground, inside their chunk", () => {
64 const world = new World(31337);
65 for (let cx = -4; cx < 4; cx++) {
66 for (let cz = -4; cz < 4; cz++) {
67 for (const mob of spawnChunk(cx, cz, 31337, world)) {
68 const kind = KINDS[mob.kind]!;
69 expect(mob.x).toBeGreaterThanOrEqual(cx * CHUNK_X);
70 expect(mob.x).toBeLessThan((cx + 1) * CHUNK_X);
71 expect(mob.z).toBeGreaterThanOrEqual(cz * CHUNK_Z);
72 expect(mob.z).toBeLessThan((cz + 1) * CHUNK_Z);
73 expect(mob.y).toBeGreaterThan(0);
74 expect(mob.y).toBeLessThan(CHUNK_Y);
75 // Nothing starts inside a block.
76 expect(isSolid(world.get(Math.floor(mob.x), Math.floor(mob.y), Math.floor(mob.z))))
77 .toBe(false);
78 if (kind.habitat === "land") {
79 expect(world.solid(Math.floor(mob.x), Math.floor(mob.y) - 1, Math.floor(mob.z)))
80 .toBe(true);
81 }
82 if (kind.habitat === "water") {
83 expect(world.liquid(Math.floor(mob.x), Math.floor(mob.y), Math.floor(mob.z)))
84 .toBe(true);
85 }
86 }
87 }
88 }
89 });
90
91 test("dry land holds no fish and the open sea holds no sheep", () => {
92 const dry = ground(30);
93 const sea = ground(10, SEA_LEVEL);
94 expect(footing(KINDS[kindNamed("fish")]!, 0, 0, dry)).toBeNull();
95 expect(footing(KINDS[kindNamed("fish")]!, 0, 0, sea)).not.toBeNull();
96 expect(footing(KINDS[kindNamed("sheep")]!, 0, 0, sea)).toBeNull();
97 expect(footing(KINDS[kindNamed("sheep")]!, 0, 0, dry)).toBe(31.01);
98 });
99
100 test("a walking creature stays on the ground and keeps its legs under it", () => {
101 const world = ground(30);
102 for (const kind of walkers) {
103 const mob = makeMob(kind, 0.5, 31.01, 0.5, 12345);
104 mob.mood = "roam";
105 mob.timer = 60;
106 let moved = 0;
107 for (let i = 0; i < 60 * 20; i++) {
108 stepMob(mob, 1 / 60, world);
109 moved += Math.hypot(mob.vx, mob.vz) / 60;
110 expect(mob.y).toBeGreaterThan(30.5);
111 expect(mob.y).toBeLessThan(34);
112 }
113 // It went somewhere, and its walk cycle went with it.
114 expect(moved).toBeGreaterThan(5);
115 expect(mob.phase).toBeGreaterThan(1);
116 }
117 });
118
119 test("a creature turns back before it walks off the edge of the world", () => {
120 const world = platform(30, 6);
121 for (const kind of walkers) {
122 const mob = makeMob(kind, 0.5, 31.01, 0.5, 777);
123 mob.mood = "roam";
124 mob.timer = 100;
125 for (let i = 0; i < 60 * 30; i++) stepMob(mob, 1 / 60, world);
126 expect(mob.y).toBeGreaterThan(30);
127 expect(Math.abs(mob.x)).toBeLessThan(8);
128 expect(Math.abs(mob.z)).toBeLessThan(8);
129 }
130 });
131
132 test("a creature does not stray far from where it was born", () => {
133 const world = ground(30);
134 const mob = makeMob(kindNamed("sheep"), 0.5, 31.01, 0.5, 20260917);
135 for (let i = 0; i < 60 * 120; i++) stepMob(mob, 1 / 60, world);
136 expect(Math.hypot(mob.x - mob.hx, mob.z - mob.hz)).toBeLessThan(LEASH + 6);
137 });
138
139 test("a fish stays in the water and waves its tail", () => {
140 const world = ground(10, SEA_LEVEL);
141 const mob = makeMob(kindNamed("fish"), 0.5, 18, 0.5, 5150);
142 for (let i = 0; i < 60 * 30; i++) {
143 stepMob(mob, 1 / 60, world);
144 expect(world.liquid(Math.floor(mob.x), Math.floor(mob.y + 0.14), Math.floor(mob.z)))
145 .toBe(true);
146 }
147 expect(mob.inWater).toBe(true);
148 expect(mob.phase).toBeGreaterThan(1);
149 });
150
151 test("a fish out of water falls back towards it", () => {
152 const world = ground(10, SEA_LEVEL);
153 const mob = makeMob(kindNamed("fish"), 0.5, SEA_LEVEL + 6, 0.5, 99);
154 const started = mob.y;
155 for (let i = 0; i < 60; i++) stepMob(mob, 1 / 60, world);
156 expect(mob.y).toBeLessThan(started);
157 });
158
159 test("a startled creature runs away from whoever startled it", () => {
160 const world = ground(30);
161 const mob = makeMob(kindNamed("pig"), 0.5, 31.01, 0.5, 4242);
162 const player = { x: 0.5, y: 31, z: 3 };
163 const before = Math.hypot(mob.x - player.x, mob.z - player.z);
164 startle(mob, player);
165 expect(mob.mood).toBe("flee");
166 for (let i = 0; i < 60; i++) stepMob(mob, 1 / 60, world);
167 expect(Math.hypot(mob.x - player.x, mob.z - player.z)).toBeGreaterThan(before + 1);
168 });
169
170 test("a creature bolts when the player walks into it", () => {
171 const world = ground(30);
172 const mob = makeMob(kindNamed("sheep"), 0.5, 31.01, 0.5, 8);
173 mob.mood = "graze";
174 mob.timer = 100;
175 stepMob(mob, 1 / 60, world, { x: 1, y: 31, z: 1 });
176 expect(mob.mood).toBe("flee");
177 });
178
179 test("a creature under a roof is darker than one under the sky", () => {
180 const open = ground(30);
181 const roofed: Ground = {
182 get: open.get,
183 solid: (x, y, z) => open.solid(x, y, z) || y === 40,
184 liquid: () => false,
185 };
186 const outside = makeMob(kindNamed("sheep"), 0.5, 31.01, 0.5, 1);
187 const inside = makeMob(kindNamed("sheep"), 0.5, 31.01, 0.5, 1);
188 stepMob(outside, 1 / 60, open);
189 stepMob(inside, 1 / 60, roofed);
190 expect(outside.light).toBe(1);
191 expect(inside.light).toBeLessThan(1);
192 });
193
194 test("a pose puts every box on the creature", () => {
195 const mob = makeMob(kindNamed("sheep"), 10, 31, -4, 3);
196 mob.yaw = 0.7;
197 const parts = poseMob(mob);
198 expect(parts.length).toBe(KINDS[mob.kind]!.parts.length);
199 for (const part of parts) {
200 expect(Math.hypot(part.px - mob.x, part.pz - mob.z)).toBeLessThan(2);
201 expect(part.py).toBeGreaterThanOrEqual(mob.y);
202 expect(part.py).toBeLessThan(mob.y + KINDS[mob.kind]!.gait.height + 0.5);
203 expect(part.yaw).toBe(mob.yaw);
204 }
205 });
206
207 test("legs swing while walking and a head goes down to graze", () => {
208 const world = ground(30);
209 const mob = makeMob(kindNamed("sheep"), 0.5, 31.01, 0.5, 11);
210 mob.mood = "roam";
211 mob.timer = 100;
212 const still = poseMob(mob).map((p) => p.swing);
213 for (let i = 0; i < 40; i++) stepMob(mob, 1 / 60, world);
214 const walking = poseMob(mob).map((p) => p.swing);
215 expect(walking).not.toEqual(still);
216 expect(walking.some((s) => Math.abs(s) > 0.05)).toBe(true);
217 // Grazing dips the head, and the eyes go with it.
218 mob.mood = "graze";
219 mob.phase = 0;
220 expect(poseMob(mob).filter((p) => p.swing > 0.5).length).toBe(3);
221 });
222
223 test("the herd fills up to its cap and lets go with the chunk", () => {
224 const world = new World(2024);
225 const herd = new Herd(world, 2024, 6);
226 for (let cx = -6; cx < 6; cx++) for (let cz = -6; cz < 6; cz++) herd.spawn(cx, cz);
227 expect(herd.count).toBeLessThanOrEqual(6);
228 const busy = [...Array(12).keys()]
229 .flatMap((cx) => [...Array(12).keys()].map((cz) => chunkKey(cx - 6, cz - 6)));
230 const held = herd.count;
231 expect(held).toBeGreaterThan(0);
232 for (const key of busy) herd.drop(key);
233 expect(herd.count).toBe(0);
234 // Retaining a set spawns what is missing and drops what is not wanted.
235 herd.retain(busy);
236 expect(herd.count).toBe(held);
237 herd.retain([]);
238 expect(herd.count).toBe(0);
239 expect(HERD_CAP).toBeGreaterThan(0);
240 });
241
242 test("the herd walks, and only wakes its night creatures at night", () => {
243 const world = new World(606);
244 const herd = new Herd(world, 606);
245 for (let cx = -3; cx < 3; cx++) for (let cz = -3; cz < 3; cz++) herd.spawn(cx, cz);
246 expect(herd.count).toBeGreaterThan(0);
247 const nocturnal = herd.mobs.filter((m) => KINDS[m.kind]!.nocturnal === true);
248 const before = nocturnal.map((m) => [m.x, m.y, m.z]);
249 for (let i = 0; i < 60; i++) herd.tick(1 / 60, null, 0);
250 expect(nocturnal.map((m) => [m.x, m.y, m.z])).toEqual(before);
251 expect(herd.awake(0).length).toBe(herd.count - nocturnal.length);
252 expect(herd.awake(1).length).toBe(herd.count);
253 for (let i = 0; i < 60; i++) herd.tick(1 / 60, null, 1);
254 if (nocturnal.length > 0) {
255 expect(nocturnal.map((m) => [m.x, m.y, m.z])).not.toEqual(before);
256 }
257 expect(NIGHT_ENOUGH).toBeGreaterThan(0);
258 // Nothing walked into a block while the herd was running.
259 for (const mob of herd.mobs) {
260 expect(isSolid(world.get(Math.floor(mob.x), Math.floor(mob.y + 0.1), Math.floor(mob.z))))
261 .toBe(false);
262 }
263 });
264
265 test("the herd asks the world what it has loaded, through the world", () => {
266 // The chunk field answers `ready` out of its own state, so the herd has to
267 // call it as a method. Lifting it off the object loses `this` and throws on
268 // the first frame the player is standing somewhere — the page never draws.
269 class Field {
270 private loaded = new Set([chunkKey(0, 0)]);
271 get = (_x: number, y: number, _z: number): number => (y <= 30 ? 1 : 0);
272 solid = (_x: number, y: number, _z: number): boolean => y <= 30;
273 liquid = (): boolean => false;
274 ready(x: number, z: number): boolean {
275 return this.loaded.has(chunkKey(Math.floor(x / CHUNK_X), Math.floor(z / CHUNK_Z)));
276 }
277 }
278 const herd = new Herd(new Field(), 1);
279 const here = makeMob(kindNamed("sheep"), 0.5, 31.01, 0.5, 5);
280 const away = makeMob(kindNamed("sheep"), 200.5, 31.01, 200.5, 5);
281 here.mood = "roam";
282 away.mood = "roam";
283 here.timer = 100;
284 away.timer = 100;
285 herd.mobs = [here, away];
286 for (let i = 0; i < 60; i++) herd.tick(1 / 60, null, 1);
287 // The one in a loaded chunk walked; the one in a chunk we do not have sat.
288 expect(Math.hypot(here.x - 0.5, here.z - 0.5)).toBeGreaterThan(0.2);
289 expect([away.x, away.z]).toEqual([200.5, 200.5]);
290 });
291
292 test("a swing along a ray startles what it passes and nothing else", () => {
293 const world = ground(30);
294 const herd = new Herd(world, 1);
295 const mob = makeMob(kindNamed("sheep"), 0, 31.01, -4, 77);
296 herd.mobs = [mob];
297 const eye = { x: 0, y: 32, z: 0 };
298 expect(herd.startleAlong(eye, 1, 0, 0)).toBeNull(); // looking away
299 expect(herd.startleAlong(eye, 0, 0, -1, 2)).toBeNull(); // too far to reach
300 expect(herd.startleAlong(eye, 0, 0, -1)).toBe(mob);
301 expect(mob.mood).toBe("flee");
302 });
303
304 test("the census counts what is about", () => {
305 const world = ground(30);
306 const herd = new Herd(world, 1);
307 herd.mobs = [
308 makeMob(kindNamed("sheep"), 0, 31, 0, 1),
309 makeMob(kindNamed("sheep"), 1, 31, 0, 2),
310 makeMob(kindNamed("pig"), 2, 31, 0, 3),
311 ];
312 expect(herd.census()).toEqual([
313 { name: "sheep", plural: "sheep", count: 2 },
314 { name: "pig", plural: "pigs", count: 3 - 2 },
315 ]);
316 });
317
318 test("a creature packs into six quads a box, where the creature is", () => {
319 const sink = new MobSink();
320 const mob = makeMob(kindNamed("sheep"), 100.25, 31, -60.5, 5);
321 const parts = KINDS[mob.kind]!.parts.length;
322 const mesh = packMobs(sink, [mob], [100, 32, -60], 64);
323 expect(mesh.quads).toBe(parts * 6);
324 expect(mesh.data.length).toBe(mesh.quads * 4 * MOB_VERTEX_BYTES);
325 // Every vertex is on the animal, not somewhere else in the world.
326 const floats = new Float32Array(mesh.data.length / 4);
327 for (let v = 0; v < mesh.quads * 4; v++) {
328 const at = v * MOB_VERTEX_BYTES;
329 const view = new DataView(mesh.data.buffer, mesh.data.byteOffset + at, 12);
330 floats[v * 3] = view.getFloat32(0, true);
331 floats[v * 3 + 1] = view.getFloat32(4, true);
332 floats[v * 3 + 2] = view.getFloat32(8, true);
333 expect(Math.abs(floats[v * 3]! - mob.x)).toBeLessThan(1.5);
334 expect(floats[v * 3 + 1]!).toBeGreaterThan(mob.y - 0.2);
335 expect(floats[v * 3 + 1]!).toBeLessThan(mob.y + 2);
336 expect(Math.abs(floats[v * 3 + 2]! - mob.z)).toBeLessThan(1.5);
337 }
338 // Far away it is not packed at all.
339 expect(packMobs(sink, [mob], [1000, 32, 1000], 64).quads).toBe(0);
340 });
341
342 test("the day keeps the fireflies out of the buffer", () => {
343 const sink = new MobSink();
344 const fly = makeMob(kindNamed("firefly"), 0, 31, 0, 6);
345 expect(packMobs(sink, [fly], [0, 31, 0], 64, 1).quads).toBeGreaterThan(0);
346 expect(packMobs(sink, [fly], [0, 31, 0], 64, 0).quads).toBe(0);
347 });
348
349 test("a herd of creatures packs fast enough for a frame", () => {
350 const world = ground(30);
351 const sink = new MobSink();
352 const mobs: Mob[] = [];
353 for (let i = 0; i < HERD_CAP; i++) {
354 mobs.push(makeMob(i % KINDS.length, i * 1.5, 31.01, (i % 7) * 2, 1000 + i));
355 }
356 const frames = (n: number): number => {
357 const started = Bun.nanoseconds();
358 for (let frame = 0; frame < n; frame++) {
359 for (const mob of mobs) stepMob(mob, 1 / 60, world, { x: 0, y: 31, z: 0 });
360 packMobs(sink, mobs, [0, 32, 0], 200);
361 }
362 return (Bun.nanoseconds() - started) / 1e6 / n;
363 };
364 frames(60); // warm, the way it is a second into a game
365 // A full herd, stepped and packed, is a small part of a 16 ms frame.
366 // The budget is generous because CI is slower than a laptop.
367 expect(frames(120)).toBeLessThan(3);
368 });
src/world/mobs.ts added +524
@@ -0,0 +1,524 @@
1 /**
2 * The creatures. A kind is a row in a table: the boxes it is made of, how it
3 * gets about, where it is willing to live. A mob is one body of that kind
4 * with a mood, a timer and a little random stream of its own.
5 *
6 * Nothing here is stored. A chunk's creatures are hashed out of the seed the
7 * moment the chunk appears and forgotten when it goes, so a world stays a
8 * seed and a list of changed blocks however long anything walks around in it.
9 * Everything is pure and frame-rate independent, so the browser and the
10 * terminal run the same animals.
11 */
12
13 import { hash3, rand3 } from "./noise.ts";
14 import { CHUNK_X, CHUNK_Y, CHUNK_Z, SEA_LEVEL } from "./chunk.ts";
15 import { moveBox, step, type Body, type Gait } from "./physics.ts";
16
17 export type Color = [number, number, number];
18
19 /** What the world has to be able to answer for a creature to live in it. */
20 export type Ground = {
21 get: (x: number, y: number, z: number) => number;
22 solid: (x: number, y: number, z: number) => boolean;
23 liquid: (x: number, y: number, z: number) => boolean;
24 };
25
26 /** Somebody to be wary of. */
27 export type Watcher = { x: number; y: number; z: number };
28
29 /**
30 * One box of a creature, in model space: -Z is the way it faces, +Y is up,
31 * and the origin is the middle of its feet. `pivot` is the point the part
32 * turns about, `box` is the middle of the box measured from that pivot, and
33 * `half` is how far the box reaches from its own middle. Parts that hang off
34 * the head share the head's pivot, so they turn with it.
35 */
36 export type Part = {
37 pivot: [number, number, number];
38 box: [number, number, number];
39 half: [number, number, number];
40 color: Color;
41 /** How far the walk cycle swings this part, and about which axis. */
42 sway?: number;
43 axis?: "x" | "y";
44 /** How far this part drops when the creature puts its head down. */
45 dip?: number;
46 /** Lit by itself, not by the sun. */
47 glow?: boolean;
48 };
49
50 export type Kind = {
51 name: string;
52 /** Plural, for anything that counts them out loud. */
53 plural: string;
54 /** Where it can live, which decides how it moves. */
55 habitat: "land" | "water" | "air";
56 gait: Gait;
57 /** Radians of leg swing per metre walked. */
58 stride: number;
59 /** A hopper leaves the ground every time it moves. */
60 hops?: boolean;
61 /** It stops to eat, head down. */
62 grazes?: boolean;
63 /** Out only in the dark. */
64 nocturnal?: boolean;
65 parts: Part[];
66 /** How many turn up together, at least and at most. */
67 group: [number, number];
68 /** The chance that a chunk holds a group of them at all. */
69 chance: number;
70 /** What it looks like where there are no pixels. */
71 glyph: string;
72 };
73
74 const WOOL: Color = [232, 228, 218];
75 const WOOL_FACE: Color = [206, 199, 185];
76 const HOOF: Color = [72, 62, 54];
77 const EYE: Color = [28, 26, 30];
78 const PORK: Color = [231, 154, 164];
79 const SNOUT: Color = [212, 116, 127];
80 const FEATHER: Color = [243, 241, 235];
81 const BEAK: Color = [224, 161, 60];
82 const COMB: Color = [197, 71, 75];
83 const SCALE: Color = [224, 138, 60];
84 const FIN: Color = [240, 178, 92];
85 const CHITIN: Color = [46, 44, 32];
86 const LANTERN: Color = [247, 243, 154];
87
88 /** The four legs of a walking animal, at a height and a spread. */
89 const legs = (
90 hip: number, spread: number, reach: number, thick: number, color: Color,
91 ): Part[] =>
92 ([[-1, -1], [1, -1], [-1, 1], [1, 1]] as [number, number][]).map(([sx, sz]) => ({
93 pivot: [sx * spread, hip, sz * reach] as [number, number, number],
94 box: [0, -hip / 2, 0] as [number, number, number],
95 half: [thick, hip / 2, thick] as [number, number, number],
96 color,
97 // Diagonal pairs swing together, the way four legs actually go.
98 sway: sx * sz,
99 axis: "x" as const,
100 }));
101
102 /** Two eyes on the sides of a head, turning with whatever the head does. */
103 const eyes = (
104 pivot: [number, number, number], out: number, up: number, forward: number, size = 0.045,
105 ): Part[] =>
106 [-1, 1].map((side) => ({
107 pivot,
108 box: [side * out, up, forward] as [number, number, number],
109 half: [size, size, size] as [number, number, number],
110 color: EYE,
111 dip: 1,
112 }));
113
114 const SHEEP_HEAD: [number, number, number] = [0, 0.94, -0.4];
115 const PIG_HEAD: [number, number, number] = [0, 0.66, -0.4];
116 const HEN_HEAD: [number, number, number] = [0, 0.5, -0.14];
117
118 export const KINDS: Kind[] = [
119 {
120 name: "sheep",
121 plural: "sheep",
122 habitat: "land",
123 gait: { width: 0.7, height: 1.2, walk: 1.4, sprint: 3.4, jump: 6.6, gravity: 28 },
124 stride: 3.4,
125 grazes: true,
126 group: [2, 4],
127 chance: 0.12,
128 glyph: "s",
129 parts: [
130 { pivot: [0, 0.86, 0], box: [0, 0, 0], half: [0.29, 0.24, 0.42], color: WOOL },
131 { pivot: SHEEP_HEAD, box: [0, 0.04, -0.16], half: [0.19, 0.18, 0.18], color: WOOL_FACE, dip: 1 },
132 ...eyes(SHEEP_HEAD, 0.14, 0.09, -0.3),
133 ...legs(0.6, 0.2, 0.28, 0.08, HOOF),
134 ],
135 },
136 {
137 name: "pig",
138 plural: "pigs",
139 habitat: "land",
140 gait: { width: 0.7, height: 0.95, walk: 1.7, sprint: 3.9, jump: 6.2, gravity: 28 },
141 stride: 3.8,
142 grazes: true,
143 group: [1, 3],
144 chance: 0.1,
145 glyph: "p",
146 parts: [
147 { pivot: [0, 0.62, 0], box: [0, 0, 0], half: [0.26, 0.21, 0.42], color: PORK },
148 { pivot: PIG_HEAD, box: [0, 0.03, -0.14], half: [0.18, 0.17, 0.16], color: PORK, dip: 1 },
149 { pivot: PIG_HEAD, box: [0, -0.02, -0.31], half: [0.09, 0.07, 0.04], color: SNOUT, dip: 1 },
150 ...eyes(PIG_HEAD, 0.13, 0.1, -0.27, 0.04),
151 ...legs(0.4, 0.19, 0.28, 0.075, SNOUT),
152 ],
153 },
154 {
155 name: "chicken",
156 plural: "chickens",
157 habitat: "land",
158 gait: { width: 0.42, height: 0.72, walk: 1.2, sprint: 2.9, jump: 4.2, gravity: 20 },
159 stride: 5.5,
160 hops: true,
161 group: [2, 4],
162 chance: 0.1,
163 glyph: "c",
164 parts: [
165 { pivot: [0, 0.4, 0], box: [0, 0, 0], half: [0.15, 0.16, 0.19], color: FEATHER },
166 { pivot: HEN_HEAD, box: [0, 0.05, -0.04], half: [0.1, 0.1, 0.1], color: FEATHER },
167 { pivot: HEN_HEAD, box: [0, 0.18, 0], half: [0.03, 0.06, 0.05], color: COMB },
168 { pivot: HEN_HEAD, box: [0, 0.03, -0.17], half: [0.04, 0.035, 0.05], color: BEAK },
169 ...eyes(HEN_HEAD, 0.09, 0.08, -0.07, 0.03),
170 // Wings beat about the length of the body, so they open outwards.
171 { pivot: [-0.15, 0.44, 0], box: [-0.03, -0.06, 0], half: [0.03, 0.12, 0.16], color: FEATHER, sway: 1, axis: "y" },
172 { pivot: [0.15, 0.44, 0], box: [0.03, -0.06, 0], half: [0.03, 0.12, 0.16], color: FEATHER, sway: -1, axis: "y" },
173 { pivot: [-0.07, 0.24, 0.02], box: [0, -0.12, 0], half: [0.03, 0.12, 0.03], color: BEAK, sway: 1, axis: "x" },
174 { pivot: [0.07, 0.24, 0.02], box: [0, -0.12, 0], half: [0.03, 0.12, 0.03], color: BEAK, sway: -1, axis: "x" },
175 ],
176 },
177 {
178 name: "fish",
179 plural: "fish",
180 habitat: "water",
181 gait: { width: 0.34, height: 0.28, walk: 1.6, sprint: 3.2, jump: 0, gravity: 28 },
182 stride: 1,
183 group: [3, 6],
184 chance: 0.3,
185 glyph: "~",
186 parts: [
187 { pivot: [0, 0.14, 0], box: [0, 0, -0.04], half: [0.08, 0.12, 0.2], color: SCALE },
188 // The tail is hinged at the back and waves from side to side.
189 { pivot: [0, 0.14, 0.16], box: [0, 0, 0.09], half: [0.02, 0.1, 0.09], color: FIN, sway: 1, axis: "y" },
190 { pivot: [0, 0.14, 0], box: [0, 0.13, 0.02], half: [0.015, 0.05, 0.08], color: FIN },
191 ...eyes([0, 0.14, 0], 0.075, 0.03, -0.16, 0.028),
192 ],
193 },
194 {
195 name: "firefly",
196 plural: "fireflies",
197 habitat: "air",
198 gait: { width: 0.16, height: 0.16, walk: 0.9, sprint: 1.4, jump: 0, gravity: 0 },
199 stride: 1,
200 nocturnal: true,
201 group: [3, 6],
202 chance: 0.3,
203 glyph: "*",
204 parts: [
205 { pivot: [0, 0.08, 0], box: [0, 0, 0], half: [0.04, 0.04, 0.06], color: CHITIN },
206 { pivot: [0, 0.08, 0], box: [0, 0, 0.05], half: [0.05, 0.05, 0.05], color: LANTERN, glow: true },
207 ],
208 },
209 ];
210
211 export const kindNamed = (name: string): number => KINDS.findIndex((k) => k.name === name);
212
213 export type Mood = "idle" | "roam" | "graze" | "flee";
214
215 export type Mob = Body & {
216 kind: number;
217 /** Where it faces. Forward is (-sin yaw, -cos yaw), like the camera's. */
218 yaw: number;
219 mood: Mood;
220 /** Seconds until it makes up its mind again. */
221 timer: number;
222 /** The walk cycle, in radians. */
223 phase: number;
224 /** Where it was born. It does not stray far. */
225 hx: number;
226 hz: number;
227 /** 1 under open sky, less in a cave or under a roof. */
228 light: number;
229 /** Seconds until the light is looked at again. */
230 lightIn: number;
231 /** Its own random stream, so a mob is the same mob every time it is run. */
232 rng: number;
233 };
234
235 /** How far from home a creature will wander before it turns back. */
236 export const LEASH = 16;
237 /** How close the player gets before a creature bolts. */
238 const PERSONAL_SPACE = 2.6;
239 const SHADE = 0.4;
240
241 /** xorshift32, one stream per creature. Never returns the same run twice. */
242 function roll(mob: Mob): number {
243 let s = mob.rng | 0;
244 s ^= s << 13;
245 s ^= s >>> 17;
246 s ^= s << 5;
247 mob.rng = s >>> 0 || 1;
248 return mob.rng / 4294967296;
249 }
250
251 /** Face along a direction in the world. Zero length leaves the yaw alone. */
252 function faceTo(mob: Mob, dx: number, dz: number): void {
253 if (dx === 0 && dz === 0) return;
254 mob.yaw = Math.atan2(-dx, -dz);
255 }
256
257 const forwardX = (yaw: number): number => -Math.sin(yaw);
258 const forwardZ = (yaw: number): number => -Math.cos(yaw);
259
260 export function makeMob(kind: number, x: number, y: number, z: number, seed: number): Mob {
261 const mob: Mob = {
262 kind,
263 x, y, z,
264 vx: 0, vy: 0, vz: 0,
265 onGround: false,
266 inWater: KINDS[kind]!.habitat === "water",
267 yaw: 0,
268 mood: "idle",
269 timer: 0,
270 phase: 0,
271 hx: x, hz: z,
272 light: 1,
273 lightIn: 0,
274 rng: (seed >>> 0) || 1,
275 };
276 mob.yaw = roll(mob) * Math.PI * 2;
277 mob.timer = roll(mob) * 3;
278 return mob;
279 }
280
281 /** The blocks a land creature is willing to stand on. */
282 const FOOTING = new Set([1, 2, 3, 4, 5, 6, 11]);
283
284 /**
285 * Where a creature of this kind could stand in this column, or null when it
286 * could not. Land animals want daylight and dry ground; fish want water deep
287 * enough to turn around in; fireflies want a little air over the grass.
288 */
289 export function footing(kind: Kind, x: number, z: number, ground: Ground): number | null {
290 if (kind.habitat === "water") {
291 if (!ground.liquid(x, SEA_LEVEL, z)) return null;
292 let floor = SEA_LEVEL;
293 while (floor > 1 && ground.liquid(x, floor - 1, z)) floor--;
294 if (SEA_LEVEL - floor < 3) return null;
295 return floor + 1 + Math.floor((SEA_LEVEL - floor) / 2);
296 }
297 let top = CHUNK_Y - 1;
298 while (top > 0 && !ground.solid(x, top, z)) top--;
299 if (top <= SEA_LEVEL) return null;
300 if (!FOOTING.has(ground.get(x, top, z))) return null;
301 const feet = top + 1;
302 const headroom = Math.ceil(kind.gait.height) + (kind.habitat === "air" ? 3 : 0);
303 for (let h = 0; h < headroom; h++) {
304 if (ground.solid(x, feet + h, z) || ground.liquid(x, feet + h, z)) return null;
305 }
306 return kind.habitat === "air" ? feet + 1.5 : feet + 0.01;
307 }
308
309 /**
310 * The creatures a chunk was born with. Deterministic in the seed and the
311 * chunk, so the same corner of the same world is always the same meadow —
312 * until they walk off, which is the point. `room` caps how many are handed
313 * back so a caller can stop at the number it is willing to draw.
314 */
315 export function spawnChunk(
316 cx: number, cz: number, seed: number, ground: Ground, room = 8,
317 ): Mob[] {
318 const mobs: Mob[] = [];
319 for (let k = 0; k < KINDS.length && mobs.length < room; k++) {
320 const kind = KINDS[k]!;
321 if (rand3(cx, cz, 300 + k, seed) > kind.chance) continue;
322 const [least, most] = kind.group;
323 const size = least + Math.floor(rand3(cx, cz, 400 + k, seed) * (most - least + 1));
324 for (let i = 0; i < size && mobs.length < room; i++) {
325 const x = cx * CHUNK_X + rand3(cx + i * 97, cz, 500 + k, seed) * CHUNK_X;
326 const z = cz * CHUNK_Z + rand3(cx, cz + i * 89, 600 + k, seed) * CHUNK_Z;
327 const bx = Math.floor(x), bz = Math.floor(z);
328 const y = footing(kind, bx, bz, ground);
329 if (y === null) continue;
330 mobs.push(makeMob(k, x, y, z, hash3(bx, bz, i * 7 + k, seed)));
331 }
332 }
333 return mobs;
334 }
335
336 /** Make up its mind: wander somewhere, put its head down, or stand still. */
337 function decide(mob: Mob, kind: Kind): void {
338 if (mob.mood === "flee") {
339 mob.mood = "idle";
340 mob.timer = 0.6 + roll(mob) * 1.5;
341 return;
342 }
343 const r = roll(mob);
344 if (r < 0.5 || kind.habitat !== "land") {
345 mob.mood = "roam";
346 mob.yaw += (roll(mob) - 0.5) * Math.PI * 1.1;
347 mob.timer = 1.5 + roll(mob) * 3.5;
348 } else if (r < 0.8 && kind.grazes) {
349 mob.mood = "graze";
350 mob.timer = 2 + roll(mob) * 4;
351 } else {
352 mob.mood = "idle";
353 mob.timer = 1 + roll(mob) * 2.5;
354 }
355 }
356
357 /** Something came too close: run, and keep running for a moment. */
358 export function startle(mob: Mob, from: Watcher): void {
359 mob.mood = "flee";
360 mob.timer = 1.2 + roll(mob) * 2;
361 faceTo(mob, mob.x - from.x, mob.z - from.z);
362 }
363
364 /** Is there open sky over this creature? Cheap enough to ask now and then. */
365 function skyAbove(mob: Mob, ground: Ground): number {
366 const x = Math.floor(mob.x), z = Math.floor(mob.z);
367 for (let y = Math.ceil(mob.y + 1); y < CHUNK_Y; y++) {
368 if (ground.solid(x, y, z)) return SHADE;
369 }
370 return 1;
371 }
372
373 /** One creature, one frame. `player` is whoever it might be shy of. */
374 export function stepMob(
375 mob: Mob, dt: number, ground: Ground, player: Watcher | null = null,
376 ): void {
377 const kind = KINDS[mob.kind]!;
378 mob.timer -= dt;
379 mob.lightIn -= dt;
380 if (mob.lightIn <= 0) {
381 mob.light = skyAbove(mob, ground);
382 mob.lightIn = 0.5;
383 }
384 if (player && kind.habitat !== "water" && mob.mood !== "flee") {
385 const dx = mob.x - player.x, dy = mob.y - player.y, dz = mob.z - player.z;
386 if (dx * dx + dy * dy + dz * dz < PERSONAL_SPACE * PERSONAL_SPACE) startle(mob, player);
387 }
388 if (mob.timer <= 0) decide(mob, kind);
389 if (kind.habitat === "water") swim(mob, kind, dt, ground);
390 else if (kind.habitat === "air") hover(mob, kind, dt, ground);
391 else walk(mob, kind, dt, ground);
392 }
393
394 /** Walking, and looking where it is going: no cliffs, no water, no walls. */
395 function walk(mob: Mob, kind: Kind, dt: number, ground: Ground): void {
396 const moving = mob.mood === "roam" || mob.mood === "flee";
397 let jump = false;
398 if (moving) {
399 const fx = forwardX(mob.yaw), fz = forwardZ(mob.yaw);
400 const ahead = 0.4 + kind.gait.width / 2;
401 const ax = Math.floor(mob.x + fx * ahead), az = Math.floor(mob.z + fz * ahead);
402 const feet = Math.floor(mob.y + 0.05);
403 if (ground.solid(ax, feet, az)) {
404 // A step up is worth a hop; a wall is worth turning away from.
405 if (!ground.solid(ax, feet + 1, az) && !ground.solid(ax, feet + 2, az)) jump = true;
406 else mob.yaw += Math.PI * (0.5 + roll(mob) * 0.5);
407 } else if (
408 ground.liquid(ax, feet, az) ||
409 (!ground.solid(ax, feet - 1, az) && !ground.solid(ax, feet - 2, az))
410 ) {
411 mob.yaw += Math.PI * (0.5 + roll(mob) * 0.5);
412 }
413 if (mob.mood === "roam") {
414 const dx = mob.x - mob.hx, dz = mob.z - mob.hz;
415 if (dx * dx + dz * dz > LEASH * LEASH) faceTo(mob, -dx, -dz);
416 }
417 }
418 const fx = forwardX(mob.yaw), fz = forwardZ(mob.yaw);
419 step(mob, {
420 moveX: moving ? fx : 0,
421 moveZ: moving ? fz : 0,
422 jump: jump || (kind.hops === true && moving && mob.onGround),
423 sprint: mob.mood === "flee",
424 }, dt, ground, kind.gait);
425 // The legs are driven by the ground, not by the clock, so they never skate.
426 mob.phase += Math.hypot(mob.vx, mob.vz) * dt * kind.stride;
427 }
428
429 /** Swimming: stay in the water, stay off the bottom, wave the tail. */
430 function swim(mob: Mob, kind: Kind, dt: number, ground: Ground): void {
431 const here = (dy: number): boolean =>
432 ground.liquid(Math.floor(mob.x), Math.floor(mob.y + dy), Math.floor(mob.z));
433 mob.inWater = here(0.14);
434 if (!mob.inWater) {
435 // Out of its element: it falls, and flaps until it is back in.
436 mob.vy = Math.max(mob.vy - kind.gait.gravity * dt, -20);
437 moveBox(mob, ground, 0, mob.vy * dt, 0, kind.gait);
438 mob.phase += dt * 18;
439 return;
440 }
441 const fx = forwardX(mob.yaw), fz = forwardZ(mob.yaw);
442 const reach = 0.35 + kind.gait.width;
443 const ax = Math.floor(mob.x + fx * reach), az = Math.floor(mob.z + fz * reach);
444 if (!ground.liquid(ax, Math.floor(mob.y + 0.14), az)) {
445 mob.yaw += Math.PI * (0.5 + roll(mob) * 0.5);
446 }
447 let rise = 0;
448 if (!here(0.7)) rise -= 0.7; // the surface is above
449 if (ground.solid(Math.floor(mob.x), Math.floor(mob.y - 0.3), Math.floor(mob.z))) rise += 0.7;
450 const speed = mob.mood === "roam" ? kind.gait.walk : kind.gait.walk * 0.25;
451 mob.vx = forwardX(mob.yaw) * speed;
452 mob.vz = forwardZ(mob.yaw) * speed;
453 mob.vy = rise + Math.sin(mob.phase * 0.5) * 0.2;
454 moveBox(mob, ground, mob.vx * dt, mob.vy * dt, mob.vz * dt, kind.gait);
455 mob.phase += dt * (6 + speed * 3);
456 }
457
458 /** Hovering: drift, bob, and keep a couple of blocks of air underneath. */
459 function hover(mob: Mob, kind: Kind, dt: number, ground: Ground): void {
460 const x = Math.floor(mob.x), z = Math.floor(mob.z);
461 let drop = 0;
462 while (drop < 5 && !ground.solid(x, Math.floor(mob.y) - drop - 1, z)) drop++;
463 const speed = kind.gait.walk * (mob.mood === "roam" ? 1 : 0.3);
464 mob.phase += dt * 2.6;
465 mob.vx = forwardX(mob.yaw) * speed + Math.sin(mob.phase * 1.7) * 0.25;
466 mob.vz = forwardZ(mob.yaw) * speed + Math.cos(mob.phase * 1.3) * 0.25;
467 // Two blocks up is where it wants to be; the sine is its wobble.
468 mob.vy = (drop < 2 ? 0.7 : drop > 3 ? -0.7 : 0) + Math.sin(mob.phase * 2.1) * 0.35;
469 const dx = mob.x - mob.hx, dz = mob.z - mob.hz;
470 if (dx * dx + dz * dz > (LEASH / 2) ** 2) faceTo(mob, -dx, -dz);
471 moveBox(mob, ground, mob.vx * dt, mob.vy * dt, mob.vz * dt, kind.gait);
472 }
473
474 /**
475 * One part of one creature, placed in the world: a box with a middle, a size
476 * and two rotations — the creature's facing, then the part's own swing. This
477 * is what a renderer or a terminal turns into something you can see.
478 */
479 export type Posed = {
480 /** The point the part turns about, in world space. */
481 px: number; py: number; pz: number;
482 /** The middle of the box and its half extents, in the part's own frame. */
483 bx: number; by: number; bz: number;
484 hx: number; hy: number; hz: number;
485 yaw: number;
486 swing: number;
487 axis: "x" | "y";
488 color: Color;
489 glow: boolean;
490 light: number;
491 };
492
493 /** How far a grazing head goes down, in radians. */
494 const GRAZE_ANGLE = 0.85;
495 /** How far a walking limb swings, in radians. */
496 const SWING_ANGLE = 0.7;
497
498 /** Every box of one creature, where it is right now. */
499 export function poseMob(mob: Mob, out: Posed[] = []): Posed[] {
500 const kind = KINDS[mob.kind]!;
501 const sin = Math.sin(mob.yaw), cos = Math.cos(mob.yaw);
502 const wave = Math.sin(mob.phase) * SWING_ANGLE;
503 const graze = mob.mood === "graze" ? GRAZE_ANGLE : 0;
504 out.length = 0;
505 for (const part of kind.parts) {
506 const [lx, ly, lz] = part.pivot;
507 // Model space has -Z forward and +X to the right, the same way the camera
508 // is pointed: right is (cos, -sin) and forward is (-sin, -cos).
509 out.push({
510 px: mob.x + lx * cos + lz * sin,
511 py: mob.y + ly,
512 pz: mob.z - lx * sin + lz * cos,
513 bx: part.box[0], by: part.box[1], bz: part.box[2],
514 hx: part.half[0], hy: part.half[1], hz: part.half[2],
515 yaw: mob.yaw,
516 swing: wave * (part.sway ?? 0) + graze * (part.dip ?? 0),
517 axis: part.axis ?? "x",
518 color: part.color,
519 glow: part.glow === true,
520 light: mob.light,
521 });
522 }
523 return out;
524 }
src/world/physics.ts +74 −29
@@ -1,14 +1,17 @@
11 /**
2 * The player as a box that cannot walk through blocks. Position is the middle
3 * of the feet. Each axis moves and resolves on its own, in steps small enough
2 * A body as a box that cannot walk through blocks. Position is the middle of
3 * the feet. Each axis moves and resolves on its own, in steps small enough
44 * that nothing tunnels through a wall, which is what makes sliding along a
55 * corner feel right instead of catching.
6 *
7 * The player is one such body; so is every sheep. What differs between them
8 * is a `Gait`: how big the box is, how fast it walks and how hard it can
9 * push off the ground.
610 */
711
812 export const PLAYER_WIDTH = 0.6;
913 export const PLAYER_HEIGHT = 1.8;
1014 export const EYE_HEIGHT = 1.62;
11 const HALF = PLAYER_WIDTH / 2;
1215 const EPS = 1e-3;
1316
1417 export const GRAVITY = 28;
@@ -26,6 +29,25 @@ export type Body = {
2629 inWater: boolean;
2730 };
2831
32 /** How big a body is and what it can do with itself. */
33 export type Gait = {
34 width: number;
35 height: number;
36 walk: number;
37 sprint: number;
38 jump: number;
39 gravity: number;
40 };
41
42 export const PLAYER_GAIT: Gait = {
43 width: PLAYER_WIDTH,
44 height: PLAYER_HEIGHT,
45 walk: WALK_SPEED,
46 sprint: SPRINT_SPEED,
47 jump: JUMP_SPEED,
48 gravity: GRAVITY,
49 };
50
2951 export type Intent = {
3052 /** Wanted horizontal direction in world space; length at most 1. */
3153 moveX: number;
@@ -39,10 +61,11 @@ export type World = {
3961 liquid: (x: number, y: number, z: number) => boolean;
4062 };
4163
42 const boxHits = (world: World, x: number, y: number, z: number): boolean => {
43 const x0 = Math.floor(x - HALF + EPS), x1 = Math.floor(x + HALF - EPS);
44 const y0 = Math.floor(y + EPS), y1 = Math.floor(y + PLAYER_HEIGHT - EPS);
45 const z0 = Math.floor(z - HALF + EPS), z1 = Math.floor(z + HALF - EPS);
64 const boxHits = (world: World, x: number, y: number, z: number, gait: Gait): boolean => {
65 const half = gait.width / 2;
66 const x0 = Math.floor(x - half + EPS), x1 = Math.floor(x + half - EPS);
67 const y0 = Math.floor(y + EPS), y1 = Math.floor(y + gait.height - EPS);
68 const z0 = Math.floor(z - half + EPS), z1 = Math.floor(z + half - EPS);
4669 for (let bx = x0; bx <= x1; bx++)
4770 for (let by = y0; by <= y1; by++)
4871 for (let bz = z0; bz <= z1; bz++)
@@ -51,18 +74,20 @@ const boxHits = (world: World, x: number, y: number, z: number): boolean => {
5174 };
5275
5376 /** True when the middle of the body is in a liquid. */
54 const inLiquid = (world: World, b: Body): boolean =>
55 world.liquid(Math.floor(b.x), Math.floor(b.y + PLAYER_HEIGHT * 0.5), Math.floor(b.z));
77 const inLiquid = (world: World, b: Body, gait: Gait): boolean =>
78 world.liquid(Math.floor(b.x), Math.floor(b.y + gait.height * 0.5), Math.floor(b.z));
5679
5780 /** One physics step. `dt` is seconds and is cut into safe slices inside. */
58 export function step(body: Body, intent: Intent, dt: number, world: World): void {
81 export function step(
82 body: Body, intent: Intent, dt: number, world: World, gait: Gait = PLAYER_GAIT,
83 ): void {
5984 const slices = Math.max(1, Math.ceil(dt / 0.02));
60 for (let i = 0; i < slices; i++) substep(body, intent, dt / slices, world);
85 for (let i = 0; i < slices; i++) substep(body, intent, dt / slices, world, gait);
6186 }
6287
63 function substep(body: Body, intent: Intent, dt: number, world: World): void {
64 body.inWater = inLiquid(world, body);
65 const top = (body.inWater ? WALK_SPEED * 0.7 : intent.sprint ? SPRINT_SPEED : WALK_SPEED);
88 function substep(body: Body, intent: Intent, dt: number, world: World, gait: Gait): void {
89 body.inWater = inLiquid(world, body, gait);
90 const top = (body.inWater ? gait.walk * 0.7 : intent.sprint ? gait.sprint : gait.walk);
6691 const accel = (body.onGround || body.inWater ? ACCELERATION : AIR_ACCELERATION) * dt;
6792 const wantX = intent.moveX * top, wantZ = intent.moveZ * top;
6893 body.vx += Math.max(-accel, Math.min(accel, wantX - body.vx));
@@ -74,34 +99,54 @@ function substep(body: Body, intent: Intent, dt: number, world: World): void {
7499 }
75100
76101 if (body.inWater) {
77 body.vy -= GRAVITY * 0.28 * dt;
102 body.vy -= gait.gravity * 0.28 * dt;
78103 body.vy *= WATER_DRAG ** (dt * 60);
79104 if (intent.jump) body.vy = Math.min(body.vy + 30 * dt, 4.2);
80105 } else {
81 body.vy -= GRAVITY * dt;
82 if (intent.jump && body.onGround) body.vy = JUMP_SPEED;
106 body.vy -= gait.gravity * dt;
107 // Pushing off leaves the ground at once. Without this the body is still
108 // standing as far as the next slice is concerned, and a held jump key
109 // reapplies the impulse every slice — which is flight, not jumping.
110 if (intent.jump && body.onGround) {
111 body.vy = gait.jump;
112 body.onGround = false;
113 }
83114 }
84115 body.vy = Math.max(body.vy, -60);
85116
86 moveAxis(body, world, "x", body.vx * dt);
87 moveAxis(body, world, "z", body.vz * dt);
88 moveAxis(body, world, "y", body.vy * dt);
117 moveBox(body, world, body.vx * dt, body.vy * dt, body.vz * dt, gait);
89118 }
90119
91 function moveAxis(body: Body, world: World, axis: "x" | "y" | "z", delta: number): void {
120 /**
121 * Move a body by a step it has already decided on, sliding along whatever it
122 * runs into. Horizontal first, so a body that is falling into a corner still
123 * slides along it rather than sticking.
124 */
125 export function moveBox(
126 body: Body, world: World, dx: number, dy: number, dz: number, gait: Gait = PLAYER_GAIT,
127 ): void {
128 moveAxis(body, world, "x", dx, gait);
129 moveAxis(body, world, "z", dz, gait);
130 moveAxis(body, world, "y", dy, gait);
131 }
132
133 function moveAxis(
134 body: Body, world: World, axis: "x" | "y" | "z", delta: number, gait: Gait,
135 ): void {
92136 if (delta === 0) return;
137 const half = gait.width / 2;
93138 const steps = Math.ceil(Math.abs(delta) / 0.4);
94139 const piece = delta / steps;
95140 for (let i = 0; i < steps; i++) {
96141 const before = body[axis];
97142 body[axis] = before + piece;
98 if (!boxHits(world, body.x, body.y, body.z)) {
143 if (!boxHits(world, body.x, body.y, body.z, gait)) {
99144 if (axis === "y" && piece < 0) body.onGround = false;
100145 continue;
101146 }
102147 if (axis === "y") {
103148 if (piece > 0) {
104 body.y = Math.floor(body.y + PLAYER_HEIGHT) - PLAYER_HEIGHT - EPS;
149 body.y = Math.floor(body.y + gait.height) - gait.height - EPS;
105150 } else {
106151 body.y = Math.floor(body.y) + 1 + EPS;
107152 body.onGround = true;
@@ -109,13 +154,13 @@ function moveAxis(body: Body, world: World, axis: "x" | "y" | "z", delta: number
109154 body.vy = 0;
110155 } else if (axis === "x") {
111156 body.x = piece > 0
112 ? Math.floor(body.x + HALF) - HALF - EPS
113 : Math.floor(body.x - HALF) + 1 + HALF + EPS;
157 ? Math.floor(body.x + half) - half - EPS
158 : Math.floor(body.x - half) + 1 + half + EPS;
114159 body.vx = 0;
115160 } else {
116161 body.z = piece > 0
117 ? Math.floor(body.z + HALF) - HALF - EPS
118 : Math.floor(body.z - HALF) + 1 + HALF + EPS;
162 ? Math.floor(body.z + half) - half - EPS
163 : Math.floor(body.z - half) + 1 + half + EPS;
119164 body.vz = 0;
120165 }
121166 return;
@@ -123,6 +168,6 @@ function moveAxis(body: Body, world: World, axis: "x" | "y" | "z", delta: number
123168 }
124169
125170 /** Does the body stand on something right now? Used after teleporting. */
126 export function settle(body: Body, world: World): void {
127 body.onGround = boxHits(world, body.x, body.y - 0.05, body.z);
171 export function settle(body: Body, world: World, gait: Gait = PLAYER_GAIT): void {
172 body.onGround = boxHits(world, body.x, body.y - 0.05, body.z, gait);
128173 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs