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.
// 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;
}
