Back to Pickle Rustlers
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.

src/game/world.ts
// 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;
    }
  }
}