SOURCE / PINNED RELEASE
Made of little things.
Pickle Rustlers
- Release
- 039e51f55e34…
- Author-recorded commit
- 40608586a8a1…
- License
- LICENSE
- Author’s source reference
- nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-bad44e7ab35
Archive hash verified: f38e99afa0992052…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// The valley: terrain, mesas, cacti, tumbleweeds and the sunset lighting that acts as the clock.
import * as THREE from 'three';
import { buildCactus, buildMesa, buildRock, buildTumbleweed } from './models.js';
export const ARENA_RADIUS = 92;
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function groundHeight(x: number, z: number): number {
const r = Math.hypot(x, z);
let h = 0.7 * Math.sin(x * 0.045) * Math.cos(z * 0.05) + 0.35 * Math.sin(x * 0.12 + z * 0.09);
// Flatten around the jar, rise into dunes at the edge.
h *= Math.min(1, Math.max(0, (r - 8) / 12));
if (r > ARENA_RADIUS - 6) h += Math.min(9, ((r - (ARENA_RADIUS - 6)) ** 1.3) * 0.22);
return h;
}
export interface Collider {
x: number;
z: number;
r: number;
/** Set on small rocks: their height. The horse jumps these instead of bumping into them. */
jump?: number;
}
/** Rocks up to this radius are small enough to jump. */
export const SMALL_ROCK_RADIUS = 1.6;
interface Tumbleweed {
mesh: THREE.Mesh;
vx: number;
vz: number;
}
// Sky colors from midday (t=0) to sundown (t=1).
const skyStops = [
{ t: 0, sky: 0x8fd3e8, sun: 0xfff1d0, sunI: 2.4, hemi: 1.15 },
{ t: 0.55, sky: 0xf6c177, sun: 0xffc27a, sunI: 2.1, hemi: 1.0 },
{ t: 0.82, sky: 0xe86f51, sun: 0xff8a4c, sunI: 1.6, hemi: 0.85 },
{ t: 1, sky: 0x5b3a74, sun: 0xd46a8a, sunI: 0.9, hemi: 0.7 },
];
export class World {
readonly colliders: Collider[] = [];
private sun: THREE.DirectionalLight;
private hemi: THREE.HemisphereLight;
private sunDisc: THREE.Mesh;
private tumbleweeds: Tumbleweed[] = [];
private skyColor = new THREE.Color();
private dustColor = new THREE.Color(0xd9b382);
/** 0 = clear sky, 1 = full dust storm. Eases toward dustTarget. */
private dust = 0;
dustTarget = 0;
private sunDirection = new THREE.Vector3();
private tmpA = new THREE.Color();
private tmpB = new THREE.Color();
constructor(private scene: THREE.Scene, shadows: boolean) {
const existingObjects = new Set(scene.children);
const rng = mulberry32(1849);
scene.background = new THREE.Color(skyStops[0].sky);
scene.fog = new THREE.Fog(skyStops[0].sky, 80, 175);
this.hemi = new THREE.HemisphereLight(0xfff4e0, 0xb0703c, 1.1);
scene.add(this.hemi);
this.sun = new THREE.DirectionalLight(0xfff1d0, 2.4);
this.sun.castShadow = shadows;
this.sun.shadow.mapSize.set(1024, 1024);
const cam = this.sun.shadow.camera;
cam.left = cam.bottom = -45;
cam.right = cam.top = 45;
cam.near = 1;
cam.far = 200;
this.sun.shadow.bias = -0.002;
scene.add(this.sun, this.sun.target);
this.sunDisc = new THREE.Mesh(
new THREE.IcosahedronGeometry(9, 1),
new THREE.MeshBasicMaterial({ color: 0xfff3c4, fog: false }),
);
scene.add(this.sunDisc);
this.buildTerrain(rng);
// Mesas ring the valley.
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2 + rng() * 0.3;
const d = ARENA_RADIUS + 22 + rng() * 25;
const mesa = buildMesa(rng);
mesa.position.set(Math.cos(a) * d, groundHeight(Math.cos(a) * d, Math.sin(a) * d) - 2, Math.sin(a) * d);
scene.add(mesa);
}
// One mesa inside the arena for cover.
for (const [x, z] of [
[48, -38],
[-55, 30],
] as const) {
const mesa = buildMesa(rng);
mesa.scale.setScalar(0.45);
mesa.position.set(x, groundHeight(x, z) - 0.5, z);
scene.add(mesa);
this.colliders.push({ x, z, r: 7.5 });
}
for (let i = 0; i < 38; i++) {
const p = this.randomSpot(rng, 16, ARENA_RADIUS - 6);
const cactus = buildCactus(rng);
cactus.position.set(p.x, groundHeight(p.x, p.z) - 0.1, p.z);
scene.add(cactus);
this.colliders.push({ x: p.x, z: p.z, r: 0.9 });
}
for (let i = 0; i < 26; i++) {
const p = this.randomSpot(rng, 14, ARENA_RADIUS + 10);
const rock = buildRock(rng);
rock.position.set(p.x, groundHeight(p.x, p.z), p.z);
scene.add(rock);
const s = (rock.geometry as THREE.DodecahedronGeometry).parameters.radius;
const jump = s <= SMALL_ROCK_RADIUS ? s * rock.scale.y : undefined;
if (Math.hypot(p.x, p.z) < ARENA_RADIUS) this.colliders.push({ x: p.x, z: p.z, r: s * 0.9, jump });
}
scene.updateMatrixWorld(true);
for (const child of scene.children) {
if (existingObjects.has(child) || child === this.sun || child === this.sun.target || child === this.hemi || child === this.sunDisc) continue;
child.traverse(object => {
object.matrixAutoUpdate = false;
object.matrixWorldAutoUpdate = false;
});
}
for (let i = 0; i < 7; i++) {
const mesh = buildTumbleweed();
const p = this.randomSpot(rng, 10, ARENA_RADIUS);
mesh.position.set(p.x, 1, p.z);
scene.add(mesh);
this.tumbleweeds.push({ mesh, vx: 3 + rng() * 3, vz: (rng() - 0.5) * 2 });
}
}
private randomSpot(rng: () => number, min: number, max: number): { x: number; z: number } {
for (;;) {
const a = rng() * Math.PI * 2;
const d = min + Math.sqrt(rng()) * (max - min);
const x = Math.cos(a) * d;
const z = Math.sin(a) * d;
if (this.colliders.every((c) => Math.hypot(c.x - x, c.z - z) > c.r + 3)) return { x, z };
}
}
private buildTerrain(rng: () => number): void {
const size = 340;
const seg = 96;
const geo = new THREE.PlaneGeometry(size, size, seg, seg).toNonIndexed();
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position as THREE.BufferAttribute;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
pos.setY(i, groundHeight(x, z));
}
const colors = new Float32Array(pos.count * 3);
const sandA = new THREE.Color(0xe8c48a);
const sandB = new THREE.Color(0xd9a86c);
const clay = new THREE.Color(0xc98b55);
const c = new THREE.Color();
for (let i = 0; i < pos.count; i += 3) {
const x = (pos.getX(i) + pos.getX(i + 1) + pos.getX(i + 2)) / 3;
const z = (pos.getZ(i) + pos.getZ(i + 1) + pos.getZ(i + 2)) / 3;
const r = Math.hypot(x, z);
c.copy(sandA).lerp(sandB, rng() * 0.7);
if (r > ARENA_RADIUS - 4) c.lerp(clay, Math.min(1, (r - ARENA_RADIUS + 4) / 20));
// A trampled trail ring around the jar.
if (r > 9 && r < 12.5) c.lerp(clay, 0.35);
for (let k = 0; k < 3; k++) c.toArray(colors, (i + k) * 3);
}
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.computeVertexNormals();
const ground = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ vertexColors: true }));
ground.receiveShadow = true;
this.scene.add(ground);
}
/** dayProgress 0 (midday) .. 1 (sundown). focus = point the shadow camera follows. */
update(dt: number, dayProgress: number, focus: THREE.Vector3): void {
const t = Math.min(1, Math.max(0, dayProgress));
let i = 0;
while (i < skyStops.length - 2 && t > skyStops[i + 1].t) i++;
const a = skyStops[i];
const b = skyStops[i + 1];
const k = (t - a.t) / (b.t - a.t);
this.skyColor.copy(this.tmpA.setHex(a.sky)).lerp(this.tmpB.setHex(b.sky), k);
this.dust += (this.dustTarget - this.dust) * Math.min(1, dt * 1.5);
if (this.dust > 0.001) this.skyColor.lerp(this.dustColor, this.dust * 0.75);
(this.scene.background as THREE.Color).copy(this.skyColor);
const fog = this.scene.fog as THREE.Fog;
fog.color.copy(this.skyColor);
fog.near = 80 - this.dust * 68;
fog.far = 175 - this.dust * 120;
this.sun.color.copy(this.tmpA.setHex(a.sun)).lerp(this.tmpB.setHex(b.sun), k);
this.sun.intensity = a.sunI + (b.sunI - a.sunI) * k;
this.hemi.intensity = a.hemi + (b.hemi - a.hemi) * k;
// Sun travels from high in the sky down to the western mesas.
const elevation = 1.15 - t * 1.05;
const dir = this.sunDirection.set(-Math.cos(elevation) * 0.8, Math.sin(elevation), -Math.cos(elevation) * 0.6).normalize();
this.sun.position.copy(focus).addScaledVector(dir, 90);
this.sun.target.position.copy(focus);
this.sunDisc.position.copy(focus).addScaledVector(dir, 190);
(this.sunDisc.material as THREE.MeshBasicMaterial).color.copy(this.sun.color).lerp(this.tmpA.setHex(0xffffff), 0.4);
for (const w of this.tumbleweeds) {
w.mesh.position.x += w.vx * dt;
w.mesh.position.z += w.vz * dt;
w.mesh.rotation.z -= w.vx * dt * 1.1;
w.mesh.rotation.x += w.vz * dt * 1.1;
const p = w.mesh.position;
if (Math.hypot(p.x, p.z) > ARENA_RADIUS + 5) {
p.x = -p.x * 0.95;
p.z = -p.z * 0.95;
}
p.y = groundHeight(p.x, p.z) + 0.9 + Math.abs(Math.sin(performance.now() / 300 + w.vz)) * 0.6;
}
}
}
