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.
// All the game rules: moving, catching waves, tricks, rocks, sharks and score.
// Nothing in here draws anything. Drawing lives in render.ts.
import type { Arrow, Input } from './input.js';
import { LONGEST_COMBO, TRICKS_LONGEST_FIRST, randomCheer, type Trick } from './tricks.js';
export type Phase = 'title' | 'playing' | 'paused' | 'over';
/** No walls! Rocks and sharks appear this far to each side of wherever you are. */
export const SPAWN_WIDTH = 9;
/** Where new things appear, far out at sea. */
export const FAR = 60;
/**
* Catching waves is all about timing! Tap W while the wave is in the green zone
* (this many meters away). Tap in the gold zone for a PERFECT catch.
*/
export const CATCH_ZONE = { far: 6, near: 0.3 };
export const PERFECT_ZONE = { far: 3, near: 1 };
/** The timing bar shows the wave from this far away. Tapping before the green zone is "too early". */
export const TIMING_RANGE = 15;
/** How many hearts you start with. */
export const START_HEARTS = 5;
/** How long one wave ride lasts (seconds). */
export const RIDE_TIME = 3;
const MAX_RIDE_TIME = 5;
const COMBO_WINDOW = 0.9; // seconds you have between each arrow press
/** Turn the same way longer than this (seconds) and you fall over! */
export const TURN_LIMIT = 1.5;
const FALL_TIME = 1.2;
export interface Obstacle {
kind: 'rock' | 'shark';
x: number;
z: number;
size: number;
seed: number;
done: boolean;
/** Seconds into a shark's big jump out of the water (null = swimming). */
jump: number | null;
/** Which way the shark leaps: -1 = left, 1 = right. */
jumpDir: number;
/** The last wave that crashed over this rock (so it only splashes once per wave). */
washedBy?: Wave;
}
/** How long a shark's leap out of the water lasts (seconds). */
export const SHARK_JUMP_TIME = 1.1;
export interface Popup {
text: string;
sub: string;
age: number;
life: number;
color: string;
slot: number;
big: boolean;
}
export interface Splash {
x: number;
z: number;
height: number;
vx: number;
vHeight: number;
life: number;
}
export interface Wave {
/** Where the wave's top is. Negative = behind you, positive = ahead of you. */
z: number;
/** 'coming' toward you, 'carrying' you, or 'passing' by you and away. */
mode: 'coming' | 'carrying' | 'passing';
/** Waves grow bigger when you catch them. */
size: number;
/** You get one tap per wave. */
tried: boolean;
}
/** Where the wave's top sits while it carries you: just behind you, pushing you forward. */
export const CARRY_Z = -0.3;
/** New waves start this far behind you. */
export const WAVE_START_BEHIND = 40;
/** How fast waves roll forward, compared to you. */
const WAVE_SPEED = 10;
/** How tall a wave is (in meters) for each step of its size. Big waves, bro! */
export const WAVE_HEIGHT = 2.2;
/** How wide the wave is in front (steep side) and behind (gentle side), in meters. */
export const WAVE_FRONT_WIDTH = 3.2;
export const WAVE_BACK_WIDTH = 7;
/** How high the big wave lifts the water at a spot (matches what you see). */
export function waveWaterHeight(wave: Wave | null, z: number): number {
if (!wave) return 0;
const d = z - wave.z;
const width = d > 0 ? WAVE_FRONT_WIDTH : WAVE_BACK_WIDTH; // steep in front, gentle behind
return wave.size * WAVE_HEIGHT * Math.exp(-(d * d) / (width * width));
}
/** Is this rock hidden under a wave right now? */
function rockUnderWater(g: Game, rock: Obstacle): boolean {
return waveWaterHeight(g.wave, rock.z) > rock.size * 0.6;
}
/** How far behind you a wave is (in meters). */
export function waveBehind(wave: Wave): number {
return -wave.z;
}
export interface Game {
phase: Phase;
time: number;
score: number;
best: number;
hearts: number;
x: number;
vx: number;
speed: number;
paddle: number;
/** Seconds of wave riding left (0 = not riding). */
riding: number;
/** Smoothly goes 0 → 1 when a ride starts, for drawing the wave wall. */
rideBlend: number;
combo: number;
trick: { trick: Trick; t: number } | null;
arrows: { arrow: Arrow; t: number }[];
invulnerable: number;
/** How long you've been turning the same way (seconds). */
turnTime: number;
turnDir: number;
warnedTurn: boolean;
/** Seconds left lying in the water after falling over. */
fallen: number;
/** Which way you fell: -1 = left, 1 = right. */
fallDir: number;
shake: number;
scroll: number;
/** The wave: rolling in, carrying you, or rolling on past you. */
wave: Wave | null;
nextSwell: number;
nextRock: number;
nextShark: number;
obstacles: Obstacle[];
popups: Popup[];
splashes: Splash[];
lastNag: number;
}
export function createGame(best: number): Game {
return {
phase: 'title',
time: 0,
score: 0,
best,
hearts: START_HEARTS,
x: 0,
vx: 0,
speed: 8,
paddle: 0,
riding: 0,
rideBlend: 0,
combo: 0,
trick: null,
arrows: [],
invulnerable: 0,
turnTime: 0,
turnDir: 0,
warnedTurn: false,
fallen: 0,
fallDir: 1,
shake: 0,
scroll: 0,
wave: null,
nextSwell: 2.5,
nextRock: 1.5,
nextShark: 8,
obstacles: [],
popups: [],
splashes: [],
lastNag: -10,
};
}
const random = (min: number, max: number): number => min + Math.random() * (max - min);
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
export function popup(g: Game, text: string, sub = '', color = '#ffd23f', big = true): void {
// Use the first free row so pop-ups don't cover each other.
const used = new Set(g.popups.map((p) => p.slot));
let slot = 0;
while (used.has(slot) && slot < 2) slot += 1;
g.popups.push({ text, sub, age: 0, life: big ? 1.6 : 1.3, color, slot, big });
}
/** Is the surfer up in the air right now (in the middle of an air trick)? */
export function isAirborne(g: Game): boolean {
if (!g.trick || !g.trick.trick.airborne) return false;
const progress = g.trick.t / g.trick.trick.duration;
return progress > 0.1 && progress < 0.9;
}
export function update(g: Game, input: Input, dt: number): void {
g.time += dt;
updateEffects(g, dt);
if (g.phase !== 'playing') return;
// Gets harder over the first 2 minutes.
const difficulty = Math.min(1, g.time / 120);
steer(g, input, dt);
paddleAndSpeed(g, input, dt);
updateWaves(g, dt, input.takeTap('w'));
for (const arrow of input.takeArrows()) handleArrow(g, arrow);
updateTrick(g, dt);
spawnObstacles(g, dt, difficulty);
moveObstacles(g, dt, difficulty);
makeSpray(g);
g.invulnerable = Math.max(0, g.invulnerable - dt);
}
function steer(g: Game, input: Input, dt: number): void {
// Lying in the water after a fall: no steering until you're back up.
if (g.fallen > 0) {
g.fallen = Math.max(0, g.fallen - dt);
g.vx *= Math.pow(0.03, dt);
g.x += g.vx * dt;
return;
}
const direction = input.steer;
checkBalance(g, direction, dt);
if (g.phase !== 'playing' || g.fallen > 0) return;
const grip = g.riding > 0 ? 34 : 24;
g.vx += direction * grip * dt;
g.vx *= Math.pow(0.03, dt); // water slows you down
g.vx = Math.max(-7, Math.min(7, g.vx));
g.x += g.vx * dt; // the ocean goes on forever to the sides
}
/** Turning one way for too long makes you lose your balance. */
function checkBalance(g: Game, direction: number, dt: number): void {
if (direction !== 0 && direction === g.turnDir) {
g.turnTime += dt;
} else if (direction !== 0) {
g.turnDir = direction; // switched sides: balance starts fresh
g.turnTime = 0;
g.warnedTurn = false;
} else {
g.turnTime = Math.max(0, g.turnTime - dt * 2); // going straight helps you recover
if (g.turnTime === 0) g.warnedTurn = false;
}
if (!g.warnedTurn && g.turnTime > TURN_LIMIT * 0.55) {
g.warnedTurn = true;
popup(g, 'WHOA! WOBBLY!', 'Straighten out or you will fall!', '#ffb347', false);
}
if (g.turnTime >= TURN_LIMIT) {
g.fallen = FALL_TIME;
g.turnTime = 0;
g.warnedTurn = false;
hurt(g, 'fall');
g.fallDir = g.turnDir;
g.vx = g.turnDir * 3; // tumble off to the side
}
}
function paddleAndSpeed(g: Game, input: Input, dt: number): void {
// Holding W paddles a bit faster (it doesn't catch waves; that's all timing).
const paddling = input.paddling && g.fallen <= 0 && g.riding <= 0 ? 1 : 0;
g.paddle += (paddling - g.paddle) * Math.min(1, dt * 3);
// Paddling is slow and calm; riding a wave is fast!
const target = g.fallen > 0 ? 2 : g.riding > 0 ? 22 : input.braking ? 3 : 5 + g.paddle * 2;
g.speed += (target - g.speed) * Math.min(1, dt * 3);
g.scroll += g.speed * dt;
g.score += Math.max(0, g.speed) * dt * (g.riding > 0 ? 1 : 0.5);
}
function updateWaves(g: Game, dt: number, tappedW: boolean): void {
if (g.riding > 0) {
g.riding -= dt;
if (g.riding <= 0) {
if (g.trick) g.riding = 0.01; // let the trick finish first
else endRide(g);
}
}
const wave = g.wave;
if (!wave) {
g.nextSwell -= dt;
if (g.nextSwell <= 0) g.wave = { z: -WAVE_START_BEHIND, mode: 'coming', size: 1, tried: false };
} else if (wave.mode === 'coming') {
// The wave rolls up from behind you at a steady speed, so the timing is fair.
wave.z += WAVE_SPEED * dt;
const behind = waveBehind(wave);
if (tappedW && !wave.tried && g.fallen <= 0 && behind < TIMING_RANGE) {
wave.tried = true;
if (behind <= CATCH_ZONE.far) {
catchWave(g, behind <= PERFECT_ZONE.far && behind >= PERFECT_ZONE.near);
wave.mode = 'carrying';
} else {
popup(g, 'TOO EARLY!', 'Wait for the green zone, bro', '#ffb347', false);
}
}
if (wave.mode === 'coming' && waveBehind(wave) <= CATCH_ZONE.near) {
if (!wave.tried) popup(g, 'MISSED IT!', 'Tap W when the wave is in the green zone', '#9fe8ff', false);
wave.mode = 'passing'; // it rolls right under you and on ahead
}
} else if (wave.mode === 'carrying') {
// The wave stays under you and grows as it carries you along.
wave.z += (CARRY_Z - wave.z) * Math.min(1, dt * 4);
wave.size += (1.7 - wave.size) * Math.min(1, dt * 2);
if (g.riding <= 0) wave.mode = 'passing'; // ride's over (or you wiped out)
} else {
// Rolling on ahead of you toward the beach, until it fades away.
wave.z += WAVE_SPEED * dt;
if (wave.z > 35) {
g.wave = null;
g.nextSwell = random(3, 5.5);
}
}
const goal = g.riding > 0 ? 1 : 0;
g.rideBlend += Math.sign(goal - g.rideBlend) * Math.min(Math.abs(goal - g.rideBlend), dt * 2);
}
function catchWave(g: Game, perfect: boolean): void {
g.riding = RIDE_TIME;
g.combo = 0;
g.arrows = [];
if (perfect) {
g.score += 200;
popup(g, 'PERFECT TIMING!', 'Caught the wave! +200', '#ffd23f');
} else {
popup(g, 'CAUGHT THE WAVE!', 'Now do tricks with the arrow keys!', '#2de1c2');
}
}
function endRide(g: Game): void {
g.riding = 0;
g.trick = null;
if (g.combo >= 3) popup(g, 'WHAT A RIDE!', `${g.combo} tricks in one wave`, '#ff7ac4');
g.combo = 0;
}
function handleArrow(g: Game, arrow: Arrow): void {
if (g.fallen > 0) return;
if (g.riding <= 0) {
if (g.time - g.lastNag > 2) {
popup(g, 'CATCH A WAVE FIRST!', 'Tricks only work while riding', '#9fe8ff', false);
g.lastNag = g.time;
}
return;
}
if (g.trick) return;
// Too long since your last press? Start a fresh combo.
const lastPress = g.arrows[g.arrows.length - 1];
if (lastPress && g.time - lastPress.t > COMBO_WINDOW) g.arrows = [];
g.arrows.push({ arrow, t: g.time });
g.arrows = g.arrows.slice(-LONGEST_COMBO);
const pressed = g.arrows.map((entry) => entry.arrow);
for (const trick of TRICKS_LONGEST_FIRST) {
const tail = pressed.slice(-trick.combo.length);
if (tail.length === trick.combo.length && tail.every((a, i) => a === trick.combo[i])) {
g.trick = { trick, t: 0 };
g.arrows = [];
return;
}
}
}
function updateTrick(g: Game, dt: number): void {
if (!g.trick) return;
g.trick.t += dt;
if (g.trick.t < g.trick.trick.duration) return;
// Landed it!
const { trick } = g.trick;
g.trick = null;
g.combo += 1;
const points = Math.round(trick.points * (1 + (g.combo - 1) * 0.5));
g.score += points;
g.riding = Math.min(MAX_RIDE_TIME, g.riding + 0.5); // tricks keep the wave going a little
const comboNote = g.combo > 1 ? ` x${g.combo} COMBO` : '';
popup(g, randomCheer(), `${trick.name} +${points}${comboNote}`);
}
function spawnObstacles(g: Game, dt: number, difficulty: number): void {
g.nextRock -= dt;
if (g.nextRock <= 0) {
g.obstacles.push({
kind: 'rock',
x: g.x + random(-SPAWN_WIDTH, SPAWN_WIDTH),
z: FAR,
size: random(0.6, 1.2),
seed: Math.random() * 1000,
done: false,
jump: null,
jumpDir: 1,
});
g.nextRock = lerp(2.2, 0.9, difficulty) * random(0.6, 1.4);
}
g.nextShark -= dt;
if (g.nextShark <= 0) {
g.obstacles.push({
kind: 'shark',
x: g.x + random(-SPAWN_WIDTH, SPAWN_WIDTH),
z: FAR * 0.85,
size: 1,
seed: Math.random() * 1000,
done: false,
jump: null,
jumpDir: 1,
});
g.nextShark = lerp(9, 4, difficulty) * random(0.7, 1.3);
}
}
function moveObstacles(g: Game, dt: number, difficulty: number): void {
for (const obstacle of g.obstacles) {
if (obstacle.kind === 'rock') washOverRock(g, obstacle);
if (obstacle.jump !== null) {
sharkLeap(g, obstacle, dt);
continue;
}
obstacle.z -= g.speed * dt;
if (obstacle.kind === 'shark') {
// Sharks swim toward you, and turn to follow you once they're close.
// They can't keep up as well when you're riding a fast wave.
obstacle.z -= 2 * dt;
if (obstacle.z < 35 && obstacle.z > 0) {
const chase = g.riding > 0 ? 0.7 : 1.5 + difficulty;
const dx = g.x - obstacle.x;
obstacle.x += Math.sign(dx) * Math.min(Math.abs(dx), chase * dt);
}
}
if (!obstacle.done && Math.abs(obstacle.z) < 0.7) {
const reach = obstacle.kind === 'rock' ? 0.45 + obstacle.size * 0.4 : 0.7;
if (Math.abs(obstacle.x - g.x) < reach) {
obstacle.done = true;
if (obstacle.kind === 'rock') {
if (isAirborne(g)) {
g.score += 150;
popup(g, 'OVER THE ROCK!', '+150', '#2de1c2', false);
} else if (rockUnderWater(g, obstacle)) {
g.score += 100;
popup(g, 'SURFED OVER THE ROCK!', 'The wave covered it! +100', '#2de1c2', false);
} else {
crashIntoRock(g, obstacle);
}
} else if (g.riding > 0) {
// Riding a wave is too fast for sharks!
g.score += 100;
popup(g, 'TOO FAST FOR THE SHARK!', '+100', '#2de1c2', false);
} else {
// The shark bursts out of the water and leaps right over you!
obstacle.jump = 0;
obstacle.jumpDir = obstacle.x < g.x ? 1 : -1;
obstacle.x = g.x - obstacle.jumpDir * 1.3; // start on one side, land on the other
obstacle.z = -0.2; // just in front of you, so it flies over your head
if (g.invulnerable <= 0) hurt(g, 'shark');
}
}
}
}
g.obstacles = g.obstacles.filter((obstacle) => obstacle.z > -3);
}
/** When a wave's crest rolls over a rock, it crashes into it with a big white splash. */
function washOverRock(g: Game, rock: Obstacle): void {
const wave = g.wave;
if (!wave || rock.washedBy === wave || rock.z > 30 || rock.z < -6) return;
if (Math.abs(wave.z - rock.z) > 0.8) return;
rock.washedBy = wave;
for (let i = 0; i < 45; i++) {
g.splashes.push({
x: rock.x + random(-0.8, 0.8) * rock.size,
z: rock.z + random(-0.3, 0.6),
height: rock.size * 0.5,
vx: random(-2.5, 2.5),
vHeight: random(3, 6.5),
life: random(0.5, 1.1),
});
}
}
/**
* Rocks are solid! You smack into it, bounce back and to the side, and fall in.
* On a wave you keep your hearts (but the ride is over). Off a wave it costs a heart.
*/
function crashIntoRock(g: Game, rock: Obstacle): void {
const onWave = g.riding > 0;
if (!onWave && g.invulnerable <= 0) hurt(g, 'rock');
if (g.phase !== 'playing') return;
if (onWave) {
g.riding = 0;
g.combo = 0;
g.invulnerable = 1.0;
popup(g, 'BONK!', 'You bounced off a rock, bro!', '#ffb347');
}
// The rock stays in front of you (you can't go through it)...
rock.z = Math.max(rock.z, 0.8);
// ...and you bounce away from it.
const away = g.x >= rock.x ? 1 : -1;
g.trick = null;
g.paddle = 0;
g.speed = -5; // knocked backward
g.vx = away * 7;
g.fallen = FALL_TIME;
g.fallDir = away;
g.shake = 0.45;
// Big splash where you hit.
for (let i = 0; i < 18; i++) {
g.splashes.push({
x: (g.x + rock.x) / 2 + random(-0.4, 0.4),
z: random(0, 0.6),
height: 0.2,
vx: random(-3, 3),
vHeight: random(2, 5),
life: random(0.4, 0.9),
});
}
}
/** A shark mid-leap moves along with you, then dives back under and swims off. */
function sharkLeap(g: Game, shark: Obstacle, dt: number): void {
shark.jump = (shark.jump ?? 0) + dt;
shark.x += shark.jumpDir * (2.6 / SHARK_JUMP_TIME) * dt;
if (shark.jump >= SHARK_JUMP_TIME) {
// Big splash where it dives back in.
for (let i = 0; i < 14; i++) {
g.splashes.push({
x: shark.x + random(-0.4, 0.4),
z: shark.z,
height: 0.1,
vx: random(-3, 3),
vHeight: random(2, 4.5),
life: random(0.5, 0.9),
});
}
shark.z = -10; // gone, back under the water
}
}
function hurt(g: Game, kind: 'rock' | 'shark' | 'fall'): void {
g.hearts -= 1;
g.invulnerable = 1.8;
g.shake = 0.45;
g.trick = null;
g.riding = 0;
g.combo = 0;
g.paddle = 0;
g.vx = 0;
if (kind === 'shark') popup(g, 'CHOMP!', 'A shark got you!', '#ff4f5e');
else if (kind === 'fall') popup(g, 'SPLASH! YOU FELL!', 'You turned too long, dude!', '#ff4f5e');
else popup(g, 'WIPEOUT!', 'You hit a rock!', '#ff4f5e');
if (g.hearts <= 0) {
g.phase = 'over';
g.score = Math.floor(g.score);
g.best = Math.max(g.best, g.score);
}
}
function makeSpray(g: Game): void {
const chance = g.riding > 0 ? 0.9 : g.speed > 11 ? 0.35 : 0.1;
if (Math.random() > chance) return;
g.splashes.push({
x: g.x + random(-0.3, 0.3),
z: random(-0.4, 0.2),
height: 0.1,
vx: random(-2.5, 2.5) - g.vx * 0.3,
vHeight: random(1.5, g.riding > 0 ? 4 : 2.5),
life: random(0.4, 0.8),
});
}
function updateEffects(g: Game, dt: number): void {
for (const p of g.popups) p.age += dt;
g.popups = g.popups.filter((p) => p.age < p.life);
for (const s of g.splashes) {
s.x += s.vx * dt;
s.height += s.vHeight * dt;
s.vHeight -= 9 * dt;
s.z -= g.speed * dt * 0.3;
s.life -= dt;
}
g.splashes = g.splashes.filter((s) => s.life > 0 && s.height > 0);
g.shake = Math.max(0, g.shake - dt);
}
