From 1c1a35d586520fea6851e7110db2458259f6f678 Mon Sep 17 00:00:00 2001 From: Lu <42706009+luinbytes@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:45:17 +0000 Subject: [PATCH 1/2] Restore the original Pixel pond --- .../signal-desk/pond-environment.module.css | 16 +- .../concepts/signal-desk/pond-environment.tsx | 62 +- .../concepts/signal-desk/pond-pixel-model.ts | 144 ++ .../signal-desk/pond-pixel-renderer.ts | 897 ++++++++++++ .../signal-desk/pond-pixel-simulation.ts | 1201 +++++++++++++++++ .../concepts/signal-desk/pond-pixel-world.ts | 616 +++++++++ .../concepts/signal-desk/pond-renderer.ts | 30 +- .../signal-desk/signal-desk.module.css | 4 - .../concepts/signal-desk/signal-desk.tsx | 24 +- package.json | 2 +- tests/browser/browser_test.py | 187 ++- tests/pond-pixel.test.mts | 159 +++ 12 files changed, 3260 insertions(+), 82 deletions(-) create mode 100644 components/concepts/signal-desk/pond-pixel-model.ts create mode 100644 components/concepts/signal-desk/pond-pixel-renderer.ts create mode 100644 components/concepts/signal-desk/pond-pixel-simulation.ts create mode 100644 components/concepts/signal-desk/pond-pixel-world.ts create mode 100644 tests/pond-pixel.test.mts diff --git a/components/concepts/signal-desk/pond-environment.module.css b/components/concepts/signal-desk/pond-environment.module.css index aaea46d..1c616bb 100644 --- a/components/concepts/signal-desk/pond-environment.module.css +++ b/components/concepts/signal-desk/pond-environment.module.css @@ -78,6 +78,12 @@ opacity: 1; } +.pond[data-switching] .pondHost, +.pond[data-motion="reduced"] .pondHost, +.pond[data-motion="reduced"] .pondSkeleton[skullet-reveal] { + transition: none; +} + .pondCanvas { display: block; width: 100%; @@ -89,12 +95,16 @@ visibility: hidden; } -.pondHost > [data-pond-transition] { +.pond > [data-pond-transition] { position: absolute; inset: 0; + z-index: 2; width: 100%; height: 100%; image-rendering: pixelated; +} + +.pond > [data-pond-transition][data-fade] { animation: pond-view-fade 300ms cubic-bezier(0.22, 0.72, 0.24, 1) both; } @@ -154,8 +164,4 @@ animation: none; } - .pondHost > [data-pond-transition] { - animation: none; - display: none; - } } diff --git a/components/concepts/signal-desk/pond-environment.tsx b/components/concepts/signal-desk/pond-environment.tsx index 0b8e1c6..e7f5b0d 100644 --- a/components/concepts/signal-desk/pond-environment.tsx +++ b/components/concepts/signal-desk/pond-environment.tsx @@ -13,14 +13,9 @@ export function PondEnvironment({ reduced, mode = "ascii" }: { reduced: boolean; const pondRef = useRef(null); const hostRef = useRef(null); const interactionRef = useRef(null); - - useEffect(() => { - const host = hostRef.current; - const pond = pondRef.current; - if (!host || !pond) return; - pond.dataset.view = mode; - host.dispatchEvent(new Event("pond-view-change")); - }, [mode]); + const transitionRef = useRef(null); + const reducedRef = useRef(reduced); + reducedRef.current = reduced; useEffect(() => { const pond = pondRef.current; @@ -28,21 +23,51 @@ export function PondEnvironment({ reduced, mode = "ascii" }: { reduced: boolean; const interaction = interactionRef.current; if (!pond || !host || !interaction) return; + pond.querySelectorAll('[data-pond-transition][data-fade]').forEach((image) => image.remove()); + if (reduced) { + transitionRef.current?.remove(); + transitionRef.current = null; + } + pond.dataset.motion = reduced ? "reduced" : "full"; + pond.toggleAttribute("data-switching", Boolean(transitionRef.current)); + pond.dataset.view = mode; pond.dataset.renderer = "loading"; + for (const key of Object.keys(host.dataset)) delete host.dataset[key]; + host.dataset.pondState = "loading"; delete document.documentElement.dataset.pondBoot; const controller = new AbortController(); let disposed = false; let cleanup = () => {}; - void import("./pond-renderer") - .then(({ startPondRenderer }) => startPondRenderer({ + let capture: (() => string) | undefined; + const observer = new MutationObserver(() => { + if (pond.dataset.renderer !== "three" && pond.dataset.renderer !== "fallback") return; + const image = transitionRef.current; + if (!image || reduced) { + image?.remove(); + pond.removeAttribute("data-switching"); + } else { + image.dataset.fade = ""; + image.addEventListener("animationend", () => { + if (image.parentElement !== pond) return; + image.remove(); + if (!pond.querySelector("[data-pond-transition]")) pond.removeAttribute("data-switching"); + }, { once: true }); + } + transitionRef.current = null; + }); + observer.observe(pond, { attributes: true, attributeFilter: ["data-renderer"] }); + void (mode === "pixel" ? import("./pond-pixel-renderer") : import("./pond-renderer")) + .then(({ startPondRenderer }) => disposed ? undefined : startPondRenderer({ pond, host, interaction, reduced, canvasClassName: styles.pondCanvas, signal: controller.signal, + onCaptureReady: (snapshot) => { capture = snapshot; }, })) .then((stop) => { + if (!stop) return; if (disposed) stop(); else cleanup = stop; }) @@ -56,10 +81,25 @@ export function PondEnvironment({ reduced, mode = "ascii" }: { reduced: boolean; return () => { disposed = true; + observer.disconnect(); + if (!reduced && !reducedRef.current && capture && pond.dataset.renderer === "three") { + try { + const image = document.createElement("img"); + image.alt = ""; + image.dataset.pondTransition = ""; + image.src = capture(); + transitionRef.current?.remove(); + transitionRef.current = image; + pond.append(image); + } catch { + transitionRef.current?.remove(); + transitionRef.current = null; + } + } controller.abort(); cleanup(); }; - }, [reduced]); + }, [mode, reduced]); return ( <> diff --git a/components/concepts/signal-desk/pond-pixel-model.ts b/components/concepts/signal-desk/pond-pixel-model.ts new file mode 100644 index 0000000..7b40a0d --- /dev/null +++ b/components/concepts/signal-desk/pond-pixel-model.ts @@ -0,0 +1,144 @@ +export type PondPoint = { x: number; y: number }; + +export type PondTarget = { + id: string; + type: "fish" | "fly"; + position: PondPoint; + velocity: PondPoint; + visible: boolean; + attackable: boolean; + interactionRange: number; + bodyRadius?: number; + species?: string; + danger?: number; +}; + +export type FishFoodState = "dropping" | "floating" | "depleted"; + +export type FishFoodEntity = { + id: string; + position: PondPoint; + age: number; + state: FishFoodState; + remainingAmount: number; + attractionRadius: number; + consumed: boolean; + expired: boolean; + pelletSize: number; + settleDelay: number; + lifetime: number; + dropProgress: number; +}; + +export type FishFoodStateName = + | "cruising" + | "detecting-food" + | "approaching-food" + | "circling" + | "feeding" + | "startled" + | "returning-to-cruise"; + +export type FishWorldIntent = { + fishId: string; + state: FishFoodStateName; + foodId: string | null; + goal: PondPoint | null; + arrivalRadius: number; + speedScale: number; + reserved: boolean; + feedingPulse: number; + reason: string; +}; + +export type FlyRoutineState = "hovering" | "foraging" | "resting" | "startled"; + +export type FlyWorldIntent = { + flyId: string; + state: FlyRoutineState; + goal: PondPoint; + speedScale: number; + reason: string; +}; + +export type PondWorldEvent = + | { type: "ambient-ripple"; position: PondPoint; strength: number } + | { type: "food-dropped"; foodId: string; position: PondPoint; rippleStrength: number; merged: boolean } + | { type: "fish-state"; fishId: string; from: FishFoodStateName; to: FishFoodStateName; reason: string } + | { type: "fish-noticed-food"; fishId: string; foodId: string } + | { type: "fish-reserved-food"; fishId: string; foodId: string; slot: number } + | { type: "fish-arrived"; fishId: string; foodId: string; position: PondPoint } + | { type: "fish-fed"; fishId: string; foodId: string; position: PondPoint; remainingAmount: number } + | { type: "food-expired"; foodId: string; position: PondPoint; consumed: boolean; reason: "consumed" | "lifetime" | "capacity" } + | { type: "fly-state"; flyId: string; from: FlyRoutineState; to: FlyRoutineState; reason: string }; + +export type PondWorldInput = { + now: number; + delta: number; + targets: readonly PondTarget[]; + foodApproach?: (fish: PondTarget, food: PondPoint, arrivalRadius: number, preferred: PondPoint) => PondPoint | null; +}; + +export type PondWorldFrame = { + environment: { + wind: number; + currentA: PondPoint; + currentB: PondPoint; + surfaceA: PondPoint; + surfaceB: PondPoint; + light: number; + }; + foods: readonly FishFoodEntity[]; + fish: readonly FishWorldIntent[]; + flies: readonly FlyWorldIntent[]; + events: readonly PondWorldEvent[]; + debug: { + seed: number; + }; +}; + +export function pondDistance(a: PondPoint, b: PondPoint) { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +export function pondLerp(a: number, b: number, amount: number) { + return a + (b - a) * amount; +} + +export function pondClamp(value: number, minimum: number, maximum: number) { + return Math.max(minimum, Math.min(maximum, value)); +} + +export function pondSmoothstep(value: number) { + const clamped = pondClamp(value, 0, 1); + return clamped * clamped * (3 - 2 * clamped); +} + +export function pondAngleDelta(from: number, to: number) { + return Math.atan2(Math.sin(to - from), Math.cos(to - from)); +} + +export function pondHashSeed(value: string | number) { + if (typeof value === "number") return value >>> 0; + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function seededRandom(seed: number) { + let state = seed >>> 0; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +} + +export function createPondRandom(seedValue: string | number) { + return seededRandom(pondHashSeed(seedValue)); +} diff --git a/components/concepts/signal-desk/pond-pixel-renderer.ts b/components/concepts/signal-desk/pond-pixel-renderer.ts new file mode 100644 index 0000000..22737bf --- /dev/null +++ b/components/concepts/signal-desk/pond-pixel-renderer.ts @@ -0,0 +1,897 @@ +import * as THREE from "three"; + +import { installPondInput } from "./pond-input.ts"; +import { + createPondRandom, + pondAngleDelta, + type PondPoint, +} from "./pond-model.ts"; +import { + createPondSimulation, + type FishDefinition, + type PondSimulationFrame, +} from "./pond-pixel-simulation.ts"; + +import { + createYDownPondQuad, + createPondSprite, + createPondWater, + loadPondAsset, + PondPixels, + POND_RIPPLE_CAPACITY, + setAtlasFrame, + type PondAsset, + type PondSprite, +} from "./pond-three.ts"; + +type StartOptions = { + pond: HTMLDivElement; + host: HTMLDivElement; + interaction: HTMLDivElement; + reduced: boolean; + canvasClassName: string; + signal: AbortSignal; + onCaptureReady: (capture: () => string) => void; +}; + +type KoiTestPose = { row: number; column: number; heading: number; feedingPulse?: number }; +type AtlasFrameBounds = { top: number; height: number }; + +type Particle = { x: number; y: number; vx: number; vy: number; born: number; life: number; size: number; color: number }; +type Ring = { x: number; y: number; born: number; life: number; radius: number; color: number }; +type Ripple = { x: number; y: number; size: number; strength: number; speed: number; born: number; life: number }; +type AtlasAnimation = { texture: THREE.Texture; frames: readonly number[]; row: number; bounds?: readonly AtlasFrameBounds[]; progress: number; speed: number; frame: number }; +type FishVisual = { + container: THREE.Group; + sprite: PondSprite; + shadow: PondPixels; + animation: AtlasAnimation; + alpha: number; + baseScaleX: number; + lastWake: number; + surfaced: boolean; + status: THREE.Group; + bonk: PondPixels; + stars: PondPixels; +}; +type FlyVisual = { container: THREE.Group; wings: PondPixels }; +type FoodVisual = { container: THREE.Group; group: THREE.Group; pellets: PondPixels[]; shadow: PondPixels }; + +const KOI_FRAME_BOUNDS_PX: readonly (readonly AtlasFrameBounds[])[] = [ + [{ top: 0, height: 300 }, { top: 0, height: 307 }, { top: 0, height: 304 }, { top: 0, height: 308 }], + [{ top: 300, height: 306 }, { top: 307, height: 301 }, { top: 304, height: 304 }, { top: 308, height: 300 }], + [{ top: 606, height: 303 }, { top: 608, height: 303 }, { top: 608, height: 304 }, { top: 608, height: 302 }], + [{ top: 909, height: 347 }, { top: 911, height: 345 }, { top: 912, height: 344 }, { top: 910, height: 346 }], +]; + +export async function startPondRenderer(options: StartOptions) { + const { pond, host, interaction, reduced } = options; + const testWindow = window as Window & { + __POND_TEST_KOI__?: KoiTestPose; + __POND_TEST_DIAGNOSTICS__?: boolean; + }; + const testPose = testWindow.__POND_TEST_KOI__; + const diagnostics = testWindow.__POND_TEST_DIAGNOSTICS__ === true; + if (options.signal.aborted) return () => {}; + host.dataset.pondState = "loading"; + host.dataset.motion = reduced ? "reduced" : "full"; + delete host.dataset.fallbackReason; + if (diagnostics) { + Object.assign(host.dataset, { + rippleCount: "0", + ringCount: "0", + wakeCount: "0", + fishCount: "0", + foodCount: "0", + foodDroppedCount: "0", + foodExpiredCount: "0", + fishFedCount: "0", + primaryImpactCount: "0", + foodAffordance: "false", + pondElementCount: "0", + insectCount: "0", + fishReacting: "false", + fishWaterViolation: "false", + }); + } + + const resources = new Set<{ dispose: () => void }>(); + const loads = new AbortController(); + let disposed = false; + let animationFrame: number | null = null; + let revealFrame = 0; + let disposeInput = () => {}; + let destroySimulation = () => {}; + let releaseRenderer = () => {}; + let removeListeners = () => {}; + const dispose = () => { + if (disposed) return; + disposed = true; + loads.abort(); + options.signal.removeEventListener("abort", dispose); + if (animationFrame !== null) window.cancelAnimationFrame(animationFrame); + window.cancelAnimationFrame(revealFrame); + disposeInput(); + removeListeners(); + destroySimulation(); + for (const resource of resources) resource.dispose(); + resources.clear(); + releaseRenderer(); + }; + const own = void }>(resource: T): T => { + resources.add(resource); + return resource; + }; + const fallback = (reason: string) => { + host.dataset.pondState = "fallback"; + host.dataset.fallbackReason = reason; + pond.dataset.renderer = "fallback"; + dispose(); + }; + options.signal.addEventListener("abort", dispose, { once: true }); + + try { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("webgl2", { + alpha: true, + antialias: false, + powerPreference: "high-performance", + preserveDrawingBuffer: Boolean(testPose), + }); + if (!context) { + fallback("webgl-unavailable"); + return dispose; + } + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ canvas, context, alpha: true, antialias: false }); + } catch (error) { + context.getExtension("WEBGL_lose_context")?.loseContext(); + throw error; + } + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setClearColor(0x000000, 0); + renderer.debug.onShaderError = () => { throw new Error("Unable to compile the pond shader"); }; + const stage = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(0, window.innerWidth, 0, window.innerHeight, 0.1, 100); + camera.position.z = 10; + const contextLost = (event: Event) => { + event.preventDefault(); + fallback("webgl-context-lost"); + }; + canvas.addEventListener("webglcontextlost", contextLost); + releaseRenderer = () => { + canvas.removeEventListener("webglcontextlost", contextLost); + stage.clear(); + renderer.dispose(); + renderer.forceContextLoss(); + canvas.remove(); + }; + const load = async (path: string) => { + const asset = await loadPondAsset(path, loads.signal); + if (disposed) { + asset.texture.dispose(); + throw new DOMException("Pond initialization cancelled", "AbortError"); + } + own(asset.texture); + return asset; + }; + const [pondTexture, waterTexture, koiAtlas, foregroundTexture, pondWaterTexture] = await Promise.all([ + load("pixel-pond-world.webp"), + load("water-displacement.jpg"), + load("pixel-koi-atlas.webp"), + load("pixel-pond-foreground.webp"), + load("pixel-pond-water-layer.webp"), + ]); + loads.signal.throwIfAborted(); + const hitCanvas = document.createElement("canvas"); + hitCanvas.width = pondWaterTexture.width; + hitCanvas.height = pondWaterTexture.height; + const hitContext = hitCanvas.getContext("2d", { willReadFrequently: true }); + if (!hitContext) throw new Error("Unable to create the pond hit map"); + hitContext.drawImage(pondWaterTexture.image, 0, 0); + const hitPixels = hitContext.getImageData(0, 0, hitCanvas.width, hitCanvas.height).data; + + host.replaceChildren(canvas); + canvas.className = options.canvasClassName; + const quad = own(createYDownPondQuad()); + const sprite = (asset: PondAsset, order: number, atlas = false) => { + const texture = atlas ? own(asset.texture.clone()) : asset.texture; + const mesh = createPondSprite(quad, texture, order); + own(mesh.material); + return mesh; + }; + const pixels = (order: number, capacity = 64) => { + const mesh = new PondPixels(order, capacity); + own(mesh.geometry); + own(mesh.material); + return mesh; + }; + const layer = () => { + const group = new THREE.Group(); + stage.add(group); + return group; + }; + const animate = (animation: AtlasAnimation, delta: number) => { + animation.progress = (animation.progress + delta * 60 * animation.speed) % animation.frames.length; + animation.frame = Math.floor(animation.progress); + const column = animation.frames[animation.frame]; + setAtlasFrame(animation.texture, column, animation.row, 4, 4, animation.bounds?.[column]); + }; + const background = sprite(pondTexture, 0); + const foreground = sprite(foregroundTexture, 4); + waterTexture.texture.colorSpace = THREE.NoColorSpace; + waterTexture.texture.wrapS = THREE.RepeatWrapping; + waterTexture.texture.wrapT = THREE.RepeatWrapping; + waterTexture.texture.minFilter = THREE.LinearFilter; + waterTexture.texture.magFilter = THREE.LinearFilter; + const water = createPondWater(quad, pondTexture.texture, pondWaterTexture.texture, waterTexture.texture); + own(water.mesh.material); + const fishLayer = layer(); + stage.add(background, water.mesh, foreground); + let viewScale = 1; + + const worldCenterX = () => pondTexture.width / 2 + (window.innerWidth < 720 ? 170 : 0); + const worldToScreen = (position: PondPoint) => ({ + x: window.innerWidth / 2 + (position.x - worldCenterX()) * viewScale, + y: window.innerHeight / 2 + (position.y - pondTexture.height / 2) * viewScale, + }); + const screenToWorld = (x: number, y: number) => ({ + x: worldCenterX() + (x - window.innerWidth / 2) / viewScale, + y: pondTexture.height / 2 + (y - window.innerHeight / 2) / viewScale, + }); + const isWaterWorld = (x: number, y: number) => { + const pixelX = Math.round(x); + const pixelY = Math.round(y); + return pixelX >= 0 && pixelY >= 0 && pixelX < hitCanvas.width && pixelY < hitCanvas.height && + hitPixels[(pixelY * hitCanvas.width + pixelX) * 4 + 3] > 96; + }; + const isWaterScreen = (x: number, y: number) => { + const world = screenToWorld(x, y); + return isWaterWorld(world.x, world.y); + }; + const nearestWater = (position: PondPoint) => { + if (isWaterWorld(position.x, position.y)) return position; + for (let radius = 12; radius <= 240; radius += 12) { + for (let sample = 0; sample < 24; sample += 1) { + const angle = sample / 24 * Math.PI * 2; + const candidate = { x: position.x + Math.cos(angle) * radius, y: position.y + Math.sin(angle) * radius }; + if (isWaterWorld(candidate.x, candidate.y)) return candidate; + } + } + return { x: pondTexture.width / 2, y: pondTexture.height / 2 }; + }; + + const layout = () => { + viewScale = Math.max(window.innerWidth / pondTexture.width, window.innerHeight / pondTexture.height) * 1.015; + const position = { + x: window.innerWidth / 2 - (worldCenterX() - pondTexture.width / 2) * viewScale, + y: window.innerHeight / 2, + }; + for (const mesh of [background, water.mesh, foreground]) { + mesh.position.set(position.x, position.y, 0); + mesh.scale.set(pondTexture.width * viewScale, pondTexture.height * viewScale, 1); + } + water.uniforms.imageSize.value.set(pondTexture.width * viewScale, pondTexture.height * viewScale); + water.uniforms.imageOrigin.value.set(position.x - pondTexture.width * viewScale / 2, position.y - pondTexture.height * viewScale / 2); + const displacementScale = Math.max(window.innerWidth / waterTexture.width, window.innerHeight / waterTexture.height) * 1.72; + water.uniforms.noiseSize.value.set(waterTexture.width * displacementScale, waterTexture.height * displacementScale); + water.uniforms.viewportCenter.value.set(window.innerWidth / 2, window.innerHeight / 2); + camera.right = window.innerWidth; + camera.bottom = window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, Math.max(0.65, Math.min(1, 1040 / window.innerWidth)))); + renderer.setSize(window.innerWidth, window.innerHeight, false); + }; + layout(); + + const fishSource = [ + [0, 82, 0.78, 0.18, 2.2, 17, 0.94], [1, 66, 0.22, 0.74, -0.7, 13, 0.86], + [2, 52, 0.58, 0.28, 2.7, 11, 0.68], [3, 74, 0.83, 0.68, -2.4, 15, 0.87], + [0, 48, 0.36, 0.12, 1.1, 10, 0.67], [1, 58, 0.12, 0.47, 0.2, 12, 0.76], + [2, 70, 0.64, 0.82, -1.4, 14, 0.82], [3, 46, 0.42, 0.63, 2.9, 9, 0.64], + [0, 44, 0.72, 0.48, -2.1, 10, 0.62], [1, 62, 0.48, 0.9, -0.9, 12, 0.78], + [2, 40, 0.31, 0.34, 1.7, 9, 0.58], [3, 56, 0.9, 0.8, -2.7, 11, 0.74], + [0, 50, 0.68, 0.58, 0.8, 11, 0.7], [1, 46, 0.86, 0.42, -1.9, 10, 0.66], + [2, 60, 0.76, 0.34, -0.2, 14, 0.76], [3, 54, 0.92, 0.57, 2.4, 12, 0.7], + [0, 68, 0.56, 0.72, -1.1, 15, 0.82], [1, 50, 0.25, 0.2, 0.6, 11, 0.68], + [2, 58, 0.8, 0.86, -2.2, 13, 0.74], [3, 42, 0.18, 0.62, 1.9, 10, 0.62], + ] as const; + const mobileScale = window.innerWidth < 720 ? 0.8 : 1; + const fishDefinitions: FishDefinition[] = (testPose + ? [[testPose.row, 160, 0.74, 0.45, testPose.heading, 0, 1] as const] + : fishSource.slice(0, window.innerWidth < 720 ? 14 : 20)) + .map(([row, displayWidth, x, y, heading, cruise, alpha], index) => ({ + id: `fish-${index}`, + row, + displayWidth: displayWidth * mobileScale, + position: nearestWater({ + x: worldCenterX() + (x - 0.5) * (window.innerWidth / viewScale) * 0.88, + y: pondTexture.height / 2 + (y - 0.5) * (window.innerHeight / viewScale) * 0.88, + }), + heading, + cruise: Math.round(cruise * 1.8 + 5), + alpha, + species: ["kohaku", "ogon", "showa", "utsuri"][row], + })); + if (testPose) { + const point = worldToScreen(fishDefinitions[0].position); + if (diagnostics) host.dataset.koiTest = JSON.stringify({ + row: testPose.row, + column: testPose.column, + x: point.x, + y: point.y, + size: fishDefinitions[0].displayWidth * viewScale, + }); + } + const pondSeed = new URLSearchParams(window.location.search).get("pond-seed")?.slice(0, 128).trim() || "6c75"; + if (diagnostics) host.dataset.pondSeed = pondSeed; + const simulation = createPondSimulation({ + seed: pondSeed, + width: pondTexture.width, + height: pondTexture.height, + fish: fishDefinitions, + flies: [ + { id: "fly-0", position: screenToWorld(window.innerWidth * 0.72, window.innerHeight * 0.25), orbitX: 240, orbitY: 90, phase: 0.4, speed: 0.021, color: 0x56d7c8 }, + { id: "fly-1", position: screenToWorld(window.innerWidth * 0.33, window.innerHeight * 0.36), orbitX: 190, orbitY: 115, phase: 2.7, speed: 0.019, color: 0xe8a64f }, + ], + isWater: isWaterWorld, + }); + destroySimulation = () => simulation.destroy(); + const visualRandom = createPondRandom(`${pondSeed}:render`); + + const sourceCellHeight = koiAtlas.height / 4; + const fishVisuals = new Map(); + for (const fish of fishDefinitions) { + const fishSprite = sprite(koiAtlas, 2, true); + fishSprite.scale.set(fish.displayWidth, fish.displayWidth, 1); + const animation: AtlasAnimation = { + texture: fishSprite.material.map!, frames: testPose ? [testPose.column] : [1, 0, 1, 2, 3, 2], row: fish.row, + bounds: KOI_FRAME_BOUNDS_PX[fish.row], + speed: 0.038 + fish.row * 0.003, progress: fish.row % 6, frame: 0, + }; + animate(animation, 0); + const shadow = pixels(2); + shadow.ellipse(3, 7, fish.displayWidth * 0.14, fish.displayWidth * 0.31, 0x062e30, 0.2); + shadow.material.blending = THREE.MultiplyBlending; + shadow.material.premultipliedAlpha = true; + const container = new THREE.Group(); + container.add(shadow, fishSprite); + fishLayer.add(container); + const bonk = pixels(9, 180); + const glyphs = [ + ["010", "111", "010", "101", "000"], + ["100", "100", "110", "101", "110"], + ["000", "000", "010", "101", "010"], + ["000", "000", "110", "101", "101"], + ["100", "101", "110", "101", "101"], + ["010", "111", "010", "101", "000"], + ]; + for (const shadowPass of [true, false]) { + glyphs.forEach((glyph, letter) => glyph.forEach((row, y) => { + for (let x = 0; x < row.length; x += 1) { + if (row[x] === "1") bonk.rectangle((letter * 4 + x - 11.5) * 2 + (shadowPass ? 1 : 0), + y * 2 + (shadowPass ? 1 : 0), 2, 2, shadowPass ? 0x173d3c : 0xffefb0); + } + })); + } + const stars = pixels(9.1, 24); + const status = new THREE.Group(); + status.add(bonk, stars); + status.visible = false; + stage.add(status); + fishVisuals.set(fish.id, { + container, sprite: fishSprite, shadow, animation, alpha: fish.alpha, + baseScaleX: fish.displayWidth, + lastWake: 0, surfaced: false, status, bonk, stars, + }); + } + + const insectLayer = layer(); + const flyVisuals = new Map(); + for (const fly of [ + { id: "fly-0", color: 0x56d7c8 }, + { id: "fly-1", color: 0xe8a64f }, + ]) { + const wings = pixels(6); + wings.rectangle(-6, -9, 10, 3, 0xe3fff5, 0.78); + wings.rectangle(-6, 6, 10, 3, 0xe3fff5, 0.78); + wings.rectangle(-2, -6, 8, 2, 0x9de9df, 0.66); + wings.rectangle(-2, 4, 8, 2, 0x9de9df, 0.66); + wings.rectangle(-5, -8, 6, 1, 0xffffff, 0.82); + wings.rectangle(-5, 7, 6, 1, 0xffffff, 0.82); + const body = pixels(6); + for (let segment = 0; segment < 5; segment += 1) { + body.rectangle(-14 + segment * 3, -1, 2, 2, segment % 2 ? 0x173d3c : fly.color); + } + body.rectangle(0, -2, 7, 4, fly.color); + body.rectangle(6, -3, 3, 2, 0x173d3c); + body.rectangle(6, 1, 3, 2, 0x173d3c); + body.rectangle(7, -2, 1, 1, 0xf4fff1); + body.rectangle(7, 1, 1, 1, 0xf4fff1); + const container = new THREE.Group(); + container.add(wings, body); + insectLayer.add(container); + flyVisuals.set(fly.id, { container, wings }); + } + const midgeLayer = pixels(6); + insectLayer.add(midgeLayer); + const midges = Array.from({ length: reduced ? 5 : 12 }, (_, index) => ({ + position: { x: 170 + (index * 113) % 1220, y: 130 + (index * 173) % 680 }, + phase: index * 1.73, + orbit: 7 + index % 5, + })); + + const ambientLayer = layer(); + const pondElements = [ + { x: 837, y: 98 }, { x: 955, y: 126 }, { x: 1007, y: 200 }, { x: 1129, y: 206 }, + { x: 1192, y: 200 }, { x: 1252, y: 207 }, { x: 1120, y: 480 }, { x: 1230, y: 630 }, + { x: 922, y: 738 }, { x: 1050, y: 835 }, + ].map((position, index) => { + const mark = pixels(4.2); + mark.rectangle(-5, -2, 8, 2, 0xdff7a5, 0.2); + mark.rectangle(4, -1, 3, 1, 0xf4efb1, 0.32); + ambientLayer.add(mark); + return { position, phase: index * 0.77, container: mark, offset: { x: 0, y: 0 } }; + }); + + const particleLayer = pixels(3.2, 2048); + stage.add(particleLayer); + let particles: Particle[] = []; + let rings: Ring[] = []; + let ripples: Ripple[] = []; + const addParticle = (x: number, y: number, vx: number, vy: number, life = 760, size = 2, color = 0xc8fff0) => { + particles.push({ x, y, vx, vy, born: performance.now(), life, size, color }); + if (particles.length > (reduced ? 32 : 120)) particles = particles.slice(reduced ? -32 : -120); + if (diagnostics) host.dataset.wakeCount = String(particles.length); + }; + const addRing = (x: number, y: number, radius = 34, life = 620, color = 0xb9f5e7) => { + rings.push({ x, y, born: performance.now(), life, radius, color }); + if (rings.length > (reduced ? 4 : 10)) rings = rings.slice(reduced ? -4 : -10); + if (diagnostics) host.dataset.ringCount = String(rings.length); + }; + const addRipple = (x: number, y: number, size = 132, strength = 1) => { + const speed = 145 + strength * 45; + ripples.push({ x, y, size, strength, speed, born: performance.now(), life: size / speed * 1000 + 180 }); + if (ripples.length > POND_RIPPLE_CAPACITY) ripples.shift(); + if (diagnostics) host.dataset.rippleCount = String(ripples.length); + }; + + const foodLayer = layer(); + const foodVisuals = new Map(); + const createFoodVisual = (id: string) => { + const shadow = pixels(3.4); + shadow.ellipse(0, 3, 7, 3, 0x062d2c, 0.24); + shadow.material.blending = THREE.MultiplyBlending; + shadow.material.premultipliedAlpha = true; + const group = new THREE.Group(); + const pellets = Array.from({ length: 9 }, (_, index) => { + const pellet = pixels(3.4, 1); + const angle = index * 2.399; + const radius = index === 0 ? 0 : 2 + index % 3 * 2; + pellet.rectangle(-1, -1, index % 4 === 0 ? 3 : 2, 2, + index % 3 === 0 ? 0xe4bb68 : index % 3 === 1 ? 0xb97a43 : 0xf0d184, 0.96); + pellet.position.set(Math.cos(angle) * radius, Math.sin(angle) * radius * 0.72, 0); + group.add(pellet); + return pellet; + }); + const container = new THREE.Group(); + container.add(shadow, group); + foodLayer.add(container); + const visual = { container, group, pellets, shadow }; + foodVisuals.set(id, visual); + return visual; + }; + const feedAffordance = pixels(7); + feedAffordance.rectangle(-8, -1, 4, 2, 0xffefab, 0.72); + feedAffordance.rectangle(5, -1, 4, 2, 0xffefab, 0.72); + feedAffordance.rectangle(-1, -8, 2, 4, 0xd7fff0, 0.64); + feedAffordance.rectangle(-1, 5, 2, 4, 0xd7fff0, 0.64); + feedAffordance.material.opacity = 0; + stage.add(feedAffordance); + let affordanceTarget = 0; + + let pointer = { + screen: { x: -1000, y: -1000 }, world: { x: -1000, y: -1000 }, + velocity: { x: 0, y: 0 }, time: 0, energy: 0, + }; + let lastTrailRing = 0; + let primaryImpactCount = 0; + let lastFoodRequested: PondPoint | null = null; + let lastFoodDropped: PondPoint | null = null; + let maxFoodCount = 0; + let touchGesture = "none"; + disposeInput = installPondInput({ + interaction, + toWorld: screenToWorld, + isWater: isWaterScreen, + onPointer: (screen, world, velocity, energy) => { + const now = performance.now(); + const distance = Math.hypot(screen.x - pointer.screen.x, screen.y - pointer.screen.y); + if (energy > 0 && pointer.time && distance > 4) { + const count = Math.min(5, Math.max(1, Math.floor(distance / 18))); + for (let index = 0; index < count; index += 1) { + const progress = (index + 1) / (count + 1); + addParticle( + pointer.screen.x + (screen.x - pointer.screen.x) * progress, + pointer.screen.y + (screen.y - pointer.screen.y) * progress, + (visualRandom() - 0.5) * 10, + -5 - visualRandom() * 8, + 520 + visualRandom() * 280, + visualRandom() > 0.72 ? 3 : 2, + ); + } + if (now - lastTrailRing > 82) { + addRing(screen.x, screen.y); + lastTrailRing = now; + } + } + pointer = { screen, world, velocity, time: energy > 0 ? now : 0, energy }; + }, + onPrimaryImpact: (screen) => { + primaryImpactCount += 1; + addRipple(screen.x, screen.y, reduced ? 72 : 132, reduced ? 0.28 : 1); + addRing(screen.x, screen.y, reduced ? 36 : 72, reduced ? 620 : 920, 0xffe8a0); + const count = reduced ? 4 : 14; + for (let index = 0; index < count; index += 1) { + const angle = index / count * Math.PI * 2; + const speed = 18 + index % 3 * 5; + addParticle(screen.x, screen.y, Math.cos(angle) * speed, Math.sin(angle) * speed, 680, index % 4 === 0 ? 3 : 2); + } + }, + onFoodDrop: (_screen, world) => { + if (diagnostics) lastFoodRequested = { ...world }; + simulation.dropFood(world); + }, + onAffordance: (screen, visible) => { + feedAffordance.position.set(screen.x, screen.y, 0); + affordanceTarget = visible ? 0.34 : 0; + if (diagnostics) host.dataset.foodAffordance = String(visible); + }, + onGesture: (gesture) => { touchGesture = gesture; }, + }); + + const motionScale = reduced ? 0.2 : 1; + let simulationNow = performance.now(); + let frame: PondSimulationFrame | null = null; + let frameCount = 0; + let foodDroppedCount = 0; + let foodExpiredCount = 0; + let fishFedCount = 0; + let revealed = false; + const current = { ax: 0, ay: 0, bx: 0, by: 0 }; + + const step = (now: number, delta: number) => { + const simulationDelta = delta * motionScale; + simulationNow += simulationDelta * 1000; + const pointerInfluence = pointer.time === 0 ? 0 : Math.max(0, 1 - (now - pointer.time) / 1400); + frame = simulation.step({ + now: simulationNow, + delta: simulationDelta, + pointer: { + position: pointer.world, + velocity: { x: pointer.velocity.x / viewScale, y: pointer.velocity.y / viewScale }, + influence: pointerInfluence, + energy: pointer.energy, + }, + }); + const { environment } = frame; + + current.ax += environment.currentA.x * simulationDelta; + current.ay += environment.currentA.y * simulationDelta; + current.bx += environment.currentB.x * simulationDelta; + current.by += environment.currentB.y * simulationDelta; + water.uniforms.angles.value.x += (environment.currentA.y - environment.currentA.x) * simulationDelta * 0.0009; + water.uniforms.angles.value.y += (environment.currentB.x + environment.currentB.y) * simulationDelta * 0.0007; + const waterEnergy = pointerInfluence * pointer.energy; + water.uniforms.currentA.value.set(current.ax, current.ay, + environment.surfaceA.x + waterEnergy * 2.6 * motionScale, + environment.surfaceA.y + waterEnergy * 1.9 * motionScale); + water.uniforms.currentB.value.set(current.bx, current.by, + environment.surfaceB.x - waterEnergy * 1.25 * motionScale, + environment.surfaceB.y + waterEnergy * motionScale); + water.uniforms.glow.value += (0.055 + environment.light * 0.34 - water.uniforms.glow.value) * Math.min(1, delta * 0.42); + water.uniforms.finger.value.set(pointer.screen.x, pointer.screen.y, + 140 + waterEnergy * 80, waterEnergy > 0.005 ? -0.105 * waterEnergy * motionScale : 0); + + for (const event of frame.events) { + if (event.type === "ambient-ripple") { + const point = worldToScreen(event.position); + if (isWaterScreen(point.x, point.y)) { + addRing(point.x, point.y, 20 + event.strength * 22, 1100, 0xb4e8d5); + if (!reduced) addRipple(point.x, point.y, 65, event.strength * 0.3); + } + } else if (event.type === "food-dropped") { + foodDroppedCount += 1; + if (diagnostics) lastFoodDropped = { ...event.position }; + const point = worldToScreen(event.position); + addRipple(point.x, point.y, reduced ? 42 : 72, event.rippleStrength * (reduced ? 0.32 : 1)); + addRing(point.x, point.y, reduced ? 18 : 30, reduced ? 460 : 720, 0xf2d58a); + } else if (event.type === "fish-fed") { + fishFedCount += 1; + const point = worldToScreen(event.position); + addRing(point.x, point.y, 13, 390, 0xe8d38d); + addParticle(point.x, point.y, 0, -7, 340, 2, 0xffe9a8); + } else if (event.type === "food-expired") { + foodExpiredCount += 1; + } + } + + const liveFood = new Set(frame.foods.map((food) => food.id)); + maxFoodCount = Math.max(maxFoodCount, frame.foods.length); + for (const food of frame.foods) { + const visual = foodVisuals.get(food.id) ?? createFoodVisual(food.id); + const point = worldToScreen(food.position); + const fall = Math.pow(1 - food.dropProgress, 2) * (reduced ? 5 : 22); + visual.container.position.set(point.x, point.y, 0); + visual.group.position.y = -fall; + visual.group.scale.setScalar((0.76 + food.pelletSize / 13) * (food.state === "depleted" ? 0.72 : 1)); + visual.shadow.material.opacity = food.state === "dropping" ? 0.12 + food.dropProgress * 0.5 : 0.7; + visual.pellets.forEach((pellet, index) => { pellet.visible = index < Math.ceil(food.remainingAmount); }); + } + for (const [id, visual] of foodVisuals) { + if (!liveFood.has(id)) { + visual.container.removeFromParent(); + for (const mesh of [visual.shadow, ...visual.pellets]) { + mesh.geometry.dispose(); + mesh.material.dispose(); + resources.delete(mesh.geometry); + resources.delete(mesh.material); + } + foodVisuals.delete(id); + } + } + feedAffordance.material.opacity += (affordanceTarget - feedAffordance.material.opacity) * Math.min(1, delta * 8); + feedAffordance.rotation.z += environment.wind * delta * 0.025; + + + for (const element of pondElements) { + const base = worldToScreen(element.position); + const dx = base.x - pointer.screen.x; + const dy = base.y - pointer.screen.y; + const distance = Math.max(1, Math.hypot(dx, dy)); + const reaction = pointerInfluence * Math.max(0, 1 - distance / 145); + const drift = Math.sin(simulationNow * 0.0012 + element.phase); + const targetX = dx / distance * reaction * 6 + environment.wind * 2.5 + drift * 2; + const targetY = dy / distance * reaction * 3.5 + Math.cos(simulationNow * 0.0009 + element.phase) * 1.5; + element.offset.x += (targetX - element.offset.x) * Math.min(1, delta * 5.2); + element.offset.y += (targetY - element.offset.y) * Math.min(1, delta * 5.2); + element.container.position.set(Math.round(base.x + element.offset.x), Math.round(base.y + element.offset.y), 0); + element.container.rotation.z = element.offset.x * 0.008; + element.container.material.opacity = 0.38 + drift * 0.18 + reaction * 0.34; + } + + midgeLayer.clear(); + for (const midge of midges) { + const base = worldToScreen(midge.position); + const x = Math.round(base.x + Math.sin(simulationNow * 0.0011 + midge.phase) * midge.orbit + environment.wind * 2); + const y = Math.round(base.y + Math.cos(simulationNow * 0.00145 + midge.phase * 1.3) * midge.orbit * 0.65); + midgeLayer.rectangle(x, y, 2, 2, 0x172f2d, 0.8); + } + for (const fly of frame.flies) { + const visual = flyVisuals.get(fly.id); + if (!visual) continue; + const point = worldToScreen(fly.position); + visual.container.position.set(point.x, point.y, 0); + visual.container.scale.setScalar(1 + fly.lift * 0.008); + const speed = Math.hypot(fly.velocity.x, fly.velocity.y); + if (speed > 2) visual.container.rotation.z += pondAngleDelta(visual.container.rotation.z, fly.heading) * (1 - Math.exp(-delta * 8)); + visual.wings.scale.y = 0.35 + Math.abs(Math.sin(fly.wingPhase)) * fly.wingActivity * (fly.reacting ? 1.1 : 0.75); + } + + for (const fish of frame.fish) { + const visual = fishVisuals.get(fish.id); + if (!visual) continue; + const point = worldToScreen(testPose ? fishDefinitions[0].position : fish.position); + visual.container.position.set(point.x, point.y, 0); + const order = 2 + point.y / (1 + Math.abs(point.y)) * 0.1; + visual.shadow.renderOrder = order; + visual.sprite.renderOrder = order + 1e-9; + const speed = Math.hypot(fish.velocity.x, fish.velocity.y); + visual.container.rotation.z = (testPose?.heading ?? fish.heading) + Math.PI / 2; + const effort = Math.min(2.5, fish.speedScale); + visual.animation.speed = (0.012 + effort * 0.025) * motionScale; + const depthAlpha = testPose ? 1 : Math.min(0.98, fish.alpha + (0.5 - fish.depth) * 0.55); + visual.alpha = testPose ? 1 : visual.alpha + (depthAlpha - visual.alpha) * Math.min(1, delta * 4); + visual.sprite.material.opacity = visual.alpha; + visual.shadow.material.opacity = visual.alpha; + visual.sprite.material.color.setHex(fish.stun ? 0xd4d5ef : fish.state === "feeding" ? 0xf4fff3 : 0xe8fff9); + animate(visual.animation, delta); + const feedingPulse = testPose?.feedingPulse ?? (fish.state === "feeding" ? fish.feedingPulse : null); + const peck = feedingPulse === null ? 1 : 1 - Math.abs(feedingPulse * 2 - 1) * 0.055; + visual.sprite.rotation.z = 0; + const depthScale = testPose ? 1 : 0.9 + (1 - fish.depth) * 0.18; + const frameBounds = KOI_FRAME_BOUNDS_PX[fish.row][visual.animation.frames[visual.animation.frame]]; + const frameCenter = frameBounds.top + frameBounds.height / 2; + const sourceCellCenter = (fish.row + 0.5) * sourceCellHeight; + const pixelScale = visual.baseScaleX * viewScale * depthScale / sourceCellHeight; + visual.sprite.position.y = (frameCenter - sourceCellCenter) * pixelScale * (2 - peck); + visual.sprite.scale.set(visual.baseScaleX * viewScale * peck * depthScale, frameBounds.height * pixelScale * (2 - peck), 1); + visual.shadow.scale.setScalar((0.86 + fish.depth * 0.24) * viewScale); + visual.status.visible = fish.stun !== null; + if (fish.stun) { + const headOffset = fish.displayWidth * viewScale * depthScale * 0.43; + visual.status.position.set(point.x + Math.cos(fish.heading) * headOffset, + point.y + Math.sin(fish.heading) * headOffset - 14, 0); + visual.bonk.position.y = -22 - Math.min(1, fish.stun.elapsed / 1.3) * (reduced ? 0 : 10); + visual.bonk.material.opacity = Math.max(0, 1 - fish.stun.elapsed / 1.3); + visual.stars.clear(); + for (let index = 0; index < 3; index += 1) { + const angle = index / 3 * Math.PI * 2 + (reduced ? 0 : fish.stun.elapsed * 4); + const x = Math.round(Math.cos(angle) * 15); + const y = Math.round(Math.sin(angle) * 6); + visual.stars.rectangle(x - 1, y - 4, 2, 3, 0xffdb72); + visual.stars.rectangle(x - 5, y - 1, 10, 2, 0xffdb72); + visual.stars.rectangle(x - 3, y + 1, 6, 2, 0xffdb72); + visual.stars.rectangle(x - 4, y + 3, 2, 2, 0xffdb72); + visual.stars.rectangle(x + 2, y + 3, 2, 2, 0xffdb72); + visual.stars.rectangle(x - 1, y - 1, 2, 2, 0xfff8d3); + } + visual.stars.material.opacity = Math.min(1, fish.stun.remaining / 0.35); + } + const surfaced = fish.depth < 0.18; + if (surfaced && !visual.surfaced && !reduced) { + addRing(point.x, point.y, fish.displayWidth * 0.34, 1100, 0xd9fff0); + addRipple(point.x, point.y, fish.displayWidth * 1.3, 0.45); + } + visual.surfaced = surfaced; + if (!reduced && speed > 9 && (fish.depth < 0.4 || fish.reacting) && now - visual.lastWake > 240 / Math.max(0.65, effort)) { + const tail = worldToScreen({ + x: fish.position.x - Math.cos(fish.heading) * fish.displayWidth * 0.22, + y: fish.position.y - Math.sin(fish.heading) * fish.displayWidth * 0.22, + }); + for (const side of [-1, 1]) { + addParticle(tail.x, tail.y, + -Math.cos(fish.heading) * speed * 0.2 + Math.sin(fish.heading) * side * 9, + -Math.sin(fish.heading) * speed * 0.2 - Math.cos(fish.heading) * side * 9, + 850, 2, 0x9be4d7); + } + visual.lastWake = now; + } + } + + particleLayer.clear(); + particles = particles.filter((particle) => { + const age = now - particle.born; + if (age >= particle.life) return false; + particle.x += particle.vx * delta; + particle.y += particle.vy * delta; + particle.vx *= Math.pow(0.92, delta * 60); + particle.vy *= Math.pow(0.92, delta * 60); + particleLayer.rectangle(Math.round(particle.x), Math.round(particle.y), particle.size, particle.size, + particle.color, Math.max(0, 1 - age / particle.life) * 0.72); + return true; + }); + rings = rings.filter((ring) => { + const age = now - ring.born; + if (age >= ring.life) return false; + const progress = age / ring.life; + const radius = 4 + ring.radius * (1 - Math.pow(1 - progress, 2)); + const alpha = Math.sin(progress * Math.PI) * 0.68; + const points = Math.max(16, Math.round(radius * 0.55)); + for (let index = 0; index < points; index += 2) { + const angle = index / points * Math.PI * 2; + particleLayer.rectangle( + Math.round(ring.x + Math.cos(angle) * radius), + Math.round(ring.y + Math.sin(angle) * radius * 0.72), + progress < 0.5 ? 3 : 2, + 2, ring.color, alpha, + ); + } + return true; + }); + ripples = ripples.filter((ripple) => now - ripple.born < ripple.life); + for (let index = 0; index < POND_RIPPLE_CAPACITY; index += 1) { + const ripple = ripples[index]; + if (ripple) { + water.uniforms.rippleCenters.value[index].set(ripple.x, ripple.y, ripple.size, 1.2 + ripple.strength * 3.8); + const age = (now - ripple.born) / 1000; + water.uniforms.rippleWaves.value[index].set(age, ripple.speed, 22 + ripple.size * 0.16, Math.exp(-age * 1.4)); + } else { + water.uniforms.rippleWaves.value[index].w = 0; + } + } + + frameCount += 1; + if (diagnostics && frameCount % 12 === 0) { + host.dataset.fishPositions = frame.fish.map((fish) => { + const point = worldToScreen(fish.position); + return `${point.x.toFixed(1)},${point.y.toFixed(1)}`; + }).join(";"); + host.dataset.fishWorldPositions = frame.fish.map((fish) => `${fish.position.x.toFixed(2)},${fish.position.y.toFixed(2)}`).join(";"); + host.dataset.fishReacting = String(frame.fish.some((fish) => fish.reacting)); + host.dataset.fishActivities = frame.fish.map((fish) => `${fish.id}:${fish.activity}`).join(";"); + host.dataset.fishStuns = frame.fish.flatMap((fish) => fish.stun + ? [`${fish.id}:${fish.stun.remaining.toFixed(2)}:${fish.stun.serial}`] : []).join(";"); + host.dataset.fishHeadings = frame.fish.map((fish) => fish.heading.toFixed(3)).join(";"); + host.dataset.fishWaterViolation = String(frame.fish.some((fish) => !isWaterWorld(fish.position.x, fish.position.y))); + host.dataset.fishMaxStep = Math.max(...frame.fish.map((fish) => fish.maxStep)).toFixed(2); + host.dataset.fishAverageSpeed = ( + frame.fish.reduce((sum, fish) => sum + Math.hypot(fish.velocity.x, fish.velocity.y), 0) / + Math.max(1, frame.fish.length) + ).toFixed(2); + host.dataset.rippleCount = String(ripples.length); + host.dataset.wakeCount = String(particles.length); + host.dataset.ringCount = String(rings.length); + host.dataset.foodCount = String(frame.foods.length); + host.dataset.foodMaxCount = String(maxFoodCount); + host.dataset.foodDroppedCount = String(foodDroppedCount); + host.dataset.foodExpiredCount = String(foodExpiredCount); + host.dataset.fishFedCount = String(fishFedCount); + host.dataset.primaryImpactCount = String(primaryImpactCount); + host.dataset.fishFoodStates = frame.fish.map((fish) => `${fish.id}:${fish.state}:${fish.foodId ?? "none"}`).join(";"); + host.dataset.foodEntities = frame.foods.map((food) => `${food.id}:${food.position.x.toFixed(2)},${food.position.y.toFixed(2)}:${food.state}:${food.remainingAmount}`).join(";"); + if (lastFoodRequested) host.dataset.foodRequestedAt = `${lastFoodRequested.x.toFixed(2)},${lastFoodRequested.y.toFixed(2)}`; + if (lastFoodDropped) host.dataset.foodLastDrop = `${lastFoodDropped.x.toFixed(2)},${lastFoodDropped.y.toFixed(2)}`; + host.dataset.foodReservations = frame.fish.filter((fish) => fish.reserved).map((fish) => `${fish.id}:${fish.foodId}`).join(";"); + host.dataset.pondCamera = `${worldCenterX().toFixed(3)},${(pondTexture.height / 2).toFixed(3)},${viewScale.toFixed(5)},${viewScale.toFixed(5)}`; + host.dataset.touchGesture = touchGesture; + host.dataset.frame = String(frameCount); + host.dataset.waterOffset = `${(window.innerWidth / 2 + current.ax).toFixed(2)},${(window.innerHeight / 2 + current.ay).toFixed(2)};${water.uniforms.currentA.value.z.toFixed(2)},${water.uniforms.currentB.value.w.toFixed(2)}`; + } + }; + + if (testPose) { + for (const child of stage.children) child.visible = child === fishLayer; + for (const visual of fishVisuals.values()) visual.shadow.visible = false; + } + + let previousTime: number | null = null; + options.onCaptureReady(() => { + renderer.render(stage, camera); + return canvas.toDataURL(); + }); + const draw = (now: number) => { + animationFrame = null; + if (disposed || document.hidden) return; + const delta = previousTime === null ? 1 / 60 : Math.min(Math.max((now - previousTime) / 1000, 0), 0.08); + previousTime = now; + try { + step(now, delta); + renderer.render(stage, camera); + } catch (error) { + fallback(error instanceof Error ? error.message : "renderer-frame-failed"); + return; + } + if (!revealed) { + revealed = true; + revealFrame = window.requestAnimationFrame(() => { + if (!disposed) pond.dataset.renderer = "three"; + }); + } + animationFrame = window.requestAnimationFrame(draw); + }; + const visibility = () => { + if (disposed) return; + previousTime = null; + if (document.hidden) { + if (animationFrame !== null) window.cancelAnimationFrame(animationFrame); + animationFrame = null; + host.dataset.pondState = "paused"; + } else { + if (animationFrame === null) animationFrame = window.requestAnimationFrame(draw); + host.dataset.pondState = "running"; + } + }; + window.addEventListener("resize", layout); + document.addEventListener("visibilitychange", visibility); + removeListeners = () => { + window.removeEventListener("resize", layout); + document.removeEventListener("visibilitychange", visibility); + }; + if (diagnostics) { + host.dataset.fishCount = String(fishDefinitions.length); + host.dataset.pondElementCount = String(pondElements.length); + host.dataset.insectCount = String(midges.length + flyVisuals.size); + host.dataset.fishLogic = "authoritative-seeded-world-steering"; + host.dataset.worldModel = "seeded-food-environment"; + } + visibility(); + return dispose; + } catch (error) { + const cancelled = loads.signal.aborted; + dispose(); + if (!cancelled) throw error; + return dispose; + } +} diff --git a/components/concepts/signal-desk/pond-pixel-simulation.ts b/components/concepts/signal-desk/pond-pixel-simulation.ts new file mode 100644 index 0000000..1a4e7db --- /dev/null +++ b/components/concepts/signal-desk/pond-pixel-simulation.ts @@ -0,0 +1,1201 @@ +import * as YUKA from "yuka"; + +import { + createPondRandom, + pondAngleDelta, + pondClamp, + pondDistance, + type FishFoodStateName, + type FlyRoutineState, + type PondPoint, + type PondTarget, + type PondWorldFrame, +} from "./pond-pixel-model.ts"; +import { createPondWorld } from "./pond-pixel-world.ts"; + +export type PondPointerInput = { + position: PondPoint; + velocity: PondPoint; + influence: number; + energy: number; +}; + +export type FishDefinition = { + id: string; + row: number; + displayWidth: number; + position: PondPoint; + heading: number; + cruise: number; + alpha: number; + species: string; +}; + +export type FlyDefinition = { + id: string; + position: PondPoint; + orbitX: number; + orbitY: number; + phase: number; + speed: number; + color: number; +}; + +export type FishActivity = "swimming" | "gliding" | "darting" | "surfacing"; + +export type FishSimulationFrame = { + id: string; + row: number; + displayWidth: number; + alpha: number; + species: string; + position: PondPoint; + velocity: PondPoint; + heading: number; + swimPhase: number; + turnRate: number; + speedScale: number; + depth: number; + activity: FishActivity; + state: FishFoodStateName; + foodId: string | null; + goal: PondPoint | null; + reserved: boolean; + feedingPulse: number; + reacting: boolean; + maxStep: number; + stun: { remaining: number; elapsed: number; serial: number } | null; +}; + +export type FlySimulationFrame = { + id: string; + position: PondPoint; + velocity: PondPoint; + heading: number; + wingPhase: number; + wingActivity: number; + lift: number; + state: FlyRoutineState; + color: number; + reacting: boolean; +}; + +export type PondSimulationFrame = Omit & { + fish: readonly FishSimulationFrame[]; + flies: readonly FlySimulationFrame[]; +}; + +type FishMotion = { + activity: FishActivity; + activityIndex: number; + activityRemaining: number; + heading: number; + speed: number; + swimPhase: number; + turnRate: number; + steeringTurnRate: number; + depth: number; + cruiseDepth: number; + variation: number; + turnBias: 1 | -1; + threat: number; + escapeHeading: number | null; + shoreHeading: number | null; + shoreRemaining: number; +}; + +type FishAgent = FishDefinition & { + vehicle: YUKA.Vehicle; + pointerFlee: YUKA.FleeBehavior; + homeSeek: YUKA.SeekBehavior; + goalArrive: YUKA.ArriveBehavior; + goalTarget: YUKA.Vector3; + wander: SeededWanderBehavior; + routeGoal: PondPoint; + nextRouteAt: number; + routeVisits: number; + motion: FishMotion; + previous: PondPoint; + maxStep: number; + bodyRadius: number; + stun: FishSimulationFrame["stun"]; + stunSerial: number; + stunHeading: number | null; +}; + +type FishPair = { first: FishAgent; second: FishAgent; side: 1 | -1; touching: boolean; passing: boolean }; +type FishMove = { position: PondPoint; heading: number }; + +type FlyAgent = FlyDefinition & { + worldPosition: PondPoint; + velocity: PondPoint; + motion: { + heading: number; + hoverPhase: number; + wingPhase: number; + wingActivity: number; + lift: number; + variation: number; + reacting: boolean; + }; +}; + +type SimulationOptions = { + seed: string | number; + width: number; + height: number; + fish: readonly FishDefinition[]; + flies: readonly FlyDefinition[]; + isWater: (x: number, y: number) => boolean; +}; + +type SimulationInput = { + now: number; + delta: number; + pointer: PondPointerInput; +}; + +const SHORE_ROUTES: readonly PondPoint[] = [ + { x: 1280, y: 475 }, + { x: 1390, y: 555 }, + { x: 1460, y: 650 }, + { x: 1330, y: 725 }, + { x: 1160, y: 805 }, + { x: 980, y: 760 }, + { x: 1190, y: 610 }, + { x: 1400, y: 810 }, +]; + +const IDLE_ACTIVITIES: readonly FishActivity[] = ["swimming", "gliding", "darting", "surfacing"]; +const IDLE_MOTION: Record = { + swimming: { speed: 1.25, duration: [5, 9] }, + gliding: { speed: 0.38, duration: [3, 5] }, + darting: { speed: 2.5, duration: [1.5, 2.4] }, + surfacing: { speed: 0.65, duration: [2.5, 4] }, +}; + +const TAU = Math.PI * 2; +const STUN_SECONDS = 5; +const CONTACT_GAP = 0.01; +const OPPOSING_COSINE = Math.cos(Math.PI / 6); +const response = (rate: number, delta: number) => 1 - Math.exp(-rate * delta); + +function awayHeading(position: PondPoint, origin: PondPoint, fallback: number) { + return pondDistance(position, origin) > 0.001 + ? Math.atan2(position.y - origin.y, position.x - origin.x) + : fallback; +} + +function bodyDiscs(agent: FishAgent, pose: FishMove) { + return [-0.2, 0, 0.2].map((offset) => ({ + x: pose.position.x + Math.cos(pose.heading) * agent.displayWidth * offset, + y: pose.position.y + Math.sin(pose.heading) * agent.displayWidth * offset, + })); +} + +function fishPose(agent: FishAgent): FishMove { + return { position: { x: agent.vehicle.position.x, y: agent.vehicle.position.z }, heading: agent.motion.heading }; +} + +function bodiesOverlap(first: FishAgent, firstPose: FishMove, second: FishAgent, secondPose: FishMove, gap = CONTACT_GAP) { + const radius = (first.displayWidth + second.displayWidth) * 0.36 + gap; + return bodyDiscs(first, firstPose).some((a) => bodyDiscs(second, secondPose).some((b) => pondDistance(a, b) < radius)); +} + +function sweptDiscContact(a: PondPoint, b: PondPoint, aEnd: PondPoint, bEnd: PondPoint, radius: number) { + const x = b.x - a.x; + const y = b.y - a.y; + const vx = (bEnd.x - b.x) - (aEnd.x - a.x); + const vy = (bEnd.y - b.y) - (aEnd.y - a.y); + const closing = x * vx + y * vy; + const clearance = x * x + y * y - radius * radius; + if (clearance <= 1e-8) return 0; + if (closing >= -1e-10) return null; + const speedSquared = vx * vx + vy * vy; + const discriminant = closing * closing - speedSquared * clearance; + if (discriminant < 0) return null; + const time = clearance / (-closing + Math.sqrt(discriminant)); + return time <= 1 ? time : null; +} + +function sweptBodyDiscContact(pair: FishPair, a: FishMove, b: FishMove, aEnd: FishMove, bEnd: FishMove, firstDisc: number, secondDisc: number) { + const discAt = (agent: FishAgent, start: FishMove, end: FishMove, disc: number, time: number) => { + const heading = start.heading + pondAngleDelta(start.heading, end.heading) * time; + const offset = agent.displayWidth * (disc - 1) * 0.2; + return { + x: start.position.x + (end.position.x - start.position.x) * time + Math.cos(heading) * offset, + y: start.position.y + (end.position.y - start.position.y) * time + Math.sin(heading) * offset, + }; + }; + const sweep = (from: number, to: number): number | null => { + const first = discAt(pair.first, a, aEnd, firstDisc, from); + const second = discAt(pair.second, b, bEnd, secondDisc, from); + const firstEnd = discAt(pair.first, a, aEnd, firstDisc, to); + const secondEnd = discAt(pair.second, b, bEnd, secondDisc, to); + const padding = pair.first.displayWidth * Math.abs(firstDisc - 1) * 0.2 * + (1 - Math.cos(pondAngleDelta(a.heading, aEnd.heading) * (to - from) / 2)) + + pair.second.displayWidth * Math.abs(secondDisc - 1) * 0.2 * + (1 - Math.cos(pondAngleDelta(b.heading, bEnd.heading) * (to - from) / 2)); + const radius = (pair.first.displayWidth + pair.second.displayWidth) * 0.36 + CONTACT_GAP + padding; + const time = sweptDiscContact(first, second, firstEnd, secondEnd, radius); + if (time === null) return null; + const closing = (second.x - first.x) * (secondEnd.x - second.x - firstEnd.x + first.x) + + (second.y - first.y) * (secondEnd.y - second.y - firstEnd.y + first.y); + // Padding bounds the rotating arc, but only the refined body contact can bonk. + if (padding > 1e-7) { + const middle = (from + to) / 2; + return sweep(from, middle) ?? sweep(middle, to); + } + return time > 0 || closing < -1e-10 ? from + (to - from) * time : null; + }; + return sweep(0, 1); +} + +class SeededWanderBehavior extends YUKA.SteeringBehavior { + private readonly random: () => number; + private angle: number; + private targetAngle: number; + private turnIn: number; + + constructor(random: () => number, heading: number) { + super(); + this.random = random; + this.angle = heading; + this.targetAngle = heading; + this.turnIn = 0.7 + random() * 1.8; + } + + calculate(vehicle: YUKA.Vehicle, force: YUKA.Vector3, delta: number) { + this.turnIn -= delta; + if (this.turnIn <= 0) { + this.targetAngle = Math.atan2(vehicle.velocity.z, vehicle.velocity.x) + (this.random() - 0.5) * 1.45; + this.turnIn = 0.9 + this.random() * 2.4; + } + this.angle += pondAngleDelta(this.angle, this.targetAngle) * Math.min(1, delta * 1.7); + return force.set(Math.cos(this.angle) * 2.2, 0, Math.sin(this.angle) * 2.2); + } +} + +function nearestWater( + position: PondPoint, + width: number, + height: number, + isWater: SimulationOptions["isWater"], + clearance = 12, + fallback?: PondPoint, +) { + const hasClearance = (candidate: PondPoint) => { + if (candidate.x < 20 + clearance || candidate.x > width - 20 - clearance || + candidate.y < 20 + clearance || candidate.y > height - 20 - clearance) return false; + if (!isWater(candidate.x, candidate.y)) return false; + for (let sample = 0; sample < 8; sample += 1) { + const angle = sample / 8 * Math.PI * 2; + if (!isWater(candidate.x + Math.cos(angle) * clearance, candidate.y + Math.sin(angle) * clearance)) return false; + } + return true; + }; + if (hasClearance(position)) return { ...position }; + const phase = (position.x * 0.017 + position.y * 0.011) % (Math.PI * 2); + for (let radius = 12; radius <= 260; radius += 12) { + for (let sample = 0; sample < 24; sample += 1) { + const angle = phase + sample / 24 * Math.PI * 2; + const candidate = { + x: pondClamp(position.x + Math.cos(angle) * radius, 30, width - 30), + y: pondClamp(position.y + Math.sin(angle) * radius, 30, height - 30), + }; + if (hasClearance(candidate)) return candidate; + } + } + if (fallback && hasClearance(fallback)) return { ...fallback }; + let closest: PondPoint | null = null; + let closestDistance = Infinity; + for (let x = 20 + clearance; x < width - 20 - clearance; x += 8) { + for (let y = 20 + clearance; y < height - 20 - clearance; y += 8) { + const candidate = { x, y }; + const distance = pondDistance(position, candidate); + if (distance < closestDistance && hasClearance(candidate)) { + closest = candidate; + closestDistance = distance; + } + } + } + if (closest) return closest; + throw new Error("Pond has no water position with fish clearance"); +} + +export function createPondSimulation(options: SimulationOptions) { + const { width, height, isWater } = options; + const random = createPondRandom(`${options.seed}:agents`); + const world = createPondWorld(options.seed); + const manager = new YUKA.EntityManager(); + const pointerAgent = new YUKA.Vehicle(); + const homeTarget = new YUKA.Vector3(width / 2, 0, height / 2); + + const duration = (minimum: number, maximum: number) => minimum + random() * (maximum - minimum); + const routePoint = (agent: FishAgent) => { + const visitShore = agent.routeVisits % 4 === 0; + agent.routeVisits += 1; + const angle = duration(0, TAU); + const radius = duration(170, 420); + const candidate = visitShore ? SHORE_ROUTES[Math.floor(random() * SHORE_ROUTES.length)] : { + x: agent.vehicle.position.x + Math.cos(angle) * radius, + y: agent.vehicle.position.z + Math.sin(angle) * radius, + }; + const goal = nearestWater( + { + x: pondClamp(candidate.x + duration(-46, 46), 60, width - 60), + y: pondClamp(candidate.y + duration(-38, 38), 60, height - 60), + }, + width, + height, + isWater, + agent.bodyRadius, + { x: agent.vehicle.position.x, y: agent.vehicle.position.z }, + ); + const from = { x: agent.vehicle.position.x, y: agent.vehicle.position.z }; + if (waterPath(from, Math.atan2(goal.y - from.y, goal.x - from.x), pondDistance(from, goal), agent.bodyRadius)) return goal; + // A wet destination across an island is still an unreachable local route. + for (let sample = 0; sample < 24; sample += 1) { + const localHeading = angle + sample / 24 * TAU; + for (const length of [160, 90, 45]) { + if (waterPath(from, localHeading, length, agent.bodyRadius)) { + return { x: from.x + Math.cos(localHeading) * length, y: from.y + Math.sin(localHeading) * length }; + } + } + } + return from; + }; + + const fish = options.fish.map((definition): FishAgent => { + // Three discs cover the atlas, including its 1.08 depth scale and stroke flex. + // Their 1.12 by 0.72 cell envelope applies equally to every swimming depth. + const bodyRadius = definition.displayWidth * 0.56; + const initial = nearestWater(definition.position, width, height, isWater, bodyRadius); + const vehicle = new YUKA.Vehicle(); + vehicle.position.set(initial.x, 0, initial.y); + vehicle.velocity.set( + Math.cos(definition.heading) * definition.cruise, + 0, + Math.sin(definition.heading) * definition.cruise, + ); + vehicle.maxSpeed = definition.cruise; + vehicle.maxForce = 7; + vehicle.maxTurnRate = 0.9; + vehicle.neighborhoodRadius = 145; + vehicle.updateNeighborhood = true; + vehicle.smoother = new YUKA.Smoother(10); + + const wander = new SeededWanderBehavior(random, definition.heading); + const pointerFlee = new YUKA.FleeBehavior(pointerAgent.position, 410); + const homeSeek = new YUKA.SeekBehavior(homeTarget); + const goalTarget = new YUKA.Vector3(initial.x, 0, initial.y); + const goalArrive = new YUKA.ArriveBehavior(goalTarget, 2.4, 10); + const separation = new YUKA.SeparationBehavior(); + const alignment = new YUKA.AlignmentBehavior(); + const cohesion = new YUKA.CohesionBehavior(); + wander.weight = 0.68; + separation.weight = 1.7; + alignment.weight = 0.24; + cohesion.weight = 0.11; + pointerFlee.active = false; + pointerFlee.weight = 0; + homeSeek.active = false; + goalArrive.active = true; + goalArrive.weight = 0.5; + vehicle.steering + .add(pointerFlee) + .add(homeSeek) + .add(separation) + .add(goalArrive) + .add(alignment) + .add(cohesion) + .add(wander); + manager.add(vehicle); + + const agent: FishAgent = { + ...definition, + position: initial, + vehicle, + pointerFlee, + homeSeek, + goalArrive, + goalTarget, + wander, + routeGoal: initial, + nextRouteAt: 0, + routeVisits: Math.floor(random() * 4), + motion: { + activity: "swimming", + activityIndex: Math.floor(random() * IDLE_ACTIVITIES.length), + activityRemaining: duration(0.8, 4), + heading: definition.heading, + speed: definition.cruise, + swimPhase: random() * TAU, + turnRate: 0, + steeringTurnRate: 0, + depth: 0.3 + random() * 0.4, + cruiseDepth: 0.3 + random() * 0.4, + variation: duration(0.88, 1.12), + turnBias: random() < 0.5 ? -1 : 1, + threat: 0, + escapeHeading: null, + shoreHeading: null, + shoreRemaining: 0, + }, + previous: initial, + maxStep: 0, + bodyRadius, + stun: null, + stunSerial: 0, + stunHeading: null, + }; + agent.routeGoal = routePoint(agent); + agent.goalTarget.set(agent.routeGoal.x, 0, agent.routeGoal.y); + return agent; + }); + + const pairs: FishPair[] = []; + const orderedFish = [...fish].sort((a, b) => a.id.localeCompare(b.id)); + for (let first = 0; first < orderedFish.length; first += 1) { + for (let second = first + 1; second < orderedFish.length; second += 1) { + pairs.push({ first: orderedFish[first], second: orderedFish[second], side: 1, touching: false, passing: false }); + } + } + + const flies = options.flies.map((definition): FlyAgent => ({ + ...definition, + worldPosition: { ...definition.position }, + velocity: { x: 0, y: 0 }, + motion: { + heading: definition.phase, + hoverPhase: definition.phase, + wingPhase: definition.phase, + wingActivity: 0.45, + lift: 5, + variation: duration(0.88, 1.12), + reacting: false, + }, + })); + + let lastFrame: PondSimulationFrame | null = null; + + function fishTargets(input: SimulationInput): PondTarget[] { + return fish.map((agent) => { + const position = { x: agent.vehicle.position.x, y: agent.vehicle.position.z }; + const pointerDistance = pondDistance(position, input.pointer.position); + const pointerDanger = Math.min( + 1, + input.pointer.influence * Math.max(0, 1 - pointerDistance / 360) * (0.42 + input.pointer.energy * 0.78), + ); + return { + id: agent.id, + type: "fish" as const, + position, + velocity: { x: agent.vehicle.velocity.x, y: agent.vehicle.velocity.z }, + visible: isWater(position.x, position.y), + attackable: true, + interactionRange: Math.max(18, agent.displayWidth * 0.3), + bodyRadius: agent.bodyRadius, + species: agent.species, + danger: pointerDanger, + }; + }); + } + + function flyTargets(input: SimulationInput): PondTarget[] { + return flies.map((agent) => { + const pointerDistance = pondDistance(agent.worldPosition, input.pointer.position); + return { + id: agent.id, + type: "fly" as const, + position: { ...agent.worldPosition }, + velocity: { ...agent.velocity }, + visible: true, + attackable: true, + interactionRange: 12, + danger: input.pointer.influence * Math.max(0, 1 - pointerDistance / 170), + }; + }); + } + + function waterAt(x: number, y: number) { + return x >= 20 && x <= width - 20 && y >= 20 && y <= height - 20 && isWater(x, y); + } + + function waterClearance(point: PondPoint, radius = 0) { + if (!waterAt(point.x, point.y)) return false; + for (let sample = 0; radius > 0 && sample < 12; sample += 1) { + const angle = sample / 12 * TAU; + if (!waterAt(point.x + Math.cos(angle) * radius, point.y + Math.sin(angle) * radius)) return false; + } + return true; + } + + function waterPath(from: PondPoint, heading: number, length: number, radius = 0) { + // Raster shore edges can reject a subpixel step even when a one-unit probe is wet. + for (const probe of [0.025, 0.05, 0.1, 0.25]) { + const distance = Math.min(length, probe); + if (!waterClearance({ x: from.x + Math.cos(heading) * distance, y: from.y + Math.sin(heading) * distance }, radius)) return false; + } + const samples = Math.max(1, Math.ceil(length * 4)); + for (let sample = 1; sample <= samples; sample += 1) { + const distance = length * sample / samples; + if (!waterClearance({ x: from.x + Math.cos(heading) * distance, y: from.y + Math.sin(heading) * distance }, radius)) return false; + } + return true; + } + + function shorelineHeading(agent: FishAgent, heading: number, lookAhead: number) { + const from = agent.previous; + const retained = agent.motion.shoreHeading; + if (retained !== null && waterPath(from, retained, Math.min(lookAhead, Math.max(4, agent.motion.speed * 0.5)), agent.bodyRadius) && + (agent.motion.shoreRemaining > 0 || !waterPath(from, heading, lookAhead, agent.bodyRadius))) return retained; + if (waterPath(from, heading, lookAhead, agent.bodyRadius)) return null; + const basis = retained ?? heading; + for (const angle of [0.45, 0.85, 1.3, 1.85, 2.4, Math.PI]) { + for (const side of [agent.motion.turnBias, -agent.motion.turnBias]) { + const candidate = basis + angle * side; + if (waterPath(from, candidate, lookAhead, agent.bodyRadius)) { + agent.motion.shoreRemaining = 1.4; + return candidate; + } + } + } + let bestHeading = heading; + let bestLength = 0; + let bestTurn = Infinity; + for (let sample = 0; sample < 48; sample += 1) { + const candidate = heading + sample / 48 * TAU * agent.motion.turnBias; + for (const length of [24, 12, 6, 3, 1]) { + if (!waterPath(from, candidate, length, agent.bodyRadius)) continue; + const turn = Math.abs(pondAngleDelta(agent.motion.heading, candidate)); + if (length > bestLength || length === bestLength && turn < bestTurn) { + bestHeading = candidate; + bestLength = length; + bestTurn = turn; + } + break; + } + } + agent.motion.shoreRemaining = 1.4; + return bestHeading; + } + + function moveInWater(agent: FishAgent, delta: number) { + const { motion, previous, vehicle } = agent; + let travelHeading = agent.stun ? motion.shoreHeading ?? agent.stunHeading ?? motion.heading : motion.heading; + const distance = motion.speed * delta; + if (motion.shoreHeading !== null && !waterPath(previous, travelHeading, distance, agent.bodyRadius) && + waterPath(previous, motion.shoreHeading, distance, agent.bodyRadius)) travelHeading = motion.shoreHeading; + const samples = Math.max(1, Math.ceil(distance * 4)); + let fraction = 0; + let checkedDistance = 0; + const clearAt = (length: number) => waterClearance({ + x: previous.x + Math.cos(travelHeading) * length, + y: previous.y + Math.sin(travelHeading) * length, + }, agent.bodyRadius); + for (let sample = 1; sample <= samples; sample += 1) { + const nextFraction = sample / samples; + const nextDistance = distance * nextFraction; + const subpixelProbes = [0.025, 0.05, 0.1, 0.25].filter((probe) => probe > checkedDistance && probe < nextDistance); + if (!subpixelProbes.every(clearAt) || !clearAt(nextDistance)) break; + fraction = nextFraction; + checkedDistance = nextDistance; + } + vehicle.position.set( + previous.x + Math.cos(travelHeading) * distance * fraction, + 0, + previous.y + Math.sin(travelHeading) * distance * fraction, + ); + if (fraction < 1) motion.speed *= fraction; + vehicle.velocity.set(Math.cos(travelHeading) * motion.speed, 0, Math.sin(travelHeading) * motion.speed); + return fraction < 1; + } + + function separateInitialFish() { + const placed: FishAgent[] = []; + for (const agent of orderedFish) { + const pose = fishPose(agent); + const free = (position: PondPoint, escapeLength: number) => waterClearance(position, agent.bodyRadius) && + placed.every((other) => !bodiesOverlap(agent, { position, heading: pose.heading }, other, fishPose(other))) && + Array.from({ length: 24 }, (_, sample) => pose.heading + sample / 24 * TAU) + .some((heading) => waterPath(position, heading, escapeLength, agent.bodyRadius)); + if (!free(pose.position, 60)) { + let replacement: PondPoint | null = null; + for (const escapeLength of [60, 30, 12]) { + for (let radius = 4; !replacement && radius < Math.hypot(width, height); radius += 4) { + for (let sample = 0; sample < 48; sample += 1) { + const heading = pose.heading + sample / 48 * TAU; + const candidate = { x: pose.position.x + Math.cos(heading) * radius, y: pose.position.y + Math.sin(heading) * radius }; + if (free(candidate, escapeLength)) { + replacement = candidate; + break; + } + } + } + if (replacement) break; + } + if (!replacement) throw new Error("Pond cannot fit non-overlapping fish in water"); + agent.vehicle.position.set(replacement.x, 0, replacement.y); + agent.previous = { ...replacement }; + agent.routeGoal = routePoint(agent); + agent.goalTarget.set(agent.routeGoal.x, 0, agent.routeGoal.y); + } + placed.push(agent); + } + } + + function avoidanceHeadings() { + const offsets = new Map(); + const courses = new Map(); + const add = (agent: FishAgent, x: number, y: number) => { + const offset = offsets.get(agent) ?? { x: 0, y: 0 }; + offset.x += x; + offset.y += y; + offsets.set(agent, offset); + }; + for (const pair of pairs) { + const { first, second } = pair; + const a = fishPose(first).position; + const b = fishPose(second).position; + const dx = b.x - a.x; + const dy = b.y - a.y; + const distance = Math.hypot(dx, dy); + const vx = second.vehicle.velocity.x - first.vehicle.velocity.x; + const vy = second.vehicle.velocity.z - first.vehicle.velocity.z; + const speedSquared = vx * vx + vy * vy; + const closing = dx * vx + dy * vy; + const time = speedSquared > 0.001 ? pondClamp(-closing / speedSquared, 0, 2.8) : 0; + const closest = Math.hypot(dx + vx * time, dy + vy * time); + const radius = first.bodyRadius + second.bodyRadius; + const nx = distance > 0.001 ? dx / distance : 1; + const ny = distance > 0.001 ? dy / distance : 0; + const interceptTime = speedSquared > 0.001 ? Math.max(0, -closing / speedSquared) : 0; + const noseMiss = Math.hypot(dx + vx * interceptTime, dy + vy * interceptTime); + if (distance > radius + 24 && (closing >= 0 || noseMiss > radius + 12)) pair.passing = false; + const convergingNoses = !first.stun && !second.stun && closing < 0 && interceptTime > 0 && + interceptTime - radius / Math.sqrt(speedSquared) < 2.8 && + first.vehicle.velocity.x * nx + first.vehicle.velocity.z * ny > 0 && + second.vehicle.velocity.x * nx + second.vehicle.velocity.z * ny < 0 && + Math.cos(first.motion.heading - second.motion.heading) <= -OPPOSING_COSINE + 1e-10 && + Math.cos(first.motion.heading) * nx + Math.sin(first.motion.heading) * ny >= OPPOSING_COSINE && + Math.cos(second.motion.heading) * nx + Math.sin(second.motion.heading) * ny <= -OPPOSING_COSINE && + noseMiss < Math.min(first.displayWidth, second.displayWidth) * 0.14; + if (convergingNoses) { + courses.set(first, first.motion.heading); + courses.set(second, second.motion.heading); + continue; + } + if (distance > radius + 18 && (closing >= 0 || closest > radius + 12)) continue; + if (!pair.passing) { + const lateralMiss = -ny * (dx + vx * interceptTime) + nx * (dy + vy * interceptTime); + pair.side = lateralMiss > 0.001 ? -1 : 1; + pair.passing = true; + } + const urgency = pondClamp((radius + 24 - closest) / 24, 0, 1) * (1 - time / 4); + // The same pair keeps its passing side throughout a crossing or an overtake. + const tx = -ny * pair.side; + const ty = nx * pair.side; + add(first, (-nx * 0.45 + tx) * urgency * 2.4, (-ny * 0.45 + ty) * urgency * 2.4); + add(second, (nx * 0.45 - tx) * urgency * 2.4, (ny * 0.45 - ty) * urgency * 2.4); + } + return { offsets, courses }; + } + + function resolveFishContacts(starts: Map, ends: Map, delta: number) { + const attempts = new Map(); + const stopped = new Set(); + const pendingBonks = new Set(); + const pendingFish = new Set(); + const interpolate = (start: FishMove, end: FishMove, time: number): FishMove => ({ + position: { x: start.position.x + (end.position.x - start.position.x) * time, y: start.position.y + (end.position.y - start.position.y) * time }, + heading: start.heading + pondAngleDelta(start.heading, end.heading) * time, + }); + const noseContact = (pair: FishPair, a: FishMove, b: FishMove, firstMove: PondPoint, secondMove: PondPoint) => { + const aDisc = bodyDiscs(pair.first, a)[2]; + const bDisc = bodyDiscs(pair.second, b)[2]; + const distance = pondDistance(aDisc, bDisc); + const nx = (bDisc.x - aDisc.x) / Math.max(0.001, distance); + const ny = (bDisc.y - aDisc.y) / Math.max(0.001, distance); + const point = { x: aDisc.x + nx * pair.first.displayWidth * 0.36, y: aDisc.y + ny * pair.first.displayWidth * 0.36 }; + const atNose = (agent: FishAgent, pose: FishMove) => pondDistance(point, { + x: pose.position.x + Math.cos(pose.heading) * agent.displayWidth * 0.47, + y: pose.position.y + Math.sin(pose.heading) * agent.displayWidth * 0.47, + }) <= agent.displayWidth * 0.14; + return Math.cos(a.heading - b.heading) <= -OPPOSING_COSINE + 1e-10 && + firstMove.x * nx + firstMove.y * ny > 1e-8 && secondMove.x * nx + secondMove.y * ny < -1e-8 && + distance <= (pair.first.displayWidth + pair.second.displayWidth) * 0.36 + CONTACT_GAP + 1e-6 && + atNose(pair.first, a) && atNose(pair.second, b); + }; + for (const pair of pairs) { + if (!bodiesOverlap(pair.first, starts.get(pair.first)!, pair.second, starts.get(pair.second)!, 4)) pair.touching = false; + } + for (let iteration = 0; iteration < pairs.length * 8 + 1; iteration += 1) { + let contact: { pair: FishPair; time: number; firstDisc: number; secondDisc: number } | null = null; + for (const pair of pairs) { + const a = starts.get(pair.first)!; + const b = starts.get(pair.second)!; + const aEnd = ends.get(pair.first)!; + const bEnd = ends.get(pair.second)!; + // Chord sweeps include the maximum sagitta of each rotating disc center. + const arcPadding = pair.first.displayWidth * 0.2 * (1 - Math.cos(pondAngleDelta(a.heading, aEnd.heading) / 2)) + + pair.second.displayWidth * 0.2 * (1 - Math.cos(pondAngleDelta(b.heading, bEnd.heading) / 2)); + if (pondDistance(a.position, b.position) > pair.first.bodyRadius + pair.second.bodyRadius + + pondDistance(a.position, aEnd.position) + pondDistance(b.position, bEnd.position) + arcPadding) continue; + for (let firstDisc = 0; firstDisc < 3; firstDisc += 1) { + for (let secondDisc = 0; secondDisc < 3; secondDisc += 1) { + const time = sweptBodyDiscContact(pair, a, b, aEnd, bEnd, firstDisc, secondDisc); + if (time !== null && time >= 1 - 1e-8 && pendingBonks.has(pair)) continue; + if (time !== null && (!contact || time < contact.time)) contact = { pair, time, firstDisc, secondDisc }; + } + } + } + if (!contact) break; + const { pair, time, firstDisc, secondDisc } = contact; + const firstBefore = starts.get(pair.first)!; + const secondBefore = starts.get(pair.second)!; + const firstMove = { x: ends.get(pair.first)!.position.x - firstBefore.position.x, y: ends.get(pair.first)!.position.y - firstBefore.position.y }; + const secondMove = { x: ends.get(pair.second)!.position.x - secondBefore.position.x, y: ends.get(pair.second)!.position.y - secondBefore.position.y }; + const a = interpolate(firstBefore, ends.get(pair.first)!, time); + const b = interpolate(secondBefore, ends.get(pair.second)!, time); + const aDisc = bodyDiscs(pair.first, a)[firstDisc]; + const bDisc = bodyDiscs(pair.second, b)[secondDisc]; + const distance = pondDistance(aDisc, bDisc); + const nx = (bDisc.x - aDisc.x) / Math.max(0.001, distance); + const ny = (bDisc.y - aDisc.y) / Math.max(0.001, distance); + const bonk = !pair.touching && !pair.first.stun && !pair.second.stun && + !pendingFish.has(pair.first) && !pendingFish.has(pair.second) && firstDisc === 2 && secondDisc === 2 && + noseContact(pair, a, b, firstMove, secondMove); + if (bonk) { + pendingBonks.add(pair); + pendingFish.add(pair.first); + pendingFish.add(pair.second); + } + const count = (attempts.get(pair) ?? 0) + 1; + attempts.set(pair, count); + let requiresYield = count > 4; + for (const [agent, pose, normal, discIndex] of [[pair.first, a, -1, firstDisc], [pair.second, b, 1, secondDisc]] as const) { + if (count > 4 && !bonk) break; + if (stopped.has(agent)) continue; + const end = ends.get(agent)!; + let dx = end.position.x - pose.position.x; + let dy = end.position.y - pose.position.y; + if (bonk) { + dx = 0; + dy = 0; + } else { + const relativeX = firstMove.x - secondMove.x; + const relativeY = firstMove.y - secondMove.y; + const inward = Math.max(0, relativeX * nx + relativeY * ny) * (1 - time) * 0.5; + dx += nx * normal * inward; + dy += ny * normal * inward; + const currentDisc = bodyDiscs(agent, pose)[discIndex]; + const endDisc = bodyDiscs(agent, end)[discIndex]; + const rotationX = endDisc.x - currentDisc.x - (end.position.x - pose.position.x); + const rotationY = endDisc.y - currentDisc.y - (end.position.y - pose.position.y); + const rotationInward = Math.max(0, -normal * (rotationX * nx + rotationY * ny)); + const padding = agent.displayWidth * 0.2 * (1 - Math.cos(pondAngleDelta(pose.heading, end.heading) / 2)); + // Yield enough room for the turn instead of indefinitely cancelling it at t=0. + dx += nx * normal * (rotationInward + padding + CONTACT_GAP); + dy += ny * normal * (rotationInward + padding + CONTACT_GAP); + } + const start = starts.get(agent)!; + const limit = agent.stun || pendingFish.has(agent) ? pondDistance(start.position, pose.position) + agent.cruise * 0.18 * delta : agent.cruise * 3.5 * delta; + const candidate = { x: pose.position.x + dx, y: pose.position.y + dy }; + const totalX = candidate.x - start.position.x; + const totalY = candidate.y - start.position.y; + const length = Math.hypot(totalX, totalY); + if (length > limit) { + candidate.x = start.position.x + totalX * limit / length; + candidate.y = start.position.y + totalY * limit / length; + } + if (waterPath(start.position, Math.atan2(candidate.y - start.position.y, candidate.x - start.position.x), pondDistance(start.position, candidate), agent.bodyRadius)) { + end.position = candidate; + if (bonk) end.heading = pose.heading; + } else { + requiresYield = true; + end.position = { ...pose.position }; + end.heading = pose.heading; + } + } + if (requiresYield && !bonk) { + const slide = (agent: FishAgent, direction: number, length: number): FishMove => { + const start = starts.get(agent)!; + return { position: { + x: start.position.x + Math.cos(direction) * length, + y: start.position.y + Math.sin(direction) * length, + }, heading: start.heading }; + }; + let yielded = false; + const tangent = Math.atan2(ny, nx) + Math.PI / 2 * pair.side; + const directions = [pair.first.motion.shoreHeading ?? tangent, pair.second.motion.shoreHeading ?? tangent, + ...Array.from({ length: 32 }, (_, sample) => tangent + sample / 32 * TAU)]; + const firstLength = stopped.has(pair.first) ? 0 : pair.first.cruise * (pair.first.stun || pendingFish.has(pair.first) ? 0.18 : 0.4) * delta; + const secondLength = stopped.has(pair.second) ? 0 : pair.second.cruise * (pair.second.stun || pendingFish.has(pair.second) ? 0.18 : 0.4) * delta; + const sharedLength = Math.min(firstLength, secondLength); + for (const direction of count >= 8 ? [] : directions) { + for (const [aLength, bLength] of [[sharedLength, sharedLength], [firstLength, 0], [0, secondLength]]) { + if (aLength + bLength === 0) continue; + const firstEnd = slide(pair.first, direction, aLength); + const secondEnd = slide(pair.second, direction, bLength); + if (!waterPath(firstBefore.position, direction, aLength, pair.first.bodyRadius) || + !waterPath(secondBefore.position, direction, bLength, pair.second.bodyRadius)) continue; + let blocked = false; + for (let firstDisc = 0; firstDisc < 3; firstDisc += 1) { + for (let secondDisc = 0; secondDisc < 3; secondDisc += 1) { + if (sweptBodyDiscContact(pair, firstBefore, secondBefore, firstEnd, secondEnd, firstDisc, secondDisc) !== null) blocked = true; + } + } + if (blocked) continue; + ends.set(pair.first, firstEnd); + ends.set(pair.second, secondEnd); + yielded = true; + break; + } + if (yielded) break; + } + if (yielded) continue; + // A blocked contact clips only its participants' trajectories. + for (const agent of [pair.first, pair.second]) { + const clipped = interpolate(starts.get(agent)!, ends.get(agent)!, count >= 8 ? 0 : Math.max(0, time - 1e-6)); + ends.set(agent, clipped); + if (time < 1e-6 || count >= 8) stopped.add(agent); + } + continue; + } + } + // Later crowd contacts can revise a candidate impact out of the accepted paths. + for (const pair of pendingBonks) { + const firstStart = starts.get(pair.first)!; + const secondStart = starts.get(pair.second)!; + const firstEnd = ends.get(pair.first)!; + const secondEnd = ends.get(pair.second)!; + const time = sweptBodyDiscContact(pair, firstStart, secondStart, firstEnd, secondEnd, 2, 2) ?? 1; + const a = interpolate(firstStart, firstEnd, time); + const b = interpolate(secondStart, secondEnd, time); + if (!noseContact(pair, a, b, + { x: firstEnd.position.x - firstStart.position.x, y: firstEnd.position.y - firstStart.position.y }, + { x: secondEnd.position.x - secondStart.position.x, y: secondEnd.position.y - secondStart.position.y })) continue; + pair.touching = true; + for (const [agent, pose] of [[pair.first, a], [pair.second, b]] as const) { + agent.stun = { remaining: STUN_SECONDS, elapsed: 0, serial: ++agent.stunSerial }; + agent.stunHeading = pose.heading + pair.side * Math.PI * 0.65; + agent.motion.shoreHeading = null; + agent.motion.speed = Math.min(agent.motion.speed, agent.cruise * 0.18); + } + } + for (const agent of fish) { + const pose = ends.get(agent)!; + agent.vehicle.position.set(pose.position.x, 0, pose.position.y); + agent.motion.heading = Math.atan2(Math.sin(pose.heading), Math.cos(pose.heading)); + } + } + + function updateFish(input: SimulationInput, frame: PondWorldFrame) { + const intents = new Map(frame.fish.map((intent) => [intent.fishId, intent])); + const starts = new Map(fish.map((agent) => [agent, fishPose(agent)])); + const avoidance = avoidanceHeadings(); + pointerAgent.position.set(input.pointer.position.x, 0, input.pointer.position.y); + pointerAgent.velocity.set(input.pointer.velocity.x, 0, input.pointer.velocity.y); + + for (const agent of fish) { + agent.previous = { ...starts.get(agent)!.position }; + agent.motion.shoreRemaining = Math.max(0, agent.motion.shoreRemaining - input.delta); + if (agent.stun) { + agent.stun.remaining = Math.max(0, agent.stun.remaining - input.delta); + agent.stun.elapsed = STUN_SECONDS - agent.stun.remaining; + if (agent.stun.remaining < 1e-9) { + agent.stun = null; + agent.stunHeading = null; + } + } + const intent = intents.get(agent.id); + const position = agent.vehicle.position; + const pointerDistance = pondDistance( + { x: position.x, y: position.z }, + input.pointer.position, + ); + const pointerThreat = input.pointer.influence * Math.max(0, 1 - pointerDistance / 360) * input.pointer.energy; + const totalThreat = pondClamp(pointerThreat, 0, 1); + agent.motion.threat = totalThreat; + agent.motion.escapeHeading = totalThreat > 0.04 + ? awayHeading(agent.previous, input.pointer.position, agent.motion.heading + Math.PI) + : null; + + const idle = !intent?.goal && totalThreat <= 0.04 && + (intent?.state === "cruising" || intent?.state === "returning-to-cruise"); + if (idle) { + agent.motion.activityRemaining -= input.delta; + if (agent.motion.activityRemaining <= 0) { + agent.motion.activityIndex = (agent.motion.activityIndex + 1) % IDLE_ACTIVITIES.length; + const activity = IDLE_ACTIVITIES[agent.motion.activityIndex]; + agent.motion.activityRemaining = duration(...IDLE_MOTION[activity].duration); + if (activity === "darting") agent.nextRouteAt = 0; + } + } + agent.motion.activity = idle ? IDLE_ACTIVITIES[agent.motion.activityIndex] : "swimming"; + + agent.pointerFlee.weight += (pointerThreat * 7.4 - agent.pointerFlee.weight) * response(26, input.delta); + agent.pointerFlee.active = agent.pointerFlee.weight > 0.012; + + if (intent?.goal) { + const goal = nearestWater(intent.goal, width, height, isWater, agent.bodyRadius, agent.previous); + agent.goalTarget.set(goal.x, 0, goal.y); + agent.goalArrive.active = true; + agent.goalArrive.weight = intent.state === "feeding" ? 1.25 : intent.state === "circling" ? 0.95 : 2.5; + agent.goalArrive.deceleration = intent.state === "feeding" ? 1.6 : 2.2; + agent.goalArrive.tolerance = Math.max(5, intent.arrivalRadius * 0.28); + agent.wander.weight += ((intent.state === "approaching-food" ? 0.14 : 0.06) - agent.wander.weight) * Math.min(1, input.delta * 4); + } else { + const current = { x: position.x, y: position.z }; + if (input.now >= agent.nextRouteAt || pondDistance(current, agent.routeGoal) < 12) { + agent.routeGoal = routePoint(agent); + agent.nextRouteAt = input.now + duration(4200, 8500); + } + agent.goalTarget.set(agent.routeGoal.x, 0, agent.routeGoal.y); + agent.goalArrive.active = true; + agent.goalArrive.weight = 0.9; + agent.goalArrive.deceleration = 2.8; + agent.goalArrive.tolerance = 30; + const wanderWeight = agent.motion.activity === "darting" ? 0.06 : 0.38; + agent.wander.weight += (wanderWeight - agent.wander.weight) * Math.min(1, input.delta * 1.8); + } + + const foodForce = intent?.state === "approaching-food" ? 9 : 0; + agent.vehicle.maxForce += (7 + foodForce + totalThreat * 70 - agent.vehicle.maxForce) * response(12, input.delta); + const speedScale = agent.stun ? 0.18 : Math.max( + idle ? IDLE_MOTION[agent.motion.activity].speed * agent.motion.variation : intent?.speedScale ?? 1, + totalThreat > 0.04 ? 1 + totalThreat * 2.35 : 0, + ); + agent.vehicle.maxSpeed += + (agent.cruise * speedScale - agent.vehicle.maxSpeed) * response(9, input.delta); + agent.vehicle.velocity.x += frame.environment.currentA.x * input.delta * 0.018; + agent.vehicle.velocity.z += frame.environment.currentA.y * input.delta * 0.018; + agent.homeSeek.active = + position.x < 100 || position.x > width - 100 || position.z < 100 || position.z > height - 100; + + } + + // Yuka supplies steering only. Locomotion owns position and velocity together. + const steering = new Map(); + for (const agent of fish) { + manager.updateNeighborhood(agent.vehicle); + const force = new YUKA.Vector3(); + agent.vehicle.steering.calculate(input.delta, force); + steering.set(agent, { + x: agent.vehicle.velocity.x + force.x * input.delta, + y: agent.vehicle.velocity.z + force.z * input.delta, + }); + } + + const ends = new Map(); + const recoveredFish = new Set(); + for (const agent of fish) { + const intent = intents.get(agent.id); + const motion = agent.motion; + const suggested = steering.get(agent)!; + const steeredSpeed = Math.hypot(suggested.x, suggested.y); + const cruising = intent?.state === "cruising" || intent?.state === "returning-to-cruise"; + let desiredHeading = motion.escapeHeading ?? (steeredSpeed > 0.1 ? Math.atan2(suggested.y, suggested.x) : motion.heading); + if (motion.escapeHeading === null && !agent.stun) { + const goalHeading = Math.atan2(agent.goalTarget.z - agent.previous.y, agent.goalTarget.x - agent.previous.x); + desiredHeading += pondAngleDelta(desiredHeading, goalHeading) * (intent?.goal ? 0.8 : 0.35); + } + const offset = avoidance.offsets.get(agent); + if (offset) desiredHeading = Math.atan2(Math.sin(desiredHeading) + offset.y, Math.cos(desiredHeading) + offset.x); + const course = avoidance.courses.get(agent); + if (course !== undefined && motion.escapeHeading === null) desiredHeading = course; + if (agent.stun) desiredHeading = agent.stunHeading ?? motion.heading; + motion.shoreHeading = shorelineHeading(agent, desiredHeading, Math.max(36, agent.bodyRadius + motion.speed * 0.9)); + if (motion.shoreHeading !== null) motion.activity = "swimming"; + const targetHeading = motion.shoreHeading ?? desiredHeading; + const maxTurnRate = (agent.stun ? 0.9 : motion.shoreHeading !== null ? 3.4 : offset ? 2.8 : 1.15 + motion.threat * 4.8) * motion.variation; + const headingError = pondAngleDelta(motion.heading, targetHeading); + const requestedTurnRate = pondClamp(headingError * 4, -maxTurnRate, maxTurnRate); + motion.steeringTurnRate += (requestedTurnRate - motion.steeringTurnRate) * response(6, input.delta); + if (motion.threat > 0.04) motion.steeringTurnRate = pondClamp(headingError / input.delta, -maxTurnRate, maxTurnRate); + const turn = pondClamp(motion.steeringTurnRate * input.delta, -Math.abs(headingError), Math.abs(headingError)); + motion.heading = Math.atan2(Math.sin(motion.heading + turn), Math.cos(motion.heading + turn)); + motion.turnRate = input.delta > 0 ? turn / input.delta : 0; + const idle = cruising && !intent?.goal && motion.threat <= 0.04; + let targetSpeed = motion.escapeHeading !== null ? agent.vehicle.maxSpeed : idle + ? agent.cruise * IDLE_MOTION[motion.activity].speed * motion.variation + : Math.max(cruising ? agent.cruise * 0.82 : 0, steeredSpeed); + if (motion.shoreHeading !== null) { + targetSpeed = Math.min(targetSpeed, agent.cruise * (0.4 + 0.5 * Math.max(0, Math.cos(pondAngleDelta(motion.heading, targetHeading))))); + } + if (agent.stun) targetSpeed = agent.cruise * 0.18; + const acceleration = agent.cruise * (1.2 + motion.threat * 6) * motion.variation; + motion.speed += pondClamp(targetSpeed - motion.speed, -acceleration * input.delta, acceleration * input.delta); + motion.speed = pondClamp(motion.speed, 0, agent.cruise * 3.5); + const recovered = moveInWater(agent, input.delta); + if (recovered) recoveredFish.add(agent); + ends.set(agent, fishPose(agent)); + const start = starts.get(agent)!; + agent.vehicle.position.set(start.position.x, 0, start.position.y); + motion.heading = start.heading; + } + + resolveFishContacts(starts, ends, input.delta); + for (const agent of fish) { + const { motion, vehicle } = agent; + const position = vehicle.position; + const velocity = vehicle.velocity; + const intent = intents.get(agent.id); + const idle = (intent?.state === "cruising" || intent?.state === "returning-to-cruise") && !intent?.goal && motion.threat <= 0.04; + velocity.set((position.x - agent.previous.x) / input.delta, 0, (position.z - agent.previous.y) / input.delta); + motion.speed = Math.hypot(velocity.x, velocity.z); + motion.turnRate = pondAngleDelta(starts.get(agent)!.heading, motion.heading) / input.delta; + motion.swimPhase = (motion.swimPhase + input.delta * (2.4 + motion.speed / agent.cruise * 5.5) * motion.variation) % TAU; + const targetDepth = motion.threat > 0.1 ? 0.9 : intent?.state === "feeding" ? 0.08 : intent?.goal ? 0.22 + : idle && motion.activity === "surfacing" ? 0.05 : motion.cruiseDepth; + motion.depth += (targetDepth - motion.depth) * response(2.8, input.delta); + const step = pondDistance(agent.previous, { x: position.x, y: position.z }); + agent.maxStep = Math.max(agent.maxStep, step); + agent.previous = { x: position.x, y: position.z }; + if (recoveredFish.has(agent) && !intents.get(agent.id)?.goal) { + agent.routeGoal = routePoint(agent); + agent.goalTarget.set(agent.routeGoal.x, 0, agent.routeGoal.y); + agent.nextRouteAt = input.now + duration(3200, 7200); + } + if (!Number.isFinite(velocity.x) || !Number.isFinite(velocity.z)) { + velocity.set(agent.cruise, 0, 0); + } + } + } + + function updateFlies(input: SimulationInput, frame: PondWorldFrame) { + const intents = new Map(frame.flies.map((intent) => [intent.flyId, intent])); + for (const agent of flies) { + const intent = intents.get(agent.id); + if (!intent) continue; + const motion = agent.motion; + motion.hoverPhase = (motion.hoverPhase + input.delta * 1.7 * motion.variation) % TAU; + const hoverRadius = intent.state === "hovering" ? 1.8 * motion.variation : 0; + const goal = { + x: pondClamp(intent.goal.x + Math.sin(motion.hoverPhase) * hoverRadius, 60, width - 60), + y: pondClamp(intent.goal.y + Math.cos(motion.hoverPhase * 2) * hoverRadius * 0.55, 55, height - 80), + }; + const dx = goal.x - agent.worldPosition.x; + const dy = goal.y - agent.worldPosition.y; + const length = Math.hypot(dx, dy); + const pointerDistance = pondDistance(agent.worldPosition, input.pointer.position); + const pointerDanger = pondClamp(input.pointer.influence * Math.max(0, 1 - pointerDistance / 170), 0, 1); + const danger = pointerDanger; + const baseSpeed = 24 * intent.speedScale * motion.variation; + const arrivalSpeed = Math.min(baseSpeed, length * 2.1); + let targetX = length > 0.001 ? dx / length * arrivalSpeed : 0; + let targetY = length > 0.001 ? dy / length * arrivalSpeed : 0; + if (danger > 0.04) { + const origin = input.pointer.position; + const heading = awayHeading(agent.worldPosition, origin, motion.heading); + targetX = Math.cos(heading) * (baseSpeed + 95 * danger); + targetY = Math.sin(heading) * (baseSpeed + 95 * danger); + } + const smoothing = response(danger > 0.04 ? 14 : 12, input.delta); + agent.velocity.x += (targetX - agent.velocity.x) * smoothing; + agent.velocity.y += (targetY - agent.velocity.y) * smoothing; + const previous = { ...agent.worldPosition }; + agent.worldPosition.x = pondClamp(previous.x + agent.velocity.x * input.delta, 60, width - 60); + agent.worldPosition.y = pondClamp(previous.y + agent.velocity.y * input.delta, 55, height - 80); + agent.velocity.x = (agent.worldPosition.x - previous.x) / input.delta; + agent.velocity.y = (agent.worldPosition.y - previous.y) / input.delta; + const speed = Math.hypot(agent.velocity.x, agent.velocity.y); + if (speed > 0.3) { + motion.heading += pondClamp(pondAngleDelta(motion.heading, Math.atan2(agent.velocity.y, agent.velocity.x)), -9 * input.delta, 9 * input.delta); + } + motion.reacting = danger > 0.04; + const activity = motion.reacting ? 1 : intent.state === "resting" ? Math.min(1, speed / 20) : pondClamp(0.35 + speed / 70, 0.35, 0.9); + motion.wingActivity += (activity - motion.wingActivity) * response(9, input.delta); + motion.wingPhase = (motion.wingPhase + input.delta * agent.speed * 1000 * motion.wingActivity) % TAU; + const lift = intent.state === "resting" && !motion.reacting ? 0 : 4 + Math.sin(motion.hoverPhase) * 1.2 + Math.min(9, speed * 0.08); + motion.lift += (lift - motion.lift) * response(6, input.delta); + } + } + + function snapshots(frame: PondWorldFrame): PondSimulationFrame { + const fishIntents = new Map(frame.fish.map((intent) => [intent.fishId, intent])); + const flyIntents = new Map(frame.flies.map((intent) => [intent.flyId, intent])); + return { + ...frame, + fish: fish.map((agent) => { + const intent = fishIntents.get(agent.id)!; + const velocity = { x: agent.vehicle.velocity.x, y: agent.vehicle.velocity.z }; + return { + id: agent.id, + row: agent.row, + displayWidth: agent.displayWidth, + alpha: agent.alpha, + species: agent.species, + position: { x: agent.vehicle.position.x, y: agent.vehicle.position.z }, + velocity, + heading: agent.motion.heading, + swimPhase: agent.motion.swimPhase, + turnRate: agent.motion.turnRate, + speedScale: agent.motion.speed / agent.cruise, + depth: agent.motion.depth, + activity: agent.motion.activity, + state: intent.state, + foodId: intent.foodId, + goal: intent.goal, + reserved: intent.reserved, + feedingPulse: intent.feedingPulse, + reacting: agent.motion.threat > 0.04, + maxStep: agent.maxStep, + stun: agent.stun ? { ...agent.stun } : null, + }; + }), + flies: flies.map((agent) => { + const intent = flyIntents.get(agent.id)!; + return { + id: agent.id, + position: { ...agent.worldPosition }, + velocity: { ...agent.velocity }, + heading: agent.motion.heading, + wingPhase: agent.motion.wingPhase, + wingActivity: agent.motion.wingActivity, + lift: agent.motion.lift, + state: intent.state, + color: agent.color, + reacting: agent.motion.reacting, + }; + }), + }; + } + + function step(input: SimulationInput) { + input = { ...input, delta: Number.isFinite(input.delta) ? pondClamp(input.delta, 0, 0.1) : 0 }; + const targets = [...fishTargets(input), ...flyTargets(input)]; + const frame = world.step({ + now: input.now, + delta: input.delta, + targets, + foodApproach: (target, food, arrivalRadius, preferred) => { + const agent = fish.find((item) => item.id === target.id); + if (!agent) return null; + for (const goal of [preferred, food]) { + const point = nearestWater(goal, width, height, isWater, agent.bodyRadius, agent.previous); + if (waterClearance(point, agent.bodyRadius) && pondDistance(point, food) < arrivalRadius) return point; + } + return null; + }, + }); + if (input.delta > 0) { + updateFish(input, frame); + updateFlies(input, frame); + } + lastFrame = snapshots(frame); + return lastFrame; + } + + separateInitialFish(); + + return { + dropFood: world.dropFood, + step, + destroy: () => manager.clear(), + get frame() { + return lastFrame; + }, + }; +} diff --git a/components/concepts/signal-desk/pond-pixel-world.ts b/components/concepts/signal-desk/pond-pixel-world.ts new file mode 100644 index 0000000..df9cf7e --- /dev/null +++ b/components/concepts/signal-desk/pond-pixel-world.ts @@ -0,0 +1,616 @@ +import { + createPondRandom, + pondClamp as clamp, + pondDistance as distance, + pondHashSeed as hashSeed, + pondLerp as lerp, + pondSmoothstep as smoothstep, + type FishFoodEntity, + type FishFoodStateName, + type FishWorldIntent, + type FlyRoutineState, + type FlyWorldIntent, + type PondPoint, + type PondTarget, + type PondWorldEvent, + type PondWorldFrame, + type PondWorldInput, +} from "./pond-pixel-model.ts"; + +export * from "./pond-pixel-model.ts"; + +type FoodInternal = Omit & { + spawnedAt: number; + depletedAt: number | null; + rippleStrength: number; +}; + +type FishBrain = { + state: FishFoodStateName; + stateStarted: number; + stateDuration: number; + foodId: string | null; + hunger: number; + nextInterestAt: number; + pathBias: number; + slot: number; + slotAngle: number; + nextBiteAt: number; + progressDistance: number; + progressAt: number; + reason: string; +}; + +type FlyBrain = { + state: FlyRoutineState; + stateStarted: number; + stateDuration: number; + goal: PondPoint; + home: PondPoint; + reason: string; +}; + +const MAX_FOOD = 8; +const FOOD_MERGE_DISTANCE = 32; +const LEGAL_FISH_TRANSITIONS: Record = { + cruising: ["detecting-food", "startled"], + "detecting-food": ["approaching-food", "returning-to-cruise", "startled"], + "approaching-food": ["circling", "returning-to-cruise", "startled"], + circling: ["feeding", "approaching-food", "returning-to-cruise", "startled"], + feeding: ["returning-to-cruise", "approaching-food", "startled"], + startled: ["returning-to-cruise"], + "returning-to-cruise": ["cruising", "detecting-food", "startled"], +}; +const LEGAL_FLY_TRANSITIONS: Record = { + hovering: ["foraging", "resting", "startled"], + foraging: ["hovering", "resting", "startled"], + resting: ["hovering", "foraging", "startled"], + startled: ["hovering"], +}; + +export function createPondWorld(seedValue: string | number = "6c75") { + const seed = hashSeed(seedValue); + const random = createPondRandom(seed); + const foods = new Map(); + const fishBrains = new Map(); + const flyBrains = new Map(); + const reservations = new Map>(); + const pendingFoodDrops: { id: string; position: PondPoint }[] = []; + let foodSequence = 0; + let wind = 0; + let windTarget = 0.25; + let nextWindShift = 0; + let currentA = { x: 5.4, y: 2.1 }; + let currentB = { x: -3.2, y: 4.1 }; + let targetCurrentA = { ...currentA }; + let targetCurrentB = { ...currentB }; + let surfaceA = { x: 5.2, y: 3.7 }; + let surfaceB = { x: -2.8, y: 3.2 }; + let targetSurfaceA = { ...surfaceA }; + let targetSurfaceB = { ...surfaceB }; + let light = 0.12; + let lightTarget = 0.12; + let nextCurrentShift = 0; + let nextAmbientRipple = 0; + + const duration = (minimum: number, maximum: number) => minimum + random() * (maximum - minimum); + + function dropFood(position: PondPoint) { + const id = `food-${seed.toString(16)}-${foodSequence}`; + foodSequence += 1; + pendingFoodDrops.push({ id, position: { ...position } }); + return id; + } + + function releaseReservation(fishId: string, brain: FishBrain) { + if (!brain.foodId) return; + const reserved = reservations.get(brain.foodId); + reserved?.delete(fishId); + if (reserved?.size === 0) reservations.delete(brain.foodId); + } + + function wakeFishForFood(now: number) { + for (const brain of fishBrains.values()) { + if (brain.state === "cruising") { + brain.nextInterestAt = Math.min(brain.nextInterestAt, now + duration(20, 90)); + } + } + } + + function transitionFish( + fishId: string, + brain: FishBrain, + next: FishFoodStateName, + reason: string, + now: number, + nextDuration: number, + events: PondWorldEvent[], + ) { + if (!LEGAL_FISH_TRANSITIONS[brain.state].includes(next)) { + throw new Error(`Illegal fish transition ${brain.state} -> ${next}`); + } + const previous = brain.state; + brain.state = next; + brain.stateStarted = now; + brain.stateDuration = nextDuration; + brain.reason = reason; + events.push({ type: "fish-state", fishId, from: previous, to: next, reason }); + } + + function expireFood( + food: FoodInternal, + reason: "consumed" | "lifetime" | "capacity", + events: PondWorldEvent[], + ) { + food.expired = true; + foods.delete(food.id); + reservations.delete(food.id); + events.push({ + type: "food-expired", + foodId: food.id, + position: food.position, + consumed: food.consumed, + reason, + }); + } + + function updateFoods(now: number, events: PondWorldEvent[]) { + while (pendingFoodDrops.length) { + const drop = pendingFoodDrops.shift()!; + const merge = [...foods.values()] + .filter((food) => !food.expired && food.state !== "depleted") + .sort((a, b) => distance(a.position, drop.position) - distance(b.position, drop.position))[0]; + if (merge && distance(merge.position, drop.position) <= FOOD_MERGE_DISTANCE) { + merge.remainingAmount = Math.min(9, merge.remainingAmount + 2); + merge.lifetime += duration(2200, 4200); + events.push({ + type: "food-dropped", + foodId: merge.id, + position: drop.position, + rippleStrength: 0.32 + random() * 0.22, + merged: true, + }); + wakeFishForFood(now); + continue; + } + if (foods.size >= MAX_FOOD) { + const oldest = [...foods.values()].sort((a, b) => a.spawnedAt - b.spawnedAt)[0]; + if (oldest) expireFood(oldest, "capacity", events); + } + const food: FoodInternal = { + id: drop.id, + position: drop.position, + spawnedAt: now, + state: "dropping", + remainingAmount: 3 + Math.floor(random() * 4), + attractionRadius: 430 + random() * 180, + consumed: false, + expired: false, + pelletSize: 4 + random() * 3.5, + settleDelay: duration(170, 310), + lifetime: duration(24000, 38000), + depletedAt: null, + rippleStrength: 0.34 + random() * 0.28, + }; + foods.set(food.id, food); + events.push({ + type: "food-dropped", + foodId: food.id, + position: food.position, + rippleStrength: food.rippleStrength, + merged: false, + }); + wakeFishForFood(now); + } + + for (const food of [...foods.values()]) { + const age = now - food.spawnedAt; + if (food.state === "dropping" && age >= food.settleDelay) food.state = "floating"; + if (food.remainingAmount <= 0 && food.state !== "depleted") { + food.state = "depleted"; + food.consumed = true; + food.depletedAt = now; + } + if (food.depletedAt !== null && now - food.depletedAt >= 620) expireFood(food, "consumed", events); + else if (age >= food.lifetime) expireFood(food, "lifetime", events); + } + } + + function foodFrames(now: number): FishFoodEntity[] { + return [...foods.values()].map((food) => { + const age = Math.max(0, now - food.spawnedAt); + return { + id: food.id, + position: food.position, + age, + state: food.state, + remainingAmount: food.remainingAmount, + attractionRadius: food.attractionRadius, + consumed: food.consumed, + expired: food.expired, + pelletSize: food.pelletSize, + settleDelay: food.settleDelay, + lifetime: food.lifetime, + dropProgress: clamp(age / food.settleDelay, 0, 1), + }; + }); + } + + function foodPreference(fish: PondTarget) { + if (fish.species === "kohaku") return 1.08; + if (fish.species === "ogon") return 1.02; + if (fish.species === "showa") return 0.9; + return 0.96; + } + + function chooseFood(fish: PondTarget, brain: FishBrain) { + return [...foods.values()] + .filter((food) => food.state !== "depleted" && !food.expired && food.remainingAmount > 0) + .map((food) => { + const foodDistance = distance(fish.position, food.position); + const reserved = reservations.get(food.id)?.size ?? 0; + const currentGoalBonus = brain.foodId === food.id ? 34 : 0; + const score = + brain.hunger * 150 + + foodPreference(fish) * 42 + + currentGoalBonus - + foodDistance * 0.36 - + reserved * 30 - + (fish.danger ?? 0) * 190 + + random() * 15; + return { food, foodDistance, score }; + }) + .filter(({ food, foodDistance }) => foodDistance <= food.attractionRadius) + .sort((a, b) => b.score - a.score)[0]?.food ?? null; + } + + function foodGoal(fish: PondTarget, food: FoodInternal, brain: FishBrain, now: number) { + const dx = food.position.x - fish.position.x; + const dy = food.position.y - fish.position.y; + const foodDistance = Math.max(1, Math.hypot(dx, dy)); + const normalX = -dy / foodDistance; + const normalY = dx / foodDistance; + if (brain.state === "circling") { + const direction = brain.pathBias < 0 ? -1 : 1; + const angle = brain.slotAngle + direction * (now - brain.stateStarted) * 0.0018; + const radius = 23 + brain.slot * 9; + return { x: food.position.x + Math.cos(angle) * radius, y: food.position.y + Math.sin(angle) * radius * 0.72 }; + } + const far = clamp(foodDistance / food.attractionRadius, 0, 1); + const curve = brain.pathBias * (8 + far * 54); + const slotOffset = 5 + brain.slot * 7; + return { + x: food.position.x + normalX * curve + Math.cos(brain.slotAngle) * slotOffset, + y: food.position.y + normalY * curve + Math.sin(brain.slotAngle) * slotOffset * 0.72, + }; + } + + function feedingRadius(fish: PondTarget, food: FoodInternal) { + return Math.max(30 + fish.interactionRange * 0.78, fish.bodyRadius ?? 0) + food.pelletSize; + } + + function updateFish(input: PondWorldInput, events: PondWorldEvent[]) { + const liveFish = input.targets.filter((target) => target.type === "fish"); + const liveIds = new Set(liveFish.map((fish) => fish.id)); + for (const [fishId, brain] of fishBrains) { + if (!liveIds.has(fishId)) { + releaseReservation(fishId, brain); + fishBrains.delete(fishId); + } + } + + return liveFish.map((fish): FishWorldIntent => { + let brain = fishBrains.get(fish.id); + if (!brain) { + brain = { + state: "cruising", + stateStarted: input.now, + stateDuration: 0, + foodId: null, + hunger: 0.38 + random() * 0.5, + nextInterestAt: input.now + duration(30, 120), + pathBias: (random() < 0.5 ? -1 : 1) * duration(0.45, 1), + slot: 0, + slotAngle: random() * Math.PI * 2, + nextBiteAt: 0, + progressDistance: Infinity, + progressAt: input.now, + reason: "cruising", + }; + fishBrains.set(fish.id, brain); + } + + brain.hunger = clamp(brain.hunger + input.delta * 0.014, 0, 1); + let food = brain.foodId ? foods.get(brain.foodId) ?? null : null; + const elapsed = input.now - brain.stateStarted; + const danger = fish.danger ?? 0; + const foodAvailable = [...foods.values()].some((candidate) => + !candidate.expired && candidate.remainingAmount > 0, + ); + const approach = food && input.foodApproach + ? input.foodApproach(fish, food.position, feedingRadius(fish, food), foodGoal(fish, food, brain, input.now)) + : null; + + if (danger > (foodAvailable ? 0.92 : 0.72) && brain.state !== "startled") { + releaseReservation(fish.id, brain); + brain.foodId = null; + transitionFish(fish.id, brain, "startled", "danger-interrupted-goal", input.now, duration(620, 1150), events); + } else if (brain.state === "cruising" && input.now >= brain.nextInterestAt && danger < (foodAvailable ? 0.9 : 0.5)) { + food = chooseFood(fish, brain); + const foodIsSettling = [...foods.values()].some((candidate) => candidate.state === "dropping" && !candidate.expired); + brain.nextInterestAt = input.now + (foodIsSettling ? duration(20, 75) : duration(420, 1100)); + if (food) { + brain.foodId = food.id; + transitionFish(fish.id, brain, "detecting-food", "food-entered-interest-radius", input.now, duration(40, 110), events); + events.push({ type: "fish-noticed-food", fishId: fish.id, foodId: food.id }); + } + } else if (brain.state === "detecting-food" && elapsed >= brain.stateDuration) { + if (!food || food.state === "depleted" || food.remainingAmount <= 0) { + brain.foodId = null; + transitionFish(fish.id, brain, "returning-to-cruise", "food-lost-before-reservation", input.now, duration(520, 1100), events); + } else if (food.state === "floating") { + const reserved = reservations.get(food.id) ?? new Set(); + const capacity = Math.min(3, Math.max(1, Math.ceil(food.remainingAmount / 2))); + if (reserved.size >= capacity && (!input.foodApproach || approach)) { + const replacement = liveFish + .filter((other) => reserved.has(other.id) && fishBrains.get(other.id)?.state === "approaching-food") + .map((other) => ({ fish: other, distance: distance(other.position, food!.position) })) + .sort((a, b) => b.distance - a.distance)[0]; + if (replacement && distance(fish.position, food.position) + 40 < replacement.distance) { + const displaced = fishBrains.get(replacement.fish.id)!; + releaseReservation(replacement.fish.id, displaced); + displaced.foodId = null; + displaced.nextInterestAt = input.now + 3000; + transitionFish(replacement.fish.id, displaced, "returning-to-cruise", "closer-fish-reserved", input.now, duration(620, 1280), events); + } + } + if ((input.foodApproach && !approach) || reserved.size >= capacity) { + brain.foodId = null; + brain.nextInterestAt = input.now + 3000; + transitionFish(fish.id, brain, "returning-to-cruise", approach || !input.foodApproach ? "food-reservation-full" : "food-unreachable", input.now, duration(620, 1280), events); + } else { + brain.slot = reserved.size; + reserved.add(fish.id); + reservations.set(food.id, reserved); + brain.progressDistance = distance(fish.position, approach ?? food.position); + brain.progressAt = input.now; + transitionFish(fish.id, brain, "approaching-food", "food-reserved", input.now, duration(18000, 26000), events); + events.push({ type: "fish-reserved-food", fishId: fish.id, foodId: food.id, slot: brain.slot }); + } + } + } else if (brain.state === "approaching-food") { + if (!food || food.state !== "floating" || food.remainingAmount <= 0) { + releaseReservation(fish.id, brain); + brain.foodId = null; + transitionFish(fish.id, brain, "returning-to-cruise", "reserved-food-unavailable", input.now, duration(620, 1200), events); + } else { + const arrivalRadius = feedingRadius(fish, food); + if (distance(fish.position, food.position) <= arrivalRadius) { + transitionFish(fish.id, brain, "circling", "arrived-within-feeding-radius", input.now, duration(280, 860), events); + events.push({ type: "fish-arrived", fishId: fish.id, foodId: food.id, position: food.position }); + } else if (input.foodApproach && !approach) { + releaseReservation(fish.id, brain); + brain.foodId = null; + brain.nextInterestAt = input.now + 3000; + transitionFish(fish.id, brain, "returning-to-cruise", "food-unreachable", input.now, duration(720, 1500), events); + } else if (elapsed >= brain.stateDuration || input.now - brain.progressAt >= 4000) { + releaseReservation(fish.id, brain); + brain.foodId = null; + brain.nextInterestAt = input.now + 3000; + transitionFish(fish.id, brain, "returning-to-cruise", elapsed >= brain.stateDuration ? "approach-timed-out" : "approach-stalled", input.now, duration(720, 1500), events); + } else if (distance(fish.position, approach ?? food.position) < brain.progressDistance - 5) { + brain.progressDistance = distance(fish.position, approach ?? food.position); + brain.progressAt = input.now; + } + } + } else if (brain.state === "circling" && elapsed >= brain.stateDuration) { + if (food && food.state === "floating" && food.remainingAmount > 0 && distance(fish.position, food.position) > feedingRadius(fish, food)) { + brain.progressDistance = distance(fish.position, approach ?? food.position); + brain.progressAt = input.now; + transitionFish(fish.id, brain, "approaching-food", "left-feeding-radius", input.now, duration(18000, 26000), events); + } else if (food && food.state === "floating" && food.remainingAmount > 0) { + transitionFish(fish.id, brain, "feeding", "feeding-opening", input.now, duration(360, 690), events); + brain.nextBiteAt = input.now + brain.stateDuration; + } else { + releaseReservation(fish.id, brain); + brain.foodId = null; + transitionFish(fish.id, brain, "returning-to-cruise", "food-gone-while-circling", input.now, duration(620, 1200), events); + } + } else if (brain.state === "feeding" && input.now >= brain.nextBiteAt) { + if (food && food.state === "floating" && food.remainingAmount > 0 && distance(fish.position, food.position) > feedingRadius(fish, food)) { + brain.progressDistance = distance(fish.position, approach ?? food.position); + brain.progressAt = input.now; + transitionFish(fish.id, brain, "approaching-food", "left-feeding-radius", input.now, duration(18000, 26000), events); + } else { + if (food && food.state === "floating" && food.remainingAmount > 0) { + food.remainingAmount = Math.max(0, food.remainingAmount - 1); + brain.hunger = Math.max(0.08, brain.hunger - duration(0.35, 0.56)); + events.push({ + type: "fish-fed", + fishId: fish.id, + foodId: food.id, + position: food.position, + remainingAmount: food.remainingAmount, + }); + } + releaseReservation(fish.id, brain); + brain.foodId = null; + brain.nextInterestAt = input.now + duration(2600, 6200); + transitionFish(fish.id, brain, "returning-to-cruise", "bite-complete", input.now, duration(650, 1350), events); + } + } else if (brain.state === "startled" && elapsed >= brain.stateDuration) { + transitionFish(fish.id, brain, "returning-to-cruise", "danger-cleared", input.now, duration(620, 1300), events); + } else if (brain.state === "returning-to-cruise" && elapsed >= brain.stateDuration) { + transitionFish(fish.id, brain, "cruising", "cruise-restored", input.now, 0, events); + } + + food = brain.foodId ? foods.get(brain.foodId) ?? null : null; + const arrivalRadius = food ? feedingRadius(fish, food) : 28; + const goal = food && ["detecting-food", "approaching-food", "circling", "feeding"].includes(brain.state) + ? approach ?? foodGoal(fish, food, brain, input.now) + : null; + return { + fishId: fish.id, + state: brain.state, + foodId: brain.foodId, + goal, + arrivalRadius, + speedScale: + brain.state === "detecting-food" + ? 1.16 + : brain.state === "approaching-food" + ? 3.1 + : brain.state === "circling" + ? 0.68 + : brain.state === "feeding" + ? 0.24 + : brain.state === "startled" + ? 1.4 + : 1, + reserved: Boolean(brain.foodId && reservations.get(brain.foodId)?.has(fish.id)), + feedingPulse: brain.state === "feeding" ? smoothstep(elapsed / Math.max(1, brain.stateDuration)) : 0, + reason: brain.reason, + }; + }); + } + + function transitionFly( + flyId: string, + brain: FlyBrain, + next: FlyRoutineState, + reason: string, + now: number, + nextDuration: number, + events: PondWorldEvent[], + ) { + if (!LEGAL_FLY_TRANSITIONS[brain.state].includes(next)) { + throw new Error(`Illegal fly transition ${brain.state} -> ${next}`); + } + const previous = brain.state; + brain.state = next; + brain.stateStarted = now; + brain.stateDuration = nextDuration; + brain.reason = reason; + events.push({ type: "fly-state", flyId, from: previous, to: next, reason }); + } + + function updateFlies(input: PondWorldInput, events: PondWorldEvent[]) { + const liveFlies = input.targets.filter((target) => target.type === "fly"); + const liveIds = new Set(liveFlies.map((fly) => fly.id)); + for (const flyId of flyBrains.keys()) { + if (!liveIds.has(flyId)) flyBrains.delete(flyId); + } + + return liveFlies.map((fly): FlyWorldIntent => { + let brain = flyBrains.get(fly.id); + if (!brain) { + brain = { + state: "hovering", + stateStarted: input.now, + stateDuration: duration(900, 2600), + goal: { ...fly.position }, + home: { ...fly.position }, + reason: "surveying-home-patch", + }; + flyBrains.set(fly.id, brain); + } + + const elapsed = input.now - brain.stateStarted; + const arrived = distance(fly.position, brain.goal) < 34; + if ((fly.danger ?? 0) > 0.52 && brain.state !== "startled") { + const heading = Math.atan2(fly.velocity.y, fly.velocity.x) + duration(-0.7, 0.7); + brain.goal = { + x: clamp(fly.position.x + Math.cos(heading) * duration(130, 230), 80, 1506), + y: clamp(fly.position.y + Math.sin(heading) * duration(90, 170) - 35, 70, 850), + }; + transitionFly(fly.id, brain, "startled", "danger-broke-routine", input.now, duration(520, 980), events); + } else if (brain.state === "startled" && elapsed >= brain.stateDuration) { + brain.goal = { ...fly.position }; + transitionFly(fly.id, brain, "hovering", "found-safe-air", input.now, duration(620, 1500), events); + } else if (brain.state === "foraging" && (arrived || elapsed >= brain.stateDuration)) { + brain.goal = { ...fly.position }; + transitionFly(fly.id, brain, "hovering", arrived ? "reached-forage-point" : "abandoned-long-route", input.now, duration(900, 2800), events); + } else if ((brain.state === "hovering" || brain.state === "resting") && elapsed >= brain.stateDuration) { + if (brain.state === "hovering" && random() < 0.22) { + brain.goal = { ...fly.position }; + transitionFly(fly.id, brain, "resting", "settled-on-reed", input.now, duration(1800, 4600), events); + } else { + brain.goal = { + x: clamp(brain.home.x + duration(-280, 280), 80, 1506), + y: clamp(brain.home.y + duration(-150, 150), 70, 850), + }; + transitionFly(fly.id, brain, "foraging", "chose-new-air-patch", input.now, duration(3600, 7800), events); + } + } + + return { + flyId: fly.id, + state: brain.state, + goal: brain.goal, + speedScale: brain.state === "startled" ? 2.8 : brain.state === "foraging" ? 2.1 : brain.state === "hovering" ? 0.42 : 0.08, + reason: brain.reason, + }; + }); + } + + function updateEnvironment(input: PondWorldInput, events: PondWorldEvent[]) { + const { now, delta } = input; + if (now >= nextWindShift) { + windTarget = -1 + random() * 2; + nextWindShift = now + duration(1700, 5300); + } + if (now >= nextCurrentShift) { + targetCurrentA = { x: 3.5 + random() * 4.8, y: -0.8 + random() * 5.2 }; + targetCurrentB = { x: -5.2 + random() * 3.8, y: 2.6 + random() * 4.4 }; + targetSurfaceA = { x: 4.8 + random() * 1.8, y: 3.2 + random() * 1.5 }; + targetSurfaceB = { x: -3.5 + random() * 1.4, y: 2.8 + random() * 1.5 }; + lightTarget = 0.08 + random() * 0.1; + nextCurrentShift = now + duration(1900, 5100); + } + wind = lerp(wind, windTarget, Math.min(1, delta * 0.5)); + currentA = { + x: lerp(currentA.x, targetCurrentA.x, Math.min(1, delta * 0.55)), + y: lerp(currentA.y, targetCurrentA.y, Math.min(1, delta * 0.55)), + }; + currentB = { + x: lerp(currentB.x, targetCurrentB.x, Math.min(1, delta * 0.55)), + y: lerp(currentB.y, targetCurrentB.y, Math.min(1, delta * 0.55)), + }; + surfaceA = { + x: lerp(surfaceA.x, targetSurfaceA.x, Math.min(1, delta * 0.72)), + y: lerp(surfaceA.y, targetSurfaceA.y, Math.min(1, delta * 0.72)), + }; + surfaceB = { + x: lerp(surfaceB.x, targetSurfaceB.x, Math.min(1, delta * 0.72)), + y: lerp(surfaceB.y, targetSurfaceB.y, Math.min(1, delta * 0.72)), + }; + light = lerp(light, lightTarget, Math.min(1, delta * 0.45)); + if (now >= nextAmbientRipple) { + events.push({ + type: "ambient-ripple", + position: { x: 180 + random() * 1080, y: 150 + random() * 650 }, + strength: 0.25 + random() * 0.35, + }); + nextAmbientRipple = now + duration(2200, 6200); + } + } + + function step(input: PondWorldInput): PondWorldFrame { + const events: PondWorldEvent[] = []; + updateEnvironment(input, events); + updateFoods(input.now, events); + const fish = updateFish(input, events); + const flies = updateFlies(input, events); + + return { + environment: { wind, currentA, currentB, surfaceA, surfaceB, light }, + foods: foodFrames(input.now), + fish, + flies, + events, + debug: { seed }, + }; + } + + return { dropFood, step }; +} diff --git a/components/concepts/signal-desk/pond-renderer.ts b/components/concepts/signal-desk/pond-renderer.ts index 8e393cc..3ce59d3 100644 --- a/components/concepts/signal-desk/pond-renderer.ts +++ b/components/concepts/signal-desk/pond-renderer.ts @@ -47,6 +47,7 @@ type StartOptions = { reduced: boolean; canvasClassName: string; signal: AbortSignal; + onCaptureReady: (capture: () => string) => void; }; type KoiTestPose = { row: number; column: number; heading: number; feedingPulse?: number }; @@ -1168,6 +1169,10 @@ export async function startPondRenderer(options: StartOptions) { }; let previousTime: number | null = null; + options.onCaptureReady(() => { + present(); + return canvas.toDataURL(); + }); let frameBudget = 0; let heroOnScreen = true; const draw = (now: number) => { @@ -1276,29 +1281,6 @@ export async function startPondRenderer(options: StartOptions) { if (liveAscii) renderer.setClearColor(themeMedia.matches ? 0xeee8f3 : 0x252031, 1); if (liveAscii && reduced && frameCount > 0) present(); }; - let presentedView = pond.dataset.view; - let transitionImage: HTMLImageElement | null = null; - const onViewChange = () => { - const nextView = pond.dataset.view; - if (nextView === presentedView) return; - if (frameCount > 0 && liveAscii && !reduced) { - present(presentedView); - const image = document.createElement("img"); - image.dataset.pondTransition = ""; - image.alt = ""; - image.src = canvas.toDataURL(); - transitionImage?.remove(); - transitionImage = image; - image.addEventListener("animationend", () => { - image.remove(); - if (transitionImage === image) transitionImage = null; - }, { once: true }); - host.append(image); - } - presentedView = nextView; - if (frameCount > 0) present(); - }; - host.addEventListener("pond-view-change", onViewChange); if (liveAscii) themeMedia.addEventListener("change", onTheme); window.addEventListener("resize", onResize); window.addEventListener("scroll", onScroll, { capture: true, passive: true }); @@ -1307,8 +1289,6 @@ export async function startPondRenderer(options: StartOptions) { window.removeEventListener("resize", onResize); window.removeEventListener("scroll", onScroll, { capture: true }); document.removeEventListener("visibilitychange", visibility); - host.removeEventListener("pond-view-change", onViewChange); - transitionImage?.remove(); heroObserver?.disconnect(); if (liveAscii) themeMedia.removeEventListener("change", onTheme); }; diff --git a/components/concepts/signal-desk/signal-desk.module.css b/components/concepts/signal-desk/signal-desk.module.css index 725812e..7795317 100644 --- a/components/concepts/signal-desk/signal-desk.module.css +++ b/components/concepts/signal-desk/signal-desk.module.css @@ -156,10 +156,6 @@ pointer-events: auto; } -.page:has([data-renderer="fallback"]) .pondSwitcher { - display: none; -} - .pondSwitcher legend { float: left; width: 100%; diff --git a/components/concepts/signal-desk/signal-desk.tsx b/components/concepts/signal-desk/signal-desk.tsx index 229de8f..57eefbb 100644 --- a/components/concepts/signal-desk/signal-desk.tsx +++ b/components/concepts/signal-desk/signal-desk.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowDown, ArrowUpRight, AudioLines, Github, Globe2, Mail, MapPin } from "lucide-react"; @@ -237,10 +237,22 @@ function ProjectExplorer({ reduced, entrance, ready }: { reduced: boolean; entra export function SignalDesk() { const { ready, reduced } = useReducedMotionPreference(); const [pondView, setPondView] = useState<"ascii" | "pixel">("ascii"); + const pageRef = useRef(null); + const [pondStatus, setPondStatus] = useState<{ mode: string; renderer: string }>({ mode: "ascii", renderer: "loading" }); const entrance = ready && !reduced; + useEffect(() => { + const pond = pageRef.current?.querySelector("[data-renderer]"); + if (!pond) return; + const sync = () => setPondStatus({ mode: pond.dataset.view ?? "ascii", renderer: pond.dataset.renderer ?? "loading" }); + const observer = new MutationObserver(sync); + observer.observe(pond, { attributes: true, attributeFilter: ["data-renderer", "data-view"] }); + sync(); + return () => observer.disconnect(); + }, []); + return ( -
+

The decorative pond responds to pointer movement. Right-click open water, or double-tap it on touch devices, to feed the fish.

@@ -269,7 +281,13 @@ export function SignalDesk() { ))}
-
Current view: {pondView === "ascii" ? "ASCII" : "Pixel"}
+
+ {pondStatus.mode !== pondView || pondStatus.renderer === "loading" + ? `Loading ${pondView === "ascii" ? "ASCII" : "Pixel"} view…` + : pondStatus.renderer === "fallback" + ? "Pond unavailable. Try the other view." + : `Current view: ${pondView === "ascii" ? "ASCII" : "Pixel"}`} +
Software engineer diff --git a/package.json b/package.json index 4fa2f85..b0f76b5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint": "eslint", "test:e2e:setup": "python3 -m pip install -r requirements-test.txt && python3 -m playwright install chromium", "test:e2e": "npm run build && python3 -m unittest discover -s tests/browser -p '*_test.py' -v", - "test:pond": "node --test tests/pond-world.test.mts tests/pond-food-priority.test.mts tests/pond-water-mask.test.mts" + "test:pond": "node --test tests/pond-world.test.mts tests/pond-food-priority.test.mts tests/pond-water-mask.test.mts tests/pond-pixel.test.mts" }, "dependencies": { "@fontsource-variable/pixelify-sans": "5.3.0", diff --git a/tests/browser/browser_test.py b/tests/browser/browser_test.py index fdf05dd..b4e2086 100644 --- a/tests/browser/browser_test.py +++ b/tests/browser/browser_test.py @@ -751,7 +751,114 @@ def compare_reference(actual: str, reference_name: str) -> dict: class PondProductionTests(BrowserTestCase): - def test_pond_view_switch_keeps_the_live_scene(self) -> None: + def test_pixel_food_pause_and_reduced_motion(self) -> None: + browser = self.playwright.chromium.launch() + self.addCleanup(browser.close) + page = self.pond_page(browser, viewport={"width": 1440, "height": 900}, reduced_motion="reduce") + self.addCleanup(page.close) + page.goto(f"{self.base_url}/?pond-seed=pixel-lifecycle", wait_until="networkidle") + page.get_by_role("radio", name="Pixel").check() + pond = page.locator("[data-pond-state]") + expect(page.locator("[data-view]")).to_have_attribute("data-view", "pixel") + expect(pond).to_have_attribute("data-pond-state", "running") + expect(pond).to_have_attribute("data-motion", "reduced") + wait_for_condition(page, "document.querySelector('[data-pond-state]')?.dataset.fishPositions") + positions = pond.get_attribute("data-fish-positions") + wait_for_condition(page, "before => document.querySelector('[data-pond-state]')?.dataset.fishPositions !== before", arg=positions) + x, y = find_open_water(page, pond) + before = int(pond.get_attribute("data-food-dropped-count")) + page.mouse.click(x, y, button="right") + wait_for_condition(page, "before => Number(document.querySelector('[data-pond-state]')?.dataset.foodDroppedCount) > before", arg=before) + self.assertGreater(int(pond.get_attribute("data-food-count")), 0) + page.evaluate("""() => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); + document.dispatchEvent(new Event('visibilitychange')); + }""") + expect(pond).to_have_attribute("data-pond-state", "paused") + frame = pond.get_attribute("data-frame") + page.wait_for_timeout(300) + self.assertEqual(pond.get_attribute("data-frame"), frame) + page.evaluate("""() => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => false }); + document.dispatchEvent(new Event('visibilitychange')); + }""") + expect(pond).to_have_attribute("data-pond-state", "running") + wait_for_condition(page, "before => document.querySelector('[data-pond-state]')?.dataset.frame !== before", arg=frame) + + def test_renderer_failure_and_context_loss_recover_by_switching_modes(self) -> None: + browser = self.playwright.chromium.launch() + self.addCleanup(browser.close) + for failure in ("load", "context-loss"): + for mode, other in (("ASCII", "Pixel"), ("Pixel", "ASCII")): + with self.subTest(failure=failure, mode=mode): + page = self.pond_page(browser, viewport={"width": 1280, "height": 800}) + try: + failed = [] + if failure == "load": + token = "pond-renderer-" if mode == "ASCII" else "pond-pixel-renderer-" + def abort_renderer(route): + failed.append(route.request.url) + route.abort() + page.route(f"**/{token}*.js", abort_renderer) + page.goto(f"{self.base_url}/?pond-seed=recovery", wait_until="networkidle") + pond = page.locator("[data-pond-state]") + if mode == "Pixel": + expect(pond).to_have_attribute("data-pond-state", "running") + page.get_by_role("radio", name=mode).check() + if failure == "context-loss": + expect(page.locator("[data-view]")).to_have_attribute("data-view", mode.lower()) + expect(pond).to_have_attribute("data-pond-state", "running") + pond.locator("canvas").evaluate("canvas => canvas.dispatchEvent(new Event('webglcontextlost', {cancelable: true}))") + expect(pond).to_have_attribute("data-pond-state", "fallback") + expect(page.locator("[data-pond-transition]")).to_have_count(0, timeout=10_000) + self.assertIsNone(page.locator("[data-renderer]").get_attribute("data-switching")) + if failure == "load": + self.assertTrue(failed) + self.assertTrue(page.get_by_role("group", name="Pond view").is_visible()) + page.get_by_role("radio", name=other).check() + expect(page.locator("[data-view]")).to_have_attribute("data-view", other.lower()) + expect(pond).to_have_attribute("data-pond-state", "running") + expect(page.locator("[data-renderer]")).to_have_attribute("data-renderer", "three") + expect(pond.locator("canvas")).to_have_count(1) + finally: + page.close() + + def test_rapid_switch_during_delayed_pixel_load_keeps_latest_mode(self) -> None: + browser = self.playwright.chromium.launch() + self.addCleanup(browser.close) + page = self.pond_page(browser, viewport={"width": 1280, "height": 800}) + self.addCleanup(page.close) + held = [] + page.route("**/pond-pixel-renderer-*.js", lambda route: held.append(route)) + page.goto(f"{self.base_url}/?pond-seed=delayed-switch", wait_until="networkidle") + pond = page.locator("[data-pond-state]") + expect(pond).to_have_attribute("data-pond-state", "running") + page.get_by_role("radio", name="Pixel").check() + wait_for_condition(page, "document.querySelector('[data-view]')?.dataset.view === 'pixel'") + expect(pond).to_have_attribute("data-pond-state", "loading") + expect(page.locator("[data-renderer]")).to_have_attribute("data-switching", "") + for _ in range(100): + if held: + break + page.wait_for_timeout(20) + self.assertTrue(held) + page.get_by_role("radio", name="ASCII").check() + expect(pond).to_have_attribute("data-pond-state", "running") + for route in held: + route.continue_() + page.unroute("**/pond-pixel-renderer-*.js") + page.wait_for_load_state("networkidle") + expect(page.locator("[data-view]")).to_have_attribute("data-view", "ascii") + expect(pond).to_have_attribute("data-pond-state", "running") + expect(pond.locator("canvas")).to_have_count(1) + expect(page.locator("[data-pond-transition]")).to_have_count(0, timeout=10_000) + self.assertIsNone(page.locator("[data-renderer]").get_attribute("data-switching")) + page.get_by_role("radio", name="Pixel").check() + expect(page.locator("[data-view]")).to_have_attribute("data-view", "pixel") + expect(pond).to_have_attribute("data-pond-state", "running") + expect(pond.locator("canvas")).to_have_count(1) + + def test_pond_view_switch_restarts_each_interactive_scene(self) -> None: browser = self.playwright.chromium.launch() self.addCleanup(browser.close) for motion in ("reduce", "no-preference"): @@ -763,47 +870,61 @@ def test_pond_view_switch_keeps_the_live_scene(self) -> None: page.goto(f"{self.base_url}/?pond-seed=view-switch", wait_until="networkidle") pond = page.locator("[data-pond-state]") expect(pond).to_have_attribute("data-pond-state", "running") - wait_for_condition(page, "document.querySelector('[data-pond-state]')?.dataset.fishWorldPositions") - canvas = pond.locator("canvas") - canvas.evaluate("canvas => window.pondTestCanvas = canvas") - before = canvas.screenshot() - fish_ids = pond.get_attribute("data-fish-ids") + expect(pond).to_have_attribute("data-motion", "reduced" if motion == "reduce" else "full") ascii_option = page.get_by_role("radio", name="ASCII") pixel_option = page.get_by_role("radio", name="Pixel") - ascii_option.focus() - pond.evaluate("""element => { + page.locator("[data-renderer]").evaluate("""element => { window.pondTransitionCount = 0; - window.pondTransitionObserver = new MutationObserver(records => { - for (const record of records) { - for (const node of record.addedNodes) { - if (node.nodeType === Node.ELEMENT_NODE && (node.matches('[data-pond-transition]') || node.querySelector('[data-pond-transition]'))) window.pondTransitionCount++; - } + new MutationObserver(records => { + for (const record of records) for (const node of record.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE && node.matches('[data-pond-transition]')) window.pondTransitionCount++; } - }); - window.pondTransitionObserver.observe(element, {childList: true, subtree: true}); + }).observe(element, {childList: true}); }""") - for key, option, view in (("ArrowRight", pixel_option, "pixel"), ("ArrowLeft", ascii_option, "ascii"), ("ArrowRight", pixel_option, "pixel")): + ascii_option.focus() + for key, option, view in (("ArrowRight", pixel_option, "pixel"), ("ArrowLeft", ascii_option, "ascii"), ("ArrowRight", pixel_option, "pixel"), ("ArrowLeft", ascii_option, "ascii")): + transitions = page.evaluate("window.pondTransitionCount") page.keyboard.press(key) expect(option).to_be_checked() expect(page.locator("[data-view]")).to_have_attribute("data-view", view) - self.assertTrue(canvas.evaluate("canvas => canvas === window.pondTestCanvas")) - self.assertEqual(fish_ids, pond.get_attribute("data-fish-ids")) - if motion == "no-preference": - self.assertGreater(page.evaluate("window.pondTransitionCount"), 0) - expect(pond.locator("[data-pond-transition]")).to_have_count(0) - else: - self.assertEqual(page.evaluate("window.pondTransitionCount"), 0) - page.evaluate("window.pondTransitionObserver.disconnect()") - self.assertNotEqual(before, canvas.screenshot()) - page.keyboard.press("ArrowLeft") - expect(ascii_option).to_be_checked() - expect(page.locator("[data-view]")).to_have_attribute("data-view", "ascii") - self.assertTrue(canvas.evaluate("canvas => canvas === window.pondTestCanvas")) - self.assertEqual(fish_ids, pond.get_attribute("data-fish-ids")) + expect(pond).to_have_attribute("data-pond-state", "running") + expect(pond.locator("canvas")).to_have_count(1) + if motion == "no-preference": + wait_for_condition(page, "before => window.pondTransitionCount > before", arg=transitions) + else: + self.assertEqual(page.evaluate("window.pondTransitionCount"), transitions) + expect(page.locator("[data-pond-transition]")).to_have_count(0, timeout=10_000) + self.assertIsNone(page.locator("[data-renderer]").get_attribute("data-switching")) + self.assertGreater(int(pond.get_attribute("data-fish-count")), 0) + x, y = find_open_water(page, pond) + before = int(pond.get_attribute("data-primary-impact-count")) + page.mouse.click(x, y) + wait_for_condition(page, "before => Number(document.querySelector('[data-pond-state]')?.dataset.primaryImpactCount) > before", arg=before) + option.focus() self.assertEqual(errors, []) finally: page.close() + def test_pixel_view_restores_original_fish_population(self) -> None: + browser = self.playwright.chromium.launch() + self.addCleanup(browser.close) + for viewport, count in (({"width": 1440, "height": 900}, 20), ({"width": 390, "height": 844}, 14)): + with self.subTest(viewport=viewport): + page = self.pond_page(browser, viewport=viewport) + try: + page.goto(f"{self.base_url}/?pond-seed=pixel-population", wait_until="networkidle") + page.get_by_role("radio", name="Pixel").check() + pond = page.locator("[data-pond-state]") + expect(pond).to_have_attribute("data-pond-state", "running") + expect(pond).to_have_attribute("data-fish-count", str(count)) + wait_for_condition(page, "document.querySelector('[data-pond-state]')?.dataset.fishWorldPositions") + positions = pond.get_attribute("data-fish-world-positions") + self.assertEqual(len(positions.split(";")), count) + wait_for_condition(page, "before => document.querySelector('[data-pond-state]')?.dataset.fishWorldPositions !== before", arg=positions) + self.assertGreaterEqual(int(pond.get_attribute("data-insect-count")), 2) + finally: + page.close() + def test_pixel_view_accepts_input_only_on_masked_water(self) -> None: browser = self.playwright.chromium.launch() self.addCleanup(browser.close) @@ -2004,7 +2125,7 @@ def test_webgl_failure_uses_the_art_directed_fallback(self) -> None: "fallback", ) self.assertEqual(page.locator("[data-pond-state] canvas").count(), 0) - self.assertTrue(page.locator("fieldset").filter(has_text="Pond view").is_hidden()) + self.assertTrue(page.get_by_role("group", name="Pond view").is_visible()) self.assertTrue( page.get_by_role( "heading", name="I make stubborn software behave." @@ -2013,7 +2134,7 @@ def test_webgl_failure_uses_the_art_directed_fallback(self) -> None: context.close() browser.close() - def test_context_loss_hides_pond_view_control(self) -> None: + def test_context_loss_preserves_pond_view_control(self) -> None: browser = self.playwright.chromium.launch() self.addCleanup(browser.close) page = self.pond_page(browser, viewport={"width": 1440, "height": 900}) @@ -2023,7 +2144,7 @@ def test_context_loss_hides_pond_view_control(self) -> None: self.assertTrue(page.get_by_role("group", name="Pond view").is_visible()) pond.locator("canvas").evaluate("canvas => canvas.dispatchEvent(new Event('webglcontextlost', {cancelable: true}))") expect(pond).to_have_attribute("data-pond-state", "fallback") - self.assertTrue(page.locator("fieldset").filter(has_text="Pond view").is_hidden()) + self.assertTrue(page.get_by_role("group", name="Pond view").is_visible()) page.close() def test_pause_resize_and_remount_keep_world_state_bounded(self) -> None: diff --git a/tests/pond-pixel.test.mts b/tests/pond-pixel.test.mts new file mode 100644 index 0000000..c20c9f2 --- /dev/null +++ b/tests/pond-pixel.test.mts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createPondWorld, type PondTarget, type PondWorldEvent } from "../components/concepts/signal-desk/pond-pixel-world.ts"; +import { createPondSimulation } from "../components/concepts/signal-desk/pond-pixel-simulation.ts"; + +const pointer = { position: { x: -1000, y: -1000 }, velocity: { x: 0, y: 0 }, influence: 0, energy: 0 }; +const fish = { id: "koi", row: 0, displayWidth: 30, position: { x: 300, y: 300 }, heading: 0, cruise: 24, alpha: 1, species: "koi" }; +const inside = (x: number, y: number) => ((x - 300) / 180) ** 2 + ((y - 300) / 130) ** 2 <= 1; + +test("seeded food drops merge, settle and expire deterministically", () => { + function run(seed: string) { + const world = createPondWorld(seed); + const first = world.dropFood({ x: 200, y: 200 }); + world.dropFood({ x: 210, y: 200 }); + const step = (now: number) => world.step({ now, delta: 0.1, targets: [] }); + const dropped = step(0); + const settled = step(400); + const expired = step(50000); + return { first, dropped, settled, expired }; + } + const result = run("food-seed"); + assert.deepEqual(result, run("food-seed")); + assert.notDeepEqual(result.dropped.foods, run("other-seed").dropped.foods); + assert.equal(result.dropped.foods.length, 1); + assert.equal(result.dropped.foods[0].id, result.first); + assert.equal(result.dropped.foods[0].state, "dropping"); + assert.deepEqual(result.dropped.events.filter((event) => event.type === "food-dropped").map((event) => event.merged), [false, true]); + assert.equal(result.settled.foods[0].state, "floating"); + assert.equal(result.settled.foods[0].dropProgress, 1); + assert.equal(result.expired.foods.length, 0); + assert.ok(result.expired.events.some((event) => event.type === "food-expired" && event.foodId === result.first && event.reason === "lifetime")); +}); + +test("zero and invalid deltas pause motion and large deltas are capped", () => { + const make = () => createPondSimulation({ seed: "delta", width: 600, height: 600, fish: [fish], flies: [], isWater: inside }); + const paused = make(); + const capped = make(); + const large = make(); + try { + const before = paused.step({ now: 0, delta: 0, pointer }); + for (const delta of [0, -1, Number.NaN, Infinity]) { + assert.deepEqual(paused.step({ now: 0, delta, pointer }).fish, before.fish); + } + assert.deepEqual(large.step({ now: 100, delta: 10, pointer }).fish, capped.step({ now: 100, delta: 0.1, pointer }).fish); + } finally { + paused.destroy(); + capped.destroy(); + large.destroy(); + } +}); + +test("fish remain in water while swimming toward the shoreline", () => { + const simulation = createPondSimulation({ seed: "shore", width: 600, height: 600, fish: [{ ...fish, position: { x: 440, y: 300 } }], flies: [], isWater: inside }); + try { + for (let index = 1; index <= 600; index += 1) { + const frame = simulation.step({ now: index * 1000 / 60, delta: 1 / 60, pointer }); + const position = frame.fish[0].position; + assert.ok(inside(position.x, position.y), `fish left water on step ${index}: ${position.x}, ${position.y}`); + } + } finally { + simulation.destroy(); + } +}); + +test("stalled food reservations are released for a nearby fish", () => { + const world = createPondWorld("food-reservation-progress"); + world.dropFood({ x: 500, y: 500 }); + const target = (id: string, x: number): PondTarget => ({ + id, type: "fish", position: { x, y: 500 }, velocity: { x: 0, y: 0 }, + visible: true, attackable: true, interactionRange: 18, + }); + const distant = [target("a", 200), target("b", 250), target("c", 300)]; + const events: PondWorldEvent[] = []; + let initiallyReserved = 0; + for (let index = 0; index <= 100; index += 1) { + const frame = world.step({ + now: index * 100, delta: 0.1, + targets: index < 20 ? distant : [...distant, target("near", 500)], + foodApproach: (_fish, food) => food, + }); + if (index === 19) initiallyReserved = frame.fish.filter((fish) => fish.reserved).length; + events.push(...frame.events); + } + assert.equal(initiallyReserved, 3); + assert.ok(events.some((event) => event.type === "fish-state" && event.reason === "approach-stalled")); + assert.ok(events.some((event) => event.type === "fish-fed" && event.fishId === "near")); +}); + +test("food reservations require a reachable approach and bites require actual proximity", () => { + const world = createPondWorld("food-reachability"); + world.dropFood({ x: 100, y: 500 }); + const fish: PondTarget = { + id: "near", type: "fish", position: { x: 130, y: 500 }, velocity: { x: 0, y: 0 }, + visible: true, attackable: true, interactionRange: 18, + }; + const events: PondWorldEvent[] = []; + let allowApproach = false; + let movedAway = false; + for (let index = 0; index < 150; index += 1) { + if (index === 45) allowApproach = true; + const frame = world.step({ + now: index * 100, delta: 0.1, targets: [fish], + foodApproach: (_fish, _food, radius) => allowApproach ? { x: 100 + radius * 0.5, y: 500 } : null, + }); + events.push(...frame.events); + if (frame.fish[0].state === "feeding" && !movedAway) { + fish.position = { x: 400, y: 500 }; + movedAway = true; + } + } + assert.ok(events.some((event) => event.type === "fish-state" && event.reason === "food-unreachable")); + assert.ok(movedAway, "reachable food was never approached"); + assert.ok(events.some((event) => event.type === "fish-state" && event.reason === "left-feeding-radius")); + assert.equal(events.some((event) => event.type === "fish-fed"), false); +}); + +test("a nearby fish can replace a distant reservation after a drop startles it", () => { + const world = createPondWorld("priority-after-disturbance"); + world.dropFood({ x: 500, y: 500 }); + const target = (id: string, x: number): PondTarget => ({ + id, type: "fish", position: { x, y: 500 }, velocity: { x: 0, y: 0 }, + visible: true, attackable: true, interactionRange: 18, + }); + const fish = [target("distant-a", 200), target("distant-b", 250), target("distant-c", 300), target("near", 510)]; + const events: PondWorldEvent[] = []; + let occupied = 0; + for (let index = 0; index <= 60; index += 1) { + fish[3].danger = index < 15 ? 1 : 0; + const frame = world.step({ now: index * 100, delta: 0.1, targets: fish }); + if (index === 14) occupied = frame.fish.filter((entry) => entry.reserved).length; + events.push(...frame.events); + } + assert.ok(occupied >= 2); + assert.ok(events.some((event) => event.type === "fish-state" && event.reason === "closer-fish-reserved")); + assert.ok(events.some((event) => event.type === "fish-fed" && event.fishId === "near")); +}); + +test("large fish feed near a bank without leaving water", () => { + const isWater = (x: number, y: number) => x >= 100 && x <= 900 && y >= 100 && y <= 900; + const simulation = createPondSimulation({ + seed: "bank-feeding", width: 1000, height: 1000, isWater, + fish: [{ ...fish, id: "large", displayWidth: 210, position: { x: 360, y: 500 }, heading: Math.PI, cruise: 35 }], flies: [], + }); + simulation.dropFood({ x: 110, y: 500 }); + const events: PondWorldEvent[] = []; + try { + for (let index = 1; index <= 1800; index += 1) { + const frame = simulation.step({ now: index * 1000 / 30, delta: 1 / 30, pointer }); + events.push(...frame.events); + assert.ok(isWater(frame.fish[0].position.x, frame.fish[0].position.y)); + if (events.some((event) => event.type === "fish-fed")) break; + } + assert.ok(events.some((event) => event.type === "fish-reserved-food")); + assert.ok(events.some((event) => event.type === "fish-arrived")); + assert.ok(events.some((event) => event.type === "fish-fed")); + } finally { + simulation.destroy(); + } +}); From 18266f15f854fb4ffc650eee2f7b091f40cb5cf2 Mon Sep 17 00:00:00 2001 From: Lu <42706009+luinbytes@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:00:27 +0000 Subject: [PATCH 2/2] Stabilize project marker geometry assertion --- tests/browser/browser_test.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/browser/browser_test.py b/tests/browser/browser_test.py index b4e2086..5b18b83 100644 --- a/tests/browser/browser_test.py +++ b/tests/browser/browser_test.py @@ -1100,9 +1100,23 @@ def test_project_marker_is_centered_for_every_selection(self) -> None: button.click() page.wait_for_timeout(400) self.assertEqual(button.get_attribute("aria-pressed"), "true") - bounds = button.bounding_box() - marker = button.locator("i").bounding_box() - self.assertAlmostEqual(bounds["y"] + bounds["height"] / 2, marker["y"] + marker["height"] / 2, delta=0.75) + centers = button.evaluate("""async button => { + const marker = button.querySelector('i'); + let previous = null; + let stable = 0; + const deadline = performance.now() + 5000; + while (performance.now() < deadline) { + await new Promise(requestAnimationFrame); + const bounds = button.getBoundingClientRect(); + const dot = marker.getBoundingClientRect(); + const centers = [bounds.y + bounds.height / 2, dot.y + dot.height / 2]; + stable = previous && centers.every((value, index) => Math.abs(value - previous[index]) < 0.05) ? stable + 1 : 0; + if (stable >= 3) return centers; + previous = centers; + } + throw new Error('Project marker did not settle'); + }""") + self.assertAlmostEqual(centers[0], centers[1], delta=0.75) page.close() def test_mobile_navigation_hero_viewport_and_system_color_modes(self) -> None: