Morph ball
v += g·dt → p += v·dt → resolve x, then y
How it works
Integration
The ball is a single body stepped with semi-implicit Euler: gravity is added to velocity first, then the new velocity moves the position. Doing it in that order rather than the other way round costs nothing and is markedly more stable under a fixed timestep, which is why almost every game uses it over the textbook explicit form.
The shared loop clamps dt to 50ms. Without that, a tab left in the background returns with one enormous delta and the ball teleports through the floor — a classic tunnelling bug, and the cheapest possible fix for it.
Collision, one axis at a time
The ball collides as an axis-aligned box even though it draws as a circle. Resolution is split by axis: apply the x velocity, look up only the tile column the leading edge now occupies, and if any tile in the overlapping row range is solid, snap the box flush against it and negate the velocity. Then repeat for y.
Splitting the axes turns an awkward 2D problem into two 1D ones and removes the ambiguity of a corner hit — after the x pass the ball is provably clear horizontally, so the y pass cannot fight it. It also makes the ground test a by-product: if the downward pass hit something, the ball is grounded.
Each hit keeps a fraction of its speed — 42% vertically, 72% into walls, so it ricochets sideways but settles downward. Below 14px/s the vertical bounce is zeroed outright, because otherwise a ball resting on the floor bounces by ever smaller amounts forever and visibly jitters.
Bomb jumps
Bombs are the only way a morph ball climbs, and they work here the same way. A bomb sits for 0.8s, then applies a radial impulse to anything inside 34px, falling off linearly with distance. Directly on top of the blast the normal is undefined, so it defaults to straight up — which is exactly the bomb jump the games are built around.
One is laid automatically every 3.4s, and early if the ball has been nearly stationary for more than 1.6s. That stall check is what stops it dying in a pocket: any dead end is temporary once you can blow yourself out of it.
Ground drag is applied as v × (1 − friction × dt) rather than subtracting a constant, so it decays exponentially and never overshoots through zero into a reversal.
One light, dithered
The room is lit by a single source travelling with the ball. Brightness is a linear falloff over 54px plus a little ambient, and every explosion adds a bright, short-lived contribution of its own.
That brightness is then quantised to three colour steps through the same 4×4 Bayer matrix as the dithering piece: floor(light × 2 + bayer). Biasing by the matrix before flooring breaks what would be three flat contour rings into a stipple, and gives the cave its GBA look. The whole scene renders into a quarter-scale ImageData buffer and is scaled up with smoothing off, so the pixels are square and deliberate.
The room itself is a solid shell plus a handful of random ledges, generated from a seeded PRNG so the cave is the same shape on every visit. The doors either side of the floor are decoration — and locked, as ever.
Source
'use client';
import { useRef } from 'react';import { useCanvasCraft, mulberry32, BAYER4 } from '../useCraft';
/* * Everything below is measured in sim pixels — a virtual GBA screen that gets * blown up by PIXEL with smoothing off. Working at this scale means collision * maths lands on whole pixels and the dithered lamp has visible steps. */const PIXEL = 4; // one sim pixel → a PIXEL×PIXEL block on screenconst TILE = 8; // sim pixels per tileconst R = 3.2; // ball half-extent (it collides as a square, draws as a circle)
const GRAVITY = 250; // sim px/s²const DRIVE = 165; // horizontal accel while touching the groundconst MAX_VX = 48;const FRICTION = 2.2; // ground drag, applied as an exponential decayconst BOUNCE_Y = 0.42; // restitution: vertical hits keep 42% of speedconst BOUNCE_X = 0.72; // walls are springier, so it ricochets
const FUSE = 0.8; // bomb countdown, secondsconst BLAST_R = 34;const BLAST_POWER = 132;const AUTO_BOMB = 3.4; // it lays its own bombs to get out of dead ends
const LAMP = 54; // light radius around the ballconst AMBIENT = 0.1;const TRAIL_LEN = 16;
// Three-step ramps, unlit → lit. Brinstar greens on bruised purple.const VOID: RGB[] = [[10, 8, 18], [26, 18, 40], [50, 34, 74]];const ROCK: RGB[] = [[22, 46, 38], [40, 96, 68], [88, 170, 106]];const DOOR_RAMP: RGB[] = [[70, 20, 44], [150, 36, 86], [255, 45, 120]];const EDGE: RGB = [128, 216, 124]; // lit top face of a tileconst SHELL: RGB = [255, 122, 26];const CORE: RGB = [255, 217, 77];const GLASS: RGB = [140, 236, 255];const BOMB: RGB = [197, 255, 59];const LEVELS = VOID.length;
type RGB = [number, number, number];
interface Bomb { x: number; y: number; fuse: number }interface Blast { x: number; y: number; age: number }
interface Sim { lw: number; lh: number; cols: number; rows: number; solid: Uint8Array; door: Uint8Array; ball: { x: number; y: number; vx: number; vy: number; grounded: boolean; dir: number; spin: number }; bombs: Bomb[]; blasts: Blast[]; trail: { x: number; y: number }[]; nextBomb: number; idle: number; buffer: HTMLCanvasElement; img: ImageData;}
/** * A room in the Metroid sense: solid shell, a handful of overlapping ledges * with gaps to fall through, and locked doors either side of the floor. */function carveRoom(cols: number, rows: number, spawnCol: number) { const solid = new Uint8Array(cols * rows); const door = new Uint8Array(cols * rows); const rand = mulberry32(0x2e304d); const at = (c: number, r: number) => r * cols + c;
for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { // Two-tile floor so the ground reads as thick, one tile everywhere else. if (c === 0 || c === cols - 1 || r === 0 || r >= rows - 2) solid[at(c, r)] = 1; } }
const ledges = Math.max(2, Math.round(rows / 2.6)); for (let i = 0; i < ledges; i++) { const r = 2 + Math.floor(rand() * Math.max(1, rows - 5)); const span = 3 + Math.floor(rand() * Math.max(2, cols / 2.2)); const c0 = 1 + Math.floor(rand() * Math.max(1, cols - span - 2)); for (let c = c0; c < Math.min(cols - 1, c0 + span); c++) solid[at(c, r)] = 1; }
// Clumps hanging off the ledges, to break up the straight lines. const clumps = Math.round(cols * rows * 0.02); for (let i = 0; i < clumps; i++) { const c = 1 + Math.floor(rand() * (cols - 2)); const r = 2 + Math.floor(rand() * Math.max(1, rows - 4)); if (!solid[at(c, r)]) continue; const below = r + 1; if (below < rows - 2) solid[at(c, below)] = 1; }
// Doors: three tiles tall, sitting on the floor. Still solid — they're locked. const top = rows - 5; for (let k = 0; k < 3; k++) { const r = top + k; if (r < 1 || r >= rows - 2) continue; door[at(0, r)] = 1; door[at(cols - 1, r)] = 1; }
// Clear a pocket so the ball never starts embedded in rock. for (let r = 1; r <= 3; r++) { for (let c = spawnCol - 1; c <= spawnCol + 1; c++) { if (c > 0 && c < cols - 1 && r < rows - 2) solid[at(c, r)] = 0; } }
return { solid, door };}
export default function MorphBall() { const sim = useRef<Sim | null>(null);
const seed = (w: number, h: number) => { const lw = Math.max(TILE * 4, Math.ceil(w / PIXEL)); const lh = Math.max(TILE * 4, Math.ceil(h / PIXEL)); const cols = Math.ceil(lw / TILE); const rows = Math.ceil(lh / TILE);
const buffer = document.createElement('canvas'); buffer.width = lw; buffer.height = lh; const bctx = buffer.getContext('2d'); if (!bctx) return;
const spawnCol = Math.floor(cols / 2); const { solid, door } = carveRoom(cols, rows, spawnCol);
sim.current = { lw, lh, cols, rows, solid, door, ball: { x: spawnCol * TILE + TILE / 2, y: TILE * 2, vx: 26, vy: 0, grounded: false, dir: 1, spin: 0, }, bombs: [], blasts: [], trail: [], nextBomb: AUTO_BOMB, idle: 0, buffer, img: bctx.createImageData(lw, lh), }; };
const canvasRef = useCanvasCraft(({ ctx, w, h, dt }) => { const s = sim.current; if (!s) return;
step(s, dt); paint(s);
const bctx = s.buffer.getContext('2d'); if (!bctx) return; bctx.putImageData(s.img, 0, 0);
ctx.clearRect(0, 0, w, h); ctx.imageSmoothingEnabled = false; ctx.drawImage(s.buffer, 0, 0, w, h); }, seed);
const dropBomb = (e: React.PointerEvent<HTMLCanvasElement>) => { const s = sim.current; if (!s) return; const rect = e.currentTarget.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * s.lw; const y = ((e.clientY - rect.top) / rect.height) * s.lh; s.bombs.push({ x, y, fuse: FUSE * 0.55 }); };
return ( <canvas ref={canvasRef} onPointerDown={dropBomb} style={{ width: '100%', height: '100%', display: 'block', cursor: 'crosshair', background: `rgb(${VOID[0].join(',')})`, }} /> );}
/* ── Simulation ──────────────────────────────────────────────────────────── */
function step(s: Sim, dt: number) { const b = s.ball; const blocked = (c: number, r: number) => c < 0 || c >= s.cols || r < 0 || r >= s.rows ? true : s.solid[r * s.cols + c] === 1;
// Semi-implicit Euler: accelerate first, then integrate the new velocity. b.vy += GRAVITY * dt; if (b.grounded) { b.vx += b.dir * DRIVE * dt; b.vx *= Math.max(0, 1 - FRICTION * dt); } b.vx = Math.max(-MAX_VX, Math.min(MAX_VX, b.vx));
// Axis-separated sweep. Move on x, push out of anything overlapping, then // repeat on y — two cheap 1D problems instead of one nasty 2D one. const EPS = 0.001; b.x += b.vx * dt; { const minR = Math.floor((b.y - R) / TILE); const maxR = Math.floor((b.y + R - EPS) / TILE); if (b.vx > 0) { const c = Math.floor((b.x + R) / TILE); for (let r = minR; r <= maxR; r++) { if (blocked(c, r)) { b.x = c * TILE - R - EPS; b.vx = -b.vx * BOUNCE_X; b.dir = -1; break; } } } else if (b.vx < 0) { const c = Math.floor((b.x - R) / TILE); for (let r = minR; r <= maxR; r++) { if (blocked(c, r)) { b.x = (c + 1) * TILE + R + EPS; b.vx = -b.vx * BOUNCE_X; b.dir = 1; break; } } } }
b.grounded = false; b.y += b.vy * dt; { const minC = Math.floor((b.x - R) / TILE); const maxC = Math.floor((b.x + R - EPS) / TILE); if (b.vy > 0) { const r = Math.floor((b.y + R) / TILE); for (let c = minC; c <= maxC; c++) { if (blocked(c, r)) { b.y = r * TILE - R - EPS; // Kill micro-bounces, otherwise it jitters forever on the floor. b.vy = b.vy < 14 ? 0 : -b.vy * BOUNCE_Y; b.grounded = true; break; } } } else if (b.vy < 0) { const r = Math.floor((b.y - R) / TILE); for (let c = minC; c <= maxC; c++) { if (blocked(c, r)) { b.y = (r + 1) * TILE + R + EPS; b.vy = -b.vy * BOUNCE_Y; break; } } } }
// Rolling: arc length over radius, so the highlight tracks real distance. b.spin += (b.vx / R) * dt;
// Bombs are the escape hatch. Lay one on a timer, and sooner if it stalls. const speed = Math.abs(b.vx) + Math.abs(b.vy); s.idle = speed < 6 ? s.idle + dt : 0; s.nextBomb -= dt; if (s.nextBomb <= 0 || s.idle > 1.6) { s.bombs.push({ x: b.x, y: b.y, fuse: FUSE }); s.nextBomb = AUTO_BOMB; s.idle = 0; }
for (let i = s.bombs.length - 1; i >= 0; i--) { const bomb = s.bombs[i]; bomb.fuse -= dt; if (bomb.fuse > 0) continue; s.bombs.splice(i, 1); s.blasts.push({ x: bomb.x, y: bomb.y, age: 0 });
// Radial impulse falling off linearly. Sitting on top of your own bomb // sends you straight up — that's a bomb jump, and it's how you climb. const dx = b.x - bomb.x; const dy = b.y - bomb.y; const d = Math.hypot(dx, dy); if (d < BLAST_R) { const k = 1 - d / BLAST_R; const nx = d < 0.5 ? 0 : dx / d; const ny = d < 0.5 ? -1 : dy / d; b.vx += nx * BLAST_POWER * k; b.vy += ny * BLAST_POWER * k - 46 * k; b.dir = b.vx >= 0 ? 1 : -1; b.grounded = false; } }
for (let i = s.blasts.length - 1; i >= 0; i--) { s.blasts[i].age += dt; if (s.blasts[i].age > 0.42) s.blasts.splice(i, 1); }
// Anything that escapes the room (a resize mid-flight) goes back to spawn. if (b.x < 0 || b.x > s.lw || b.y < 0 || b.y > s.lh) { b.x = Math.floor(s.cols / 2) * TILE + TILE / 2; b.y = TILE * 2; b.vx = 20; b.vy = 0; }
s.trail.push({ x: b.x, y: b.y }); if (s.trail.length > TRAIL_LEN) s.trail.shift();}
/* ── Rendering ───────────────────────────────────────────────────────────── */
function paint(s: Sim) { const { lw, lh, cols, solid, door, img } = s; const data = img.data; const b = s.ball;
for (let y = 0; y < lh; y++) { const ty = (y / TILE) | 0; const rowBayer = (y & 3) * 4;
for (let x = 0; x < lw; x++) { const tx = (x / TILE) | 0; const cell = ty * cols + tx;
// One moving light source, plus whatever is currently exploding. const dx = x - b.x; const dy = y - b.y; let light = AMBIENT + Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) / LAMP) * 0.95;
for (const blast of s.blasts) { const bd = Math.hypot(x - blast.x, y - blast.y); if (bd > BLAST_R) continue; light += (1 - bd / BLAST_R) * (1 - blast.age / 0.42) * 1.5; }
// Ordered dithering: bias by the Bayer cell before quantising, so the // falloff breaks into a stipple instead of three flat bands. const t = BAYER4[rowBayer + (x & 3)]; const shade = Math.min(LEVELS - 1, Math.max(0, Math.floor(light * (LEVELS - 1) + t)));
let c: RGB; if (solid[cell]) { const isDoor = door[cell] === 1; // Highlight the top 2 rows of a tile whose neighbour above is open. const exposed = ty === 0 || !solid[(ty - 1) * cols + tx]; if (!isDoor && exposed && y % TILE < 2 && shade > 0) { c = mix(ROCK[shade], EDGE, 0.55); } else { c = (isDoor ? DOOR_RAMP : ROCK)[shade]; } } else { c = VOID[shade]; }
const i = (y * lw + x) * 4; data[i] = c[0]; data[i + 1] = c[1]; data[i + 2] = c[2]; data[i + 3] = 255; } }
// Trail, oldest first so newer samples paint over older ones. s.trail.forEach((p, i) => { const k = (i / s.trail.length) * 0.5; blend(data, lw, lh, p.x | 0, p.y | 0, SHELL, k); });
for (const bomb of s.bombs) { // Flash faster as the fuse runs out. const blink = Math.sin(bomb.fuse * 46) > 0 ? 1 : 0.35; disc(data, lw, lh, bomb.x, bomb.y, 1.9, BOMB, blink); }
for (const blast of s.blasts) { const k = blast.age / 0.42; ring(data, lw, lh, blast.x, blast.y, k * BLAST_R, 1 - k); }
// The ball: shell, then a core, then a spinning glint so the roll reads. disc(data, lw, lh, b.x, b.y, R + 0.7, SHELL, 1); disc(data, lw, lh, b.x, b.y, R * 0.55, CORE, 1); blend( data, lw, lh, Math.round(b.x + Math.cos(b.spin) * R * 0.72), Math.round(b.y + Math.sin(b.spin) * R * 0.72), GLASS, 0.9, );}
function mix(a: RGB, c: RGB, k: number): RGB { return [a[0] + (c[0] - a[0]) * k, a[1] + (c[1] - a[1]) * k, a[2] + (c[2] - a[2]) * k];}
function blend(data: Uint8ClampedArray, lw: number, lh: number, x: number, y: number, c: RGB, k: number) { if (x < 0 || y < 0 || x >= lw || y >= lh || k <= 0) return; const i = (y * lw + x) * 4; data[i] += (c[0] - data[i]) * k; data[i + 1] += (c[1] - data[i + 1]) * k; data[i + 2] += (c[2] - data[i + 2]) * k;}
function disc(data: Uint8ClampedArray, lw: number, lh: number, cx: number, cy: number, r: number, c: RGB, k: number) { const r2 = r * r; for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) { for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) { const dx = x - cx; const dy = y - cy; if (dx * dx + dy * dy <= r2) blend(data, lw, lh, x, y, c, k); } }}
function ring(data: Uint8ClampedArray, lw: number, lh: number, cx: number, cy: number, r: number, k: number) { const steps = Math.max(8, Math.round(r * 6)); for (let i = 0; i < steps; i++) { const a = (i / steps) * Math.PI * 2; blend(data, lw, lh, Math.round(cx + Math.cos(a) * r), Math.round(cy + Math.sin(a) * r), [255, 255, 255], k * 0.8); }}The shared loop every craft runs on, plus the noise and dither helpers.
'use client';
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
/** Decorative previews don't need every frame; the detail pages get the lot. */const PREVIEW_FPS = 30;
/** * Counts capped loops so each can be given a different phase. Without this they * all skip the same frames and compute on the same ones, so the index page does * nothing for one frame and every simulation at once on the next — which shows * up as a dropped frame rather than as evenly spread work. */let phaseCounter = 0;const PHASES = 3;
/** * rAF loop that only runs while the target is on screen and the tab is visible. * Each craft runs its own, so several visible at once is several animations' * worth of work — which is why the grid previews are capped: on the index a * handful of simulations compete for one main thread, and at a glance nobody can * tell 30 frames from 60. */export function useVisibleRaf( targetRef: RefObject<HTMLElement | null>, onFrame: (t: number, dt: number) => void,) { const cb = useRef(onFrame); cb.current = onFrame;
useEffect(() => { const el = targetRef.current; if (!el) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // The class that marks a stage as a thumbnail rather than the main event. const isPreview = el.closest('.craft-stage-preview') !== null; // A frame of slack, so a 30fps cap doesn't skip to 20 by missing a vsync. const minGap = isPreview ? 1000 / PREVIEW_FPS - 4 : 0; const phase = minGap ? ((phaseCounter++ % PHASES) * minGap) / PHASES : 0;
let raf = 0; let running = false; let visible = false; const start = performance.now(); let last = start - phase;
const loop = (now: number) => { raf = requestAnimationFrame(loop); if (now - last < minGap) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; cb.current((now - start) / 1000, dt); };
const play = () => { if (running || !visible || document.hidden) return; running = true; // Keep this loop's phase on resume, or scrolling would resync them all. last = performance.now() - phase; raf = requestAnimationFrame(loop); };
const pause = () => { running = false; cancelAnimationFrame(raf); };
const io = new IntersectionObserver( ([entry]) => { visible = entry.isIntersecting; if (visible) play(); else pause(); }, { threshold: 0.05 }, ); io.observe(el);
const onVis = () => (document.hidden ? pause() : play()); document.addEventListener('visibilitychange', onVis);
// Reduced motion: paint one frame, then stop. if (reduced) { cb.current(0, 0); io.disconnect(); document.removeEventListener('visibilitychange', onVis); return; }
return () => { pause(); io.disconnect(); document.removeEventListener('visibilitychange', onVis); }; }, [targetRef]);}
export interface CraftFrame { ctx: CanvasRenderingContext2D; w: number; h: number; t: number; dt: number; pointer: { x: number; y: number; inside: boolean };}
/** * Canvas sized to its CSS box with DPR scaling, driven by useVisibleRaf. * `onInit` fires once per resize — use it to seed grids and particles. */export function useCanvasCraft( onFrame: (f: CraftFrame) => void, onInit?: (w: number, h: number) => void,) { const canvasRef = useRef<HTMLCanvasElement>(null); const box = useRef({ w: 0, h: 0 }); const pointer = useRef({ x: -9999, y: -9999, inside: false }); const frame = useRef(onFrame); const init = useRef(onInit); frame.current = onFrame; init.current = onInit;
useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return;
const resize = () => { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); box.current = { w: rect.width, h: rect.height }; canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); init.current?.(rect.width, rect.height); };
resize(); const ro = new ResizeObserver(resize); ro.observe(canvas);
/* * Touch has no hover: pointermove only arrives while a finger is down, and * the browser stops sending it the moment it decides the gesture is a * scroll. So the position is also taken on pointerdown — otherwise a tap, * or a grab that hasn't moved yet, reads as no pointer at all. */ const at = (e: PointerEvent) => { const r = canvas.getBoundingClientRect(); pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top, inside: true }; };
const down = (e: PointerEvent) => { at(e); // Keeps a drag that wanders off the canvas mid-gesture still tracking. try { canvas.setPointerCapture(e.pointerId); } catch { // Pointer already gone; nothing to capture. } };
const up = (e: PointerEvent) => { // A lifted finger stops existing, where a clicked mouse is still hovering. if (e.pointerType !== 'mouse') pointer.current.inside = false; };
const leave = () => { pointer.current.inside = false; };
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', at); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', leave); canvas.addEventListener('pointerleave', leave);
return () => { ro.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', at); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', leave); canvas.removeEventListener('pointerleave', leave); }; }, []);
useVisibleRaf(canvasRef, (t, dt) => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; frame.current({ ctx, w: box.current.w, h: box.current.h, t, dt, pointer: pointer.current }); });
return canvasRef;}
/** Current theme, kept in sync with the dock's toggle. Starts 'light' so SSR matches. */export function useCraftTheme(): 'light' | 'dark' { const [theme, setTheme] = useState<'light' | 'dark'>('light');
useLayoutEffect(() => { const el = document.documentElement; const read = () => setTheme((el.dataset.theme as 'light' | 'dark') ?? 'light'); read(); const mo = new MutationObserver(read); mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] }); return () => mo.disconnect(); }, []);
return theme;}
/** Seeded PRNG, so anything generated looks the same on every visit. */export function mulberry32(seed: number) { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Ordered 4×4 Bayer matrix, normalised to 0..1. Shared by the dithered pieces. */export const BAYER4 = [ 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,].map(v => (v + 0.5) / 16);
/** Cheap hash-based value noise — enough character for a flow field, no lookup tables. */export function noise2(x: number, y: number): number { const xi = Math.floor(x); const yi = Math.floor(y); const xf = x - xi; const yf = y - yi; const u = xf * xf * (3 - 2 * xf); const v = yf * yf * (3 - 2 * yf);
const h = (a: number, b: number) => { let n = Math.imul(a, 374761393) + Math.imul(b, 668265263); n = Math.imul(n ^ (n >>> 13), 1274126177); return ((n ^ (n >>> 16)) >>> 0) / 4294967295; };
return ( h(xi, yi) * (1 - u) * (1 - v) + h(xi + 1, yi) * u * (1 - v) + h(xi, yi + 1) * (1 - u) * v + h(xi + 1, yi + 1) * u * v );}