Back to Chase'n Those Narly Waves
SOURCE / PINNED RELEASE

Made of little things.

Chase'n Those Narly Waves

Release
0c7dde9d4921…
Author-recorded commit
88e58516be17…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-836af5a08b9

Archive hash verified: e1acfdbe67c54dd4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/render/scene.ts
// The 3D world: sky, sun, ocean, the surfer, rocks and sharks, and the camera.
// It only *shows* the game. The rules live in game.ts.

import * as THREE from 'three';
import { SHARK_JUMP_TIME, TURN_LIMIT, WAVE_HEIGHT, WAVE_START_BEHIND, type Game, type Obstacle } from '../game.js';
import { MAX_FOAM_SPOTS, Ocean, heightAt, type OceanState } from './ocean.js';
import { makeLeapingShark, makeRock, makeRockGeometry, makeSharkFin, makeSurfer, type SurferModel } from './models.js';

/** Bright tropical afternoon: the sun is high, behind you and to the right. */
const SUN_ELEVATION = 32;
const SUN_AZIMUTH = 55;
const SUN_COLOR = new THREE.Color(1.0, 0.95, 0.85);
// Sky colors (linear light values, not screen colors).
const HORIZON = new THREE.Color(0.34, 0.55, 0.78);
const ZENITH = new THREE.Color(0.02, 0.12, 0.5);

const MAX_SPLASHES = 400;

/** Where the camera sits behind the surfer. */
const CAMERA_HEIGHT = 2.3;
const CAMERA_BACK = 5;

interface ObstacleView {
  root: THREE.Group;
  swimming?: THREE.Object3D;
  leaping?: THREE.Object3D;
}

export interface ScreenPoint {
  x: number;
  y: number;
}

export class World {
  private readonly renderer: THREE.WebGLRenderer;
  private readonly scene = new THREE.Scene();
  private readonly camera = new THREE.PerspectiveCamera(55, 1, 0.1, 20000);
  private readonly ocean: Ocean;
  private readonly sky: THREE.Mesh;
  private readonly surfer: SurferModel;
  private readonly rockGeometries: THREE.BufferGeometry[];
  private readonly views = new Map<Obstacle, ObstacleView>();
  private readonly splashes: THREE.Points;
  private readonly oceanState: OceanState = {
    time: 0,
    scroll: 0,
    swellZ: -100,
    swellHeight: 0,
    playerX: 0,
    wake: 0,
  };
  private width = 1;
  private height = 1;

  constructor(canvas: HTMLCanvasElement) {
    this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
    this.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
    this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
    this.renderer.toneMappingExposure = 0.9;

    const sunDir = new THREE.Vector3().setFromSphericalCoords(
      1,
      THREE.MathUtils.degToRad(90 - SUN_ELEVATION),
      THREE.MathUtils.degToRad(SUN_AZIMUTH),
    );

    // Sky.
    this.sky = makeSky(sunDir, 9000);
    this.scene.add(this.sky);
    this.scene.fog = new THREE.FogExp2(HORIZON, 0.006);

    // Light the 3D models with the sky itself, plus the warm sun.
    const skyOnly = new THREE.Scene();
    skyOnly.add(makeSky(sunDir, 50));
    const pmrem = new THREE.PMREMGenerator(this.renderer);
    this.scene.environment = pmrem.fromScene(skyOnly, 0, 0.1, 100).texture;
    pmrem.dispose();
    this.scene.environmentIntensity = 0.7;
    const sun = new THREE.DirectionalLight(SUN_COLOR, 2.4);
    sun.position.copy(sunDir).multiplyScalar(100);
    this.scene.add(sun, sun.target);

    this.ocean = new Ocean(sunDir, SUN_COLOR, HORIZON, ZENITH);
    this.scene.add(this.ocean.group);

    this.surfer = makeSurfer();
    this.scene.add(this.surfer.root);

    this.rockGeometries = [1, 2, 3, 4, 5].map((seed) => makeRockGeometry(seed * 1.7));

    this.splashes = new THREE.Points(
      new THREE.BufferGeometry().setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(MAX_SPLASHES * 3), 3)),
      new THREE.PointsMaterial({
        size: 0.3,
        map: makeDropTexture(),
        transparent: true,
        depthWrite: false,
        color: 0xf2f6f8,
      }),
    );
    this.splashes.frustumCulled = false;
    this.scene.add(this.splashes);
  }

  resize(width: number, height: number): void {
    this.width = width;
    this.height = height;
    this.renderer.setSize(width, height, false);
    const aspect = width / height;
    this.camera.aspect = aspect;
    // Narrow screens (phones) zoom out so you can still see left and right.
    const wantedSideways = THREE.MathUtils.degToRad(80);
    const fov = aspect >= 1.3 ? 55 : THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(wantedSideways / 2) / aspect));
    this.camera.fov = Math.min(95, Math.max(55, fov));
    this.camera.updateProjectionMatrix();
  }

  render(g: Game): void {
    this.updateOcean(g);
    const waterY = heightAt(this.oceanState, g.x, 0);
    this.updateSurfer(g, waterY);
    this.updateObstacles(g);
    this.updateSplashes(g);
    this.updateCamera(g, waterY);
    this.renderer.render(this.scene, this.camera);
  }

  /** Screen spots for close sharks, so the HUD can show a "!" warning. */
  sharkWarnings(g: Game): ScreenPoint[] {
    const points: ScreenPoint[] = [];
    for (const o of g.obstacles) {
      if (o.kind !== 'shark' || o.done || o.jump !== null || o.z > 20 || o.z < 1) continue;
      const p = new THREE.Vector3(o.x, 1.4, -o.z).project(this.camera);
      if (p.z > 1) continue;
      points.push({ x: (p.x * 0.5 + 0.5) * this.width, y: (-p.y * 0.5 + 0.5) * this.height });
    }
    return points;
  }

  private updateOcean(g: Game): void {
    const s = this.oceanState;
    s.time = g.time;
    s.scroll = g.scroll;
    s.playerX = g.x;
    s.wake = g.fallen > 0 || g.phase === 'title' ? 0.2 : Math.min(1, 0.25 + g.speed / 20);

    const wave = g.wave;
    if (wave) {
      // Grows in far behind you, fades away far ahead of you.
      const fadeIn = THREE.MathUtils.smoothstep(wave.z + WAVE_START_BEHIND, 0, 8);
      const fadeOut = 1 - THREE.MathUtils.smoothstep(wave.z, 20, 35);
      s.swellZ = -wave.z;
      s.swellHeight = wave.size * WAVE_HEIGHT * fadeIn * fadeOut;
    } else {
      s.swellHeight = 0;
    }

    const spots = g.obstacles
      .filter((o) => o.jump === null && o.z < 70 && o.z > -4)
      .sort((a, b) => a.z - b.z)
      .slice(0, MAX_FOAM_SPOTS)
      .map((o) => new THREE.Vector3(o.x, -o.z, o.kind === 'rock' ? o.size * 1.05 : 0.45));
    this.ocean.update(s, this.camera.position.x, spots);
  }

  private updateSurfer(g: Game, waterY: number): void {
    const { root, air, standing, paddling } = this.surfer;
    // Blink while you can't be hurt, but not during a crash, so you can see it.
    root.visible = g.fallen > 0 || !(g.invulnerable > 0 && Math.floor(g.invulnerable * 10) % 2 === 0);

    // Lie down and paddle between waves; pop up to stand when riding or doing tricks.
    const lying = g.riding <= 0 && !g.trick && g.fallen <= 0;
    standing.visible = !lying;
    paddling.visible = lying;
    if (lying) {
      const stroke = g.time * (g.paddle > 0.3 ? 4 : 1.5);
      for (const [name, side] of [['paddleLeft', 0], ['paddleRight', Math.PI]] as const) {
        const arm = paddling.getObjectByName(name);
        if (arm) arm.position.y = 0.07 + Math.sin(stroke + side) * 0.12;
      }
    }

    // Tilt the board with the slope of the water.
    const ahead = heightAt(this.oceanState, g.x, -0.6);
    const behind = heightAt(this.oceanState, g.x, 0.6);
    const pitch = THREE.MathUtils.clamp(Math.atan2(ahead - behind, 1.2) * 0.35, -0.3, 0.3);

    let lift = 0;
    let lean = -g.vx * 0.07;
    let yaw = -g.vx * 0.05;
    const balance = g.turnTime / TURN_LIMIT;
    lean -= g.turnDir * balance * 0.3;
    if (balance > 0.55) lean += Math.sin(g.time * 30) * 0.1 * balance;

    air.rotation.set(0, 0, 0);
    standing.scale.set(1, 1, 1);

    if (g.trick) {
      const t = Math.min(1, g.trick.t / g.trick.trick.duration);
      const arc = Math.sin(Math.PI * t);
      switch (g.trick.trick.move) {
        case 'jump':
          lift = arc * 1.6;
          break;
        case 'spin':
          lift = arc * 1.2;
          air.rotation.y = Math.PI * 2 * t;
          break;
        case 'double-spin':
          lift = arc * 1.5;
          air.rotation.y = Math.PI * 4 * t;
          break;
        case 'flip':
          lift = arc * 1.9;
          air.rotation.x = -Math.PI * 2 * t;
          break;
        case 'big-flip':
          lift = arc * 2.4;
          air.rotation.x = -Math.PI * 4 * t;
          break;
        case 'roll':
          lift = arc * 1.3;
          air.rotation.z = Math.PI * 2 * t;
          break;
        case 'grab':
          lift = arc * 1.4;
          standing.scale.y = 1 - arc * 0.3;
          break;
        case 'wiggle':
          lean += Math.sin(t * Math.PI * 6) * 0.3;
          lift = arc * 0.15;
          break;
        case 'carve-left':
          lean += arc * 0.55;
          yaw += arc * 0.5;
          break;
        case 'carve-right':
          lean -= arc * 0.55;
          yaw -= arc * 0.5;
          break;
      }
    }

    let sink = 0;
    if (g.fallen > 0) {
      const tip = Math.min(1, (1.2 - g.fallen) / 0.25);
      lean = -g.fallDir * 1.45 * tip;
      sink = 0.1 * tip; // float on top so you can see the crash
      standing.visible = true;
      paddling.visible = false;
    }

    root.position.set(g.x, waterY + lift - sink, 0);
    root.rotation.set(pitch, yaw, lean, 'YXZ');
  }

  private updateObstacles(g: Game): void {
    const seen = new Set<Obstacle>();
    for (const o of g.obstacles) {
      seen.add(o);
      let view = this.views.get(o);
      if (!view) {
        view = this.makeView(o);
        this.views.set(o, view);
        this.scene.add(view.root);
      }

      if (o.kind === 'rock') {
        // Rocks sit still on the sea floor; waves wash right over them.
        view.root.position.set(o.x, -0.25 * o.size, -o.z);
        continue;
      }

      const water = heightAt(this.oceanState, o.x, -o.z);
      if (o.jump === null) {
        // Fin cutting through the water toward you.
        if (view.swimming) view.swimming.visible = true;
        if (view.leaping) view.leaping.visible = false;
        view.root.position.set(o.x, water - 0.08, -o.z);
        view.root.rotation.set(0, Math.PI + Math.sin(g.time * 3 + o.seed) * 0.15, 0);
      } else {
        // The big leap: up out of the water, over your head, and back in.
        if (!view.leaping) {
          view.leaping = makeLeapingShark();
          view.leaping.scale.setScalar(1.15);
          view.root.add(view.leaping);
        }
        if (view.swimming) view.swimming.visible = false;
        view.leaping.visible = true;
        const t = o.jump / SHARK_JUMP_TIME;
        view.root.position.set(o.x, water - 0.4 + Math.sin(Math.PI * t) * 3.2, -o.z);
        view.root.rotation.set(0.9 - 1.8 * t, o.jumpDir > 0 ? -Math.PI / 2 : Math.PI / 2, 0, 'YXZ');
      }
    }

    // Remove things that have gone past.
    for (const [o, view] of this.views) {
      if (seen.has(o)) continue;
      this.scene.remove(view.root);
      this.views.delete(o);
    }
  }

  private makeView(o: Obstacle): ObstacleView {
    const root = new THREE.Group();
    if (o.kind === 'rock') {
      const geometry = this.rockGeometries[Math.floor(o.seed) % this.rockGeometries.length]!;
      const rock = makeRock(geometry);
      rock.scale.set(o.size * 0.85, o.size * 0.9, o.size * 0.8);
      rock.rotation.y = o.seed;
      root.add(rock);
      return { root };
    }
    const swimming = makeSharkFin();
    root.add(swimming);
    return { root, swimming };
  }

  private updateSplashes(g: Game): void {
    const positions = this.splashes.geometry.attributes.position as THREE.BufferAttribute;
    const count = Math.min(MAX_SPLASHES, g.splashes.length);
    for (let i = 0; i < count; i++) {
      const s = g.splashes[i]!;
      const water = heightAt(this.oceanState, s.x, -s.z);
      positions.setXYZ(i, s.x, water + s.height * 0.45, -s.z);
    }
    positions.needsUpdate = true;
    this.splashes.geometry.setDrawRange(0, count);
  }

  private updateCamera(g: Game, waterY: number): void {
    const cam = this.camera;
    // Follow the surfer sideways with a little lag, so you can see yourself steer.
    cam.position.x += (g.x - cam.position.x) * 0.06;
    // Ride up with the surfer, and always stay above a big wave rolling under the camera.
    // Check the water between the camera and the surfer, so a wave never blocks the view.
    let highestWater = -Infinity;
    for (const z of [0.6, 1.5, 3, CAMERA_BACK]) highestWater = Math.max(highestWater, heightAt(this.oceanState, cam.position.x, z));
    const targetY = Math.max(CAMERA_HEIGHT + waterY * 0.6, highestWater + 1.6);
    cam.position.y += (targetY - cam.position.y) * 0.2;
    cam.position.z = CAMERA_BACK;
    // When the camera rises over a wave, tilt down more so the surfer stays in view.
    const extraRise = cam.position.y - (CAMERA_HEIGHT + waterY * 0.6);
    // Look up to follow big trick jumps, so the surfer never flies off the top of the screen.
    const jump = Math.max(0, this.surfer.root.position.y - waterY);
    const lookX = cam.position.x + (g.x - cam.position.x) * 0.5;
    cam.lookAt(lookX, 0.7 + waterY * 0.5 - extraRise * 2 + jump * 1.6, -10);
    // The sky moves with you, so you can never reach its edge.
    this.sky.position.copy(cam.position);
  }
}

/** A big dome around the world: blue sky fading to pale at the horizon, with soft clouds. */
function makeSky(sunDir: THREE.Vector3, size: number): THREE.Mesh {
  const material = new THREE.ShaderMaterial({
    side: THREE.BackSide,
    depthWrite: false,
    toneMapped: false,
    fog: false,
    uniforms: {
      uSunDir: { value: sunDir.clone() },
      uHorizon: { value: HORIZON.clone() },
      uZenith: { value: ZENITH.clone() },
    },
    vertexShader: /* glsl */ `
      varying vec3 vDir;
      void main() {
        vDir = normalize((modelMatrix * vec4(position, 0.0)).xyz);
        vec4 p = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
        gl_Position = p.xyww;
      }
    `,
    fragmentShader: /* glsl */ `
      uniform vec3 uSunDir;
      uniform vec3 uHorizon;
      uniform vec3 uZenith;
      varying vec3 vDir;

      float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
      float noise(vec2 p) {
        vec2 i = floor(p), f = fract(p);
        f = f * f * (3.0 - 2.0 * f);
        return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
      }
      float fbm(vec2 p) {
        float v = 0.0, a = 0.5;
        for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.1; a *= 0.5; }
        return v;
      }

      void main() {
        vec3 dir = normalize(vDir);
        float up = max(dir.y, 0.0);
        vec3 col = mix(uHorizon, uZenith, pow(up, 0.55));
        // Warm glow around the sun.
        float sun = max(dot(dir, uSunDir), 0.0);
        col += vec3(1.0, 0.9, 0.7) * (pow(sun, 12.0) * 0.25 + pow(sun, 900.0) * 6.0);
        // Fluffy clouds, flatter and thinner toward the horizon.
        if (dir.y > 0.01) {
          vec2 uv = dir.xz / (dir.y + 0.12) * 1.4;
          float c = fbm(uv + vec2(3.0, 1.0));
          float clouds = smoothstep(0.52, 0.78, c) * smoothstep(0.0, 0.12, dir.y);
          vec3 cloudColor = mix(vec3(0.62, 0.66, 0.72), vec3(0.95, 0.95, 0.95), smoothstep(0.55, 0.85, c));
          col = mix(col, cloudColor, clouds * 0.9);
        }
        gl_FragColor = vec4(col, 1.0);
        #include <colorspace_fragment>
      }
    `,
  });
  const dome = new THREE.Mesh(new THREE.SphereGeometry(1, 48, 24), material);
  dome.scale.setScalar(size);
  dome.frustumCulled = false;
  return dome;
}

/** A soft round dot for water droplets. */
function makeDropTexture(): THREE.Texture {
  const canvas = document.createElement('canvas');
  canvas.width = canvas.height = 64;
  const ctx = canvas.getContext('2d')!;
  const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
  gradient.addColorStop(0, 'rgba(255,255,255,1)');
  gradient.addColorStop(0.45, 'rgba(255,255,255,0.7)');
  gradient.addColorStop(1, 'rgba(255,255,255,0)');
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, 64, 64);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return texture;
}