Public: anyone can read this; the crew can change it.
mobs. make the world come alive #6
Check · Review · Land · Live done — Landed and live in production.
vesper/voxel:vesper/5-mobs-make-the-world-come-alive → main 3 commits fork
11 files
changed +1548
−52
README.md +25 −5
| @@ -23,6 +23,11 @@ far you can see, from 64 to 192 metres. | ||
| 23 | 23 | The sun crosses the sky every twenty minutes; caves and the undersides of |
| 24 | 24 | things stay dark through the day, and the stars come out at night. |
| 25 | 25 | |
| 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 | ||
| 26 | 31 | ## How it is put together |
| 27 | 32 | |
| 28 | 33 | - `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. | ||
| 30 | 35 | trees), `chunk.ts`, `store.ts` (chunks plus the blocks people changed), |
| 31 | 36 | `mesher.ts` (faces, ambient occlusion, a daylight bit), `physics.ts`, |
| 32 | 37 | `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. | |
| 33 | 41 | - `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. | |
| 36 | 45 | - `src/server.ts` routes and holds the security floor — every response |
| 37 | 46 | carries it, including the 404, the 403, the 413, the 429 and the 500, and |
| 38 | 47 | 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. | ||
| 41 | 50 | - `src/bundle.ts` builds the two client bundles at boot and serves them from |
| 42 | 51 | memory at a name that is a hash of their contents, cached for a year. |
| 43 | 52 | |
| 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 | |
| 46 | 58 | storage stays small no matter how far you walk. There is no multiplayer: two |
| 47 | 59 | people in one world will not see each other, though they do change the same |
| 48 | 60 | blocks. Chunks are 16 x 64 x 16 and the world is 64 blocks tall. |
| 49 | 61 | |
| 50 | 62 | ## What it keeps |
| 51 | 63 | |
| 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 | ||
| 52 | 70 | A world is a name and a seed. Only the blocks you change are stored, in |
| 53 | 71 | SQLite under `FORGE_APP_DATA`. Where you are standing is kept per browser |
| 54 | 72 | 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 | ||
| 86 | 104 | a real pty over the same worlds: `j`/`k` and `enter` to open one, `n` to make |
| 87 | 105 | one, `d` to delete one — typing its name to confirm — then the same ground |
| 88 | 106 | 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. | |
| 90 | 110 | |
| 91 | 111 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` |
| 92 | 112 | (`production` or `preview`), `FORGE_APP_URL` and `FORGE_APP_COMMIT` |
src/client/main.ts +58 −6
| @@ -6,8 +6,10 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { ChunkField } from "./chunks.ts"; |
| 8 | 8 | import { Input } from "./input.ts"; |
| 9 | import { MobSink, packMobs } from "./mobmesh.ts"; | |
| 9 | 10 | import { Renderer } from "./renderer.ts"; |
| 10 | 11 | import { Save } from "./save.ts"; |
| 12 | import { Herd } from "../world/herd.ts"; | |
| 11 | 13 | import { identity, forward, lookRotation, multiply, perspective } from "../math/mat4.ts"; |
| 12 | 14 | import { DAY_SECONDS, daylight, timeName } from "../world/daylight.ts"; |
| 13 | 15 | import { EYE_HEIGHT, PLAYER_HEIGHT, PLAYER_WIDTH, settle, step, type Body } from "../world/physics.ts"; |
| @@ -52,8 +54,15 @@ async function boot(): Promise<void> { | ||
| 52 | 54 | const stored = Number(localStorage.getItem("voxel.distance")); |
| 53 | 55 | const radius = stored >= 4 && stored <= 12 ? stored : 7; |
| 54 | 56 | 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(); | |
| 57 | 66 | field.open(state.seed, state.edits); |
| 58 | 67 | |
| 59 | 68 | const save = new Save(world); |
| @@ -72,7 +81,10 @@ async function boot(): Promise<void> { | ||
| 72 | 81 | const value = Number(hud.distance.value); |
| 73 | 82 | localStorage.setItem("voxel.distance", String(value)); |
| 74 | 83 | 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 | } | |
| 76 | 88 | }); |
| 77 | 89 | |
| 78 | 90 | const body: Body = { |
| @@ -109,8 +121,23 @@ async function boot(): Promise<void> { | ||
| 109 | 121 | && block[2] + 1 > body.z - half && block[2] < body.z + half; |
| 110 | 122 | }; |
| 111 | 123 | |
| 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; | |
| 112 | 129 | function frame(now: number): void { |
| 130 | if (stopped) return; | |
| 113 | 131 | 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 { | |
| 114 | 141 | const elapsed = now - last; |
| 115 | 142 | last = now; |
| 116 | 143 | const dt = Math.min(elapsed / 1000, 0.1); |
| @@ -135,7 +162,10 @@ async function boot(): Promise<void> { | ||
| 135 | 162 | body.y = spawnHeight(Math.round(body.x), Math.round(body.z), state.seed); |
| 136 | 163 | body.vy = 0; |
| 137 | 164 | } |
| 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 | } | |
| 139 | 169 | hud.loading.hidden = true; |
| 140 | 170 | } |
| 141 | 171 | |
| @@ -143,7 +173,20 @@ async function boot(): Promise<void> { | ||
| 143 | 173 | const dir = forward(controls.yaw, controls.pitch); |
| 144 | 174 | const hit = raycast(eye[0], eye[1], eye[2], dir[0], dir[1], dir[2], 6, field.targets); |
| 145 | 175 | |
| 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 | ||
| 146 | 180 | 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 | } | |
| 147 | 190 | if (standing && hit) { |
| 148 | 191 | for (let i = 0; i < actions.breaks; i++) { |
| 149 | 192 | 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> { | ||
| 160 | 203 | } |
| 161 | 204 | } |
| 162 | 205 | |
| 163 | const light = daylight(time); | |
| 164 | 206 | const aspect = canvas.width / Math.max(1, canvas.height); |
| 165 | 207 | const fogFar = field.radius * 16 - 12; |
| 166 | 208 | perspective(projection, (70 * Math.PI) / 180, aspect, 0.08, Math.max(220, fogFar + 80)); |
| @@ -175,6 +217,7 @@ async function boot(): Promise<void> { | ||
| 175 | 217 | fogFar, |
| 176 | 218 | underwater: isLiquid(field.get(Math.floor(eye[0]), Math.floor(eye[1]), Math.floor(eye[2]))), |
| 177 | 219 | highlight: hit ? [hit.x, hit.y, hit.z] : null, |
| 220 | mobs: packMobs(mobSink, herd.mobs, eye, fogFar + 24, night), | |
| 178 | 221 | }); |
| 179 | 222 | |
| 180 | 223 | frames++; |
| @@ -185,6 +228,7 @@ async function boot(): Promise<void> { | ||
| 185 | 228 | hud.stats.textContent = |
| 186 | 229 | `${Math.round(1000 / Math.max(ms, 0.1))} fps · ${ms.toFixed(1)} ms · ` + |
| 187 | 230 | `${stats.chunks} chunks · ${(stats.quads / 1000).toFixed(0)}k faces · ` + |
| 231 | `${herd.awake(night).length} creatures · ` + | |
| 188 | 232 | `${Math.round(body.x)}, ${Math.round(body.y)}, ${Math.round(body.z)} · ${timeName(time)}` + |
| 189 | 233 | (save.trouble ? " · not saving" : ""); |
| 190 | 234 | frames = 0; |
| @@ -214,4 +258,12 @@ function cornerRays(out: Float32Array, yaw: number, pitch: number, aspect: numbe | ||
| 214 | 258 | }); |
| 215 | 259 | } |
| 216 | 260 | |
| 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 @@ | ||
| 9 | 9 | import { compile, describe, uniforms } from "./gl.ts"; |
| 10 | 10 | import { uploadTiles } from "./textures.ts"; |
| 11 | 11 | 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, | |
| 13 | 14 | } from "./shaders.ts"; |
| 15 | import { MOB_VERTEX_BYTES, type MobMesh } from "./mobmesh.ts"; | |
| 14 | 16 | import { VERTEX_BYTES, type Mesh } from "../world/mesher.ts"; |
| 15 | 17 | import { CHUNK_X, CHUNK_Y, CHUNK_Z } from "../world/chunk.ts"; |
| 16 | 18 | import { boxInFrustum, frustumPlanes, type Mat4 } from "../math/mat4.ts"; |
| @@ -27,6 +29,8 @@ export type Scene = { | ||
| 27 | 29 | fogFar: number; |
| 28 | 30 | underwater: boolean; |
| 29 | 31 | highlight: [number, number, number] | null; |
| 32 | /** Every creature in one buffer, already packed by the page. */ | |
| 33 | mobs: MobMesh | null; | |
| 30 | 34 | }; |
| 31 | 35 | |
| 32 | 36 | export type Stats = { chunks: number; quads: number }; |
| @@ -52,6 +56,10 @@ export class Renderer { | ||
| 52 | 56 | private outline: WebGLProgram; |
| 53 | 57 | private outlineU: Record<string, WebGLUniformLocation>; |
| 54 | 58 | private outlineVao: WebGLVertexArrayObject; |
| 59 | private mobs: WebGLProgram; | |
| 60 | private mobsU: Record<string, WebGLUniformLocation>; | |
| 61 | private mobVao: WebGLVertexArrayObject; | |
| 62 | private mobBuffer: WebGLBuffer; | |
| 55 | 63 | private emptyVao: WebGLVertexArrayObject; |
| 56 | 64 | private indices: WebGLBuffer; |
| 57 | 65 | private indexQuads = 0; |
| @@ -77,6 +85,23 @@ export class Renderer { | ||
| 77 | 85 | describe(gl, this.outline, edges, 12, [{ name: "aPos", size: 3, type: gl.FLOAT, offset: 0 }]); |
| 78 | 86 | gl.bindVertexArray(null); |
| 79 | 87 | |
| 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 | ||
| 80 | 105 | gl.enable(gl.DEPTH_TEST); |
| 81 | 106 | gl.enable(gl.CULL_FACE); |
| 82 | 107 | gl.cullFace(gl.BACK); |
| @@ -152,6 +177,31 @@ export class Renderer { | ||
| 152 | 177 | return this.meshes.has(key); |
| 153 | 178 | } |
| 154 | 179 | |
| 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 | ||
| 155 | 205 | draw(scene: Scene): Stats { |
| 156 | 206 | const gl = this.gl; |
| 157 | 207 | const { light } = scene; |
| @@ -205,6 +255,10 @@ export class Renderer { | ||
| 205 | 255 | quads += mesh.opaque.quads; |
| 206 | 256 | } |
| 207 | 257 | |
| 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 | ||
| 208 | 262 | gl.enable(gl.BLEND); |
| 209 | 263 | gl.depthMask(false); |
| 210 | 264 | gl.disable(gl.CULL_FACE); |
src/client/shaders.ts +51
| @@ -102,6 +102,57 @@ void main() { | ||
| 102 | 102 | fragColor = vec4(mix(sky, vec3(0.09, 0.26, 0.44), uUnderwater * 0.85), 1.0); |
| 103 | 103 | }`; |
| 104 | 104 | |
| 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 | ||
| 105 | 156 | export const OUTLINE_VERTEX = `#version 300 es |
| 106 | 157 | precision highp float; |
| 107 | 158 | in vec3 aPos; |
src/pages/home.ts +4
| @@ -100,6 +100,10 @@ export function homePage( | ||
| 100 | 100 | Click to look around; left click breaks a block, right click places the one in your hand, |
| 101 | 101 | <code>1</code>–<code>9</code> and the scroll wheel choose it. On a phone: left thumb walks, |
| 102 | 102 | 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> | |
| 103 | 107 | <p>Everything you change is saved to the world's name, so you can come back to it.</p> |
| 104 | 108 | <p>${ssh |
| 105 | 109 | ? `The same worlds in a terminal: <code>${escape(ssh)}</code>` |
src/tty.ts +95 −11
| @@ -7,15 +7,18 @@ | ||
| 7 | 7 | * to own a world with, so it takes the confirmation the web page takes from |
| 8 | 8 | * anyone else. Inside a world you get the same ground the browser draws, seen |
| 9 | 9 | * 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. | |
| 11 | 12 | */ |
| 12 | 13 | |
| 13 | 14 | import { Store, open } from "./db.ts"; |
| 14 | 15 | 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"; | |
| 16 | 17 | import { HOTBAR, blockName } from "./world/blocks.ts"; |
| 17 | 18 | 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"; | |
| 19 | 22 | import { seedFrom, worldName } from "./valid.ts"; |
| 20 | 23 | |
| 21 | 24 | const store = new Store(open()); |
| @@ -36,6 +39,20 @@ const COLOR: Record<string, number> = { | ||
| 36 | 39 | const paint = (id: number, glyph: string): string => |
| 37 | 40 | `${esc}38;5;${COLOR[blockName(id)] ?? 240}m${glyph}${esc}0m`; |
| 38 | 41 | |
| 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 | ||
| 39 | 56 | type Screen = { kind: "list" } | { kind: "map"; name: string }; |
| 40 | 57 | let screen: Screen = { kind: "list" }; |
| 41 | 58 | let cursor = 0; |
| @@ -47,6 +64,9 @@ let worldId = 0; | ||
| 47 | 64 | let at = { x: 0, z: 0 }; |
| 48 | 65 | let slot = 0; |
| 49 | 66 | 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; | |
| 50 | 70 | |
| 51 | 71 | const size = (): [number, number] => [process.stdout.columns ?? 80, process.stdout.rows ?? 24]; |
| 52 | 72 | |
| @@ -71,24 +91,62 @@ function listScreen(): string[] { | ||
| 71 | 91 | return lines; |
| 72 | 92 | } |
| 73 | 93 | |
| 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 | ||
| 74 | 112 | /** The top of each column, straight down, the way the sun sees it. */ |
| 75 | 113 | function mapScreen(name: string): string[] { |
| 76 | 114 | const [columns, rows] = size(); |
| 77 | 115 | 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); | |
| 80 | 117 | 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 | } | |
| 81 | 131 | for (let row = 0; row < height; row++) { |
| 82 | 132 | let line = ""; |
| 83 | 133 | for (let column = 0; column < width; column++) { |
| 84 | 134 | const x = at.x + column - half.x; |
| 85 | 135 | 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 | } | |
| 86 | 146 | let y = CHUNK_Y - 1; |
| 87 | 147 | while (y > 0 && world!.get(x, y, z) === 0) y--; |
| 88 | 148 | 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 ? "█" : "▒"); | |
| 92 | 150 | } |
| 93 | 151 | lines.push(line); |
| 94 | 152 | } |
| @@ -99,14 +157,23 @@ function mapScreen(name: string): string[] { | ||
| 99 | 157 | `${bold(`${at.x}, ${at.z}`)} ${dim(`· top is ${blockName(world!.get(at.x, y, at.z))} at y ${y}`)}` + |
| 100 | 158 | ` ${dim("holding")} ${bold(blockName(HOTBAR[slot]!))}`, |
| 101 | 159 | ); |
| 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(", ")}`)); | |
| 102 | 164 | lines.push(dim("h/j/k/l or arrows move · x breaks the top · 1-9 places · q back")); |
| 103 | 165 | if (says) lines.push(dim(says)); |
| 104 | 166 | return lines; |
| 105 | 167 | } |
| 106 | 168 | |
| 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 | */ | |
| 107 | 174 | function draw(): void { |
| 108 | 175 | 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`); | |
| 110 | 177 | } |
| 111 | 178 | |
| 112 | 179 | function openWorld(name: string): void { |
| @@ -117,7 +184,9 @@ function openWorld(name: string): void { | ||
| 117 | 184 | const spawn = findSpawn(row.seed); |
| 118 | 185 | at = { x: Math.round(spawn.x), z: Math.round(spawn.z) }; |
| 119 | 186 | 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`; | |
| 121 | 190 | } |
| 122 | 191 | |
| 123 | 192 | /** 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 { | ||
| 130 | 199 | process.stdin.setRawMode?.(true); |
| 131 | 200 | process.stdin.resume(); |
| 132 | 201 | process.stdin.setEncoding("utf8"); |
| 202 | out(`${esc}2J`); | |
| 133 | 203 | draw(); |
| 134 | 204 | |
| 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 | ||
| 135 | 211 | process.stdin.on("data", (key: string) => { |
| 136 | 212 | if (prompt !== undefined) { |
| 137 | 213 | if (key === "\r") { |
| @@ -170,7 +246,15 @@ process.stdin.on("data", (key: string) => { | ||
| 170 | 246 | } |
| 171 | 247 | |
| 172 | 248 | 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 | } | |
| 174 | 258 | if (key === "h" || key === "\x1b[D") at.x -= step; |
| 175 | 259 | if (key === "l" || key === "\x1b[C") at.x += step; |
| 176 | 260 | 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 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 4 | 4 | * that nothing tunnels through a wall, which is what makes sliding along a |
| 5 | 5 | * 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. | |
| 6 | 10 | */ |
| 7 | 11 | |
| 8 | 12 | export const PLAYER_WIDTH = 0.6; |
| 9 | 13 | export const PLAYER_HEIGHT = 1.8; |
| 10 | 14 | export const EYE_HEIGHT = 1.62; |
| 11 | const HALF = PLAYER_WIDTH / 2; | |
| 12 | 15 | const EPS = 1e-3; |
| 13 | 16 | |
| 14 | 17 | export const GRAVITY = 28; |
| @@ -26,6 +29,25 @@ export type Body = { | ||
| 26 | 29 | inWater: boolean; |
| 27 | 30 | }; |
| 28 | 31 | |
| 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 | ||
| 29 | 51 | export type Intent = { |
| 30 | 52 | /** Wanted horizontal direction in world space; length at most 1. */ |
| 31 | 53 | moveX: number; |
| @@ -39,10 +61,11 @@ export type World = { | ||
| 39 | 61 | liquid: (x: number, y: number, z: number) => boolean; |
| 40 | 62 | }; |
| 41 | 63 | |
| 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); | |
| 46 | 69 | for (let bx = x0; bx <= x1; bx++) |
| 47 | 70 | for (let by = y0; by <= y1; by++) |
| 48 | 71 | for (let bz = z0; bz <= z1; bz++) |
| @@ -51,18 +74,20 @@ const boxHits = (world: World, x: number, y: number, z: number): boolean => { | ||
| 51 | 74 | }; |
| 52 | 75 | |
| 53 | 76 | /** 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)); | |
| 56 | 79 | |
| 57 | 80 | /** 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 { | |
| 59 | 84 | 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); | |
| 61 | 86 | } |
| 62 | 87 | |
| 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); | |
| 66 | 91 | const accel = (body.onGround || body.inWater ? ACCELERATION : AIR_ACCELERATION) * dt; |
| 67 | 92 | const wantX = intent.moveX * top, wantZ = intent.moveZ * top; |
| 68 | 93 | 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 { | ||
| 74 | 99 | } |
| 75 | 100 | |
| 76 | 101 | if (body.inWater) { |
| 77 | body.vy -= GRAVITY * 0.28 * dt; | |
| 102 | body.vy -= gait.gravity * 0.28 * dt; | |
| 78 | 103 | body.vy *= WATER_DRAG ** (dt * 60); |
| 79 | 104 | if (intent.jump) body.vy = Math.min(body.vy + 30 * dt, 4.2); |
| 80 | 105 | } 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 | } | |
| 83 | 114 | } |
| 84 | 115 | body.vy = Math.max(body.vy, -60); |
| 85 | 116 | |
| 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); | |
| 89 | 118 | } |
| 90 | 119 | |
| 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 { | |
| 92 | 136 | if (delta === 0) return; |
| 137 | const half = gait.width / 2; | |
| 93 | 138 | const steps = Math.ceil(Math.abs(delta) / 0.4); |
| 94 | 139 | const piece = delta / steps; |
| 95 | 140 | for (let i = 0; i < steps; i++) { |
| 96 | 141 | const before = body[axis]; |
| 97 | 142 | 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)) { | |
| 99 | 144 | if (axis === "y" && piece < 0) body.onGround = false; |
| 100 | 145 | continue; |
| 101 | 146 | } |
| 102 | 147 | if (axis === "y") { |
| 103 | 148 | 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; | |
| 105 | 150 | } else { |
| 106 | 151 | body.y = Math.floor(body.y) + 1 + EPS; |
| 107 | 152 | body.onGround = true; |
| @@ -109,13 +154,13 @@ function moveAxis(body: Body, world: World, axis: "x" | "y" | "z", delta: number | ||
| 109 | 154 | body.vy = 0; |
| 110 | 155 | } else if (axis === "x") { |
| 111 | 156 | 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; | |
| 114 | 159 | body.vx = 0; |
| 115 | 160 | } else { |
| 116 | 161 | 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; | |
| 119 | 164 | body.vz = 0; |
| 120 | 165 | } |
| 121 | 166 | return; |
| @@ -123,6 +168,6 @@ function moveAxis(body: Body, world: World, axis: "x" | "y" | "z", delta: number | ||
| 123 | 168 | } |
| 124 | 169 | |
| 125 | 170 | /** 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); | |
| 128 | 173 | } |
new above
offline