diff --git a/README.md b/README.md index afbca51..ff8450c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The site has two public routes: `/`, the interactive portfolio, and `/privacy`, The portfolio features [HomeBot](https://github.com/luinbytes/HomeBot), [rakazo-android](https://github.com/luinbytes/rakazo-android), [Linux Sonar](https://github.com/luinbytes/linux-sonar), [bongocat](https://github.com/luinbytes/bongocat), and [cursor-barrier](https://github.com/luinbytes/cursor-barrier). -The interface follows the system light/dark preference. The pond, fish, cat, +The interface follows the system light/dark preference. The pond, fish, feeding controls, and portrait foil retain their existing behavior in both modes. Navigation is available at every screen size, including without JavaScript. diff --git a/components/concepts/signal-desk/pond-ascii.ts b/components/concepts/signal-desk/pond-ascii.ts new file mode 100644 index 0000000..4458f2d --- /dev/null +++ b/components/concepts/signal-desk/pond-ascii.ts @@ -0,0 +1,736 @@ +import * as THREE from "three"; +import { FullScreenQuad } from "three/addons/postprocessing/Pass.js"; + +export const POND_ASCII_CELL_PLATES = false; +export const POND_ASCII_DPR_CAP = 1.5; +export const POND_ASCII_FPS_CAP = 60; +export const POND_ASCII_MAX_COLS = 160; +export const POND_ASCII_MAX_ROWS = 90; +export const POND_ASCII_STATE_CELL = "floor(vec2(vUv.x, 1.0 - vUv.y) * uGrid)"; + +const RAMP = ".' ,:;t%#XS8@".replace(/ /g, ""); +const GLYPH_ROWS = [ + RAMP, + "-\\|/'", + "·", + "&8@", + "o0", + "\"',.", + ".-=~", + "'.,", + "o°", +] as const; + +const ATLAS_COLS = 12; +const ATLAS_ROWS = GLYPH_ROWS.length; + +export type PondAsciiGrid = { + cell: number; + cols: number; + rows: number; +}; + +export type PondGlyphInk = { + cssWidth: number; + cssHeight: number; + fontPx: number; + atlasCell: number; +}; + +export function pondAsciiGrid(width: number, height: number): PondAsciiGrid { + const nominal = width < 640 ? 10 : 12; + let cell = nominal; + if (Math.ceil(width / cell) > POND_ASCII_MAX_COLS) cell = Math.ceil(width / POND_ASCII_MAX_COLS); + if (Math.ceil(height / cell) > POND_ASCII_MAX_ROWS) cell = Math.max(cell, Math.ceil(height / POND_ASCII_MAX_ROWS)); + const cols = Math.max(1, Math.min(POND_ASCII_MAX_COLS, Math.round(width / cell))); + const rows = Math.max(1, Math.min(POND_ASCII_MAX_ROWS, Math.round(height / cell))); + return { cell, cols, rows }; +} + +export const POND_ASCII_RAMP = RAMP; +export const POND_BODY_MIN_COLUMN = 3; +export const POND_KOI_FRAME_WIDTH = 314; +export const POND_KOI_BOUNDS_HEIGHT = 303; +export const POND_KOI_OPAQUE_WIDTH = 181; +export const POND_KOI_OPAQUE_HEIGHT = 295; + +export function pondBodyGlyphColumn(shade: number) { + const rounded = Math.min(11, Math.max(0, Math.floor(shade * 11 + 0.5))); + return Math.max(POND_BODY_MIN_COLUMN, rounded); +} + +export function pondKoiQuadPixels(lengthCells: number, cell: number) { + const lengthPx = lengthCells * cell; + const widthPx = lengthPx * POND_KOI_OPAQUE_WIDTH / POND_KOI_OPAQUE_HEIGHT; + return { + lengthCells, + widthCells: widthPx / cell, + scaleX: widthPx * POND_KOI_FRAME_WIDTH / POND_KOI_OPAQUE_WIDTH, + scaleY: lengthPx * POND_KOI_BOUNDS_HEIGHT / POND_KOI_OPAQUE_HEIGHT, + }; +} + +export const POND_ASCII_FRAME_TOLERANCE_MS = 2; + +export function pondFrameBudget(budget: number, deltaMs: number, interval: number, tolerance = POND_ASCII_FRAME_TOLERANCE_MS) { + const accumulated = budget + deltaMs; + if (accumulated < interval - tolerance) return { budget: accumulated, stepMs: 0 }; + const remainder = accumulated >= interval ? accumulated % interval : 0; + return { budget: remainder, stepMs: accumulated - remainder }; +} + +function themeVector(hex: string, alpha: number) { + const color = new THREE.Color(hex); + return new THREE.Vector4(color.r, color.g, color.b, alpha); +} + +function themeSet(dark: boolean) { + const water = dark ? themeVector("#cfc0dd", 0.07) : themeVector("#66516f", 0.07); + const stone = dark ? themeVector("#8a8076", 0.4) : themeVector("#8a7f74", 0.4); + const grass = dark ? themeVector("#7c8a68", 0.72) : themeVector("#6f8250", 0.72); + const pad = dark ? themeVector("#7f9a7a", 0.82) : themeVector("#5f7f5c", 0.82); + const body = themeVector(dark ? "#e2ded4" : "#918a80", 1); + const ink = themeVector(dark ? "#a8a39a" : "#655e58", 1); + const tancho = themeVector(dark ? "#f07a37" : "#c34d1c", 1); + const fin = dark ? themeVector("#bcb8b0", 0.9) : themeVector("#79736b", 0.9); + const eye = dark ? themeVector("#f4edf9", 1) : themeVector("#1f1a2a", 1); + const ripple = dark ? themeVector("#cfc0dd", 0.75) : themeVector("#66516f", 0.8); + const wake = dark ? themeVector("#cfc0dd", 0.55) : themeVector("#66516f", 0.55); + const food = dark ? themeVector("#ece6f0", 1) : themeVector("#6e6a86", 1); + const fly = dark ? themeVector("#8ca0bd", 0.85) : themeVector("#6f84a3", 0.85); + const bonk = dark ? themeVector("#cfc0dd", 1) : themeVector("#66516f", 1); + return [themeVector("#000000", 0), water, stone, grass, pad, body, ink, tancho, fin, eye, ripple, wake, food, themeVector("#000000", 0), fly, bonk]; +} + +function makeTarget(width: number, height: number) { + const target = new THREE.WebGLRenderTarget(width, height, { + depthBuffer: false, + stencilBuffer: false, + magFilter: THREE.NearestFilter, + minFilter: THREE.NearestFilter, + generateMipmaps: false, + type: THREE.UnsignedByteType, + }); + target.texture.colorSpace = THREE.NoColorSpace; + return target; +} + +function drawGlyphAtlas(context: CanvasRenderingContext2D, atlasCell: number, fontPx: number) { + context.clearRect(0, 0, context.canvas.width, context.canvas.height); + context.fillStyle = "#ffffff"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.font = `700 ${fontPx}px "Space Mono", monospace`; + GLYPH_ROWS.forEach((row, rowIndex) => { + for (let column = 0; column < row.length; column += 1) { + context.fillText(row[column], column * atlasCell + atlasCell / 2, rowIndex * atlasCell + atlasCell / 2 + 1); + } + }); +} + +function glyphBounds(context: CanvasRenderingContext2D, atlasCell: number, column: number, row: number) { + const image = context.getImageData(column * atlasCell, row * atlasCell, atlasCell, atlasCell); + let left = atlasCell; + let top = atlasCell; + let right = -1; + let bottom = -1; + for (let y = 0; y < atlasCell; y += 1) { + for (let x = 0; x < atlasCell; x += 1) { + if (image.data[(y * atlasCell + x) * 4 + 3] <= 24) continue; + left = Math.min(left, x); + top = Math.min(top, y); + right = Math.max(right, x); + bottom = Math.max(bottom, y); + } + } + return { + left, + top, + right, + bottom, + width: right < 0 ? 0 : right - left + 1, + height: bottom < 0 ? 0 : bottom - top + 1, + }; +} + +function buildGlyphAtlas(cell: number, pixelRatio: number) { + const atlasCell = Math.max(8, Math.round(cell * pixelRatio)); + const canvas = document.createElement("canvas"); + canvas.width = atlasCell * ATLAS_COLS; + canvas.height = atlasCell * ATLAS_ROWS; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("Unable to draw the pond glyph atlas"); + let fontPx = atlasCell; + let bounds = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }; + for (let attempt = 0; attempt < 5; attempt += 1) { + drawGlyphAtlas(context, atlasCell, fontPx); + bounds = glyphBounds(context, atlasCell, RAMP.length - 1, 0); + const wideEnough = bounds.width >= atlasCell * 0.6; + const tallEnough = bounds.height >= atlasCell * 0.75; + const clipped = bounds.left <= 0 || bounds.top <= 0 || bounds.right >= atlasCell - 1 || bounds.bottom >= atlasCell - 1; + if ((wideEnough && tallEnough) || clipped || bounds.width === 0) break; + const needed = Math.max(0.6 / (bounds.width / atlasCell), 0.75 / (bounds.height / atlasCell)); + const next = Math.min(atlasCell * 1.35, Math.ceil(fontPx * Math.min(needed, 1.2))); + if (next <= fontPx) break; + fontPx = next; + } + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.NoColorSpace; + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.generateMipmaps = false; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.needsUpdate = true; + const ink: PondGlyphInk = { + cssWidth: bounds.width / atlasCell * cell, + cssHeight: bounds.height / atlasCell * cell, + fontPx, + atlasCell, + }; + return { texture, ink }; +} + +function createAsciiMaterial() { + const material = new THREE.ShaderMaterial({ + uniforms: { + tMaterial: { value: null as THREE.Texture | null }, + tGlyphs: { value: null as THREE.Texture | null }, + tState: { value: null as THREE.Texture | null }, + uGrid: { value: new THREE.Vector2(1, 1) }, + uResolution: { value: new THREE.Vector2(1, 1) }, + uCell: { value: 12 }, + uDark: { value: 1 }, + uTheme: { value: themeSet(true) }, + uRipples: { value: Array.from({ length: 6 }, () => new THREE.Vector4(-9999, -9999, 0, 0)) }, + uCard: { value: new THREE.Vector4(-1, -1, -1, -1) }, + uDim: { value: 0.3 }, + uEllipse: { value: new THREE.Vector4(0, 0, 23, 18) }, + uPads: { value: Array.from({ length: 12 }, () => new THREE.Vector4(-9999, -9999, 0, 0)) }, + uHysteresis: { value: 0.04 }, + uCellPlates: { value: POND_ASCII_CELL_PLATES ? 1 : 0 }, + uPass: { value: 1 }, + uBackground: { value: new THREE.Vector3(37 / 255, 32 / 255, 49 / 255) }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform sampler2D tMaterial; + uniform sampler2D tGlyphs; + uniform sampler2D tState; + uniform vec2 uGrid; + uniform vec2 uResolution; + uniform float uCell; + uniform float uDark; + uniform vec4 uTheme[16]; + uniform vec4 uRipples[6]; + uniform vec4 uCard; + uniform float uDim; + uniform vec4 uEllipse; + uniform vec4 uPads[12]; + uniform float uHysteresis; + uniform float uCellPlates; + uniform float uPass; + uniform vec3 uBackground; + varying vec2 vUv; + + float hash21(vec2 cell) { + vec3 p = fract(vec3(cell.xyx) * 0.1031); + p += dot(p, p.yzx + 33.33); + return fract((p.x + p.y) * p.z); + } + + vec2 cssFromUv(vec2 uv) { + return vec2(uv.x, 1.0 - uv.y) * uResolution; + } + + vec4 materialCell(vec2 cell) { + vec2 uv = vec2((cell.x + 0.5) / uGrid.x, 1.0 - (cell.y + 0.5) / uGrid.y); + return texture2D(tMaterial, uv); + } + + float idAt(vec2 cell) { + if (cell.x < 0.0 || cell.y < 0.0 || cell.x >= uGrid.x || cell.y >= uGrid.y) return 0.0; + return floor(materialCell(cell).r * 255.0 + 0.5); + } + + bool bodyId(float id) { + return id > 4.5 && id < 9.5; + } + + float edgeDistance(vec2 cell) { + float dist = 6.0; + for (int y = -4; y <= 4; y++) { + for (int x = -4; x <= 4; x++) { + if (!bodyId(idAt(cell + vec2(float(x), float(y))))) dist = min(dist, length(vec2(float(x), float(y)))); + } + } + return dist; + } + + float glyph(float row, float column, vec2 local) { + vec2 atlas = vec2((column + local.x) / ${ATLAS_COLS}.0, 1.0 - (row + local.y) / ${ATLAS_ROWS}.0); + return texture2D(tGlyphs, atlas).a; + } + + float rampColumn(float index) { + return clamp(floor(index * 11.0 + 0.5), 0.0, 11.0); + } + + void main() { + vec2 screenCss = cssFromUv(vUv); + if (uPass >= 0.5) { + vec2 cell = clamp(floor(screenCss / uCell), vec2(0.0), uGrid - 1.0); + vec2 local = fract(screenCss / max(uCell, 0.001)); + vec2 stateUv = vec2((cell.x + 0.5) / uGrid.x, 1.0 - (cell.y + 0.5) / uGrid.y); + vec4 state = texture2D(tState, stateUv); + float id = floor(state.g * 255.0 + 0.5); + float alpha = state.a; + if (id < 0.5 || alpha < 0.004) { + gl_FragColor = vec4(uBackground, 1.0); + return; + } + float packed = floor(state.b * 255.0 + 0.5); + float row = floor(packed / 16.0); + float column = mod(packed, 16.0); + vec4 theme = uTheme[int(clamp(id, 0.0, 15.0))]; + float dim = screenCss.x > uCard.x && screenCss.x < uCard.z && screenCss.y > uCard.y && screenCss.y < uCard.w ? uDim : 1.0; + float glyphAlpha = glyph(row, column, local) * alpha * dim; + vec3 encoded = linearToOutputTexel(vec4(theme.rgb, 1.0)).rgb; + gl_FragColor = vec4(encoded * glyphAlpha + uBackground * (1.0 - glyphAlpha), 1.0); + return; + } + + vec2 cell = ${POND_ASCII_STATE_CELL}; + vec2 css = (cell + 0.5) * uCell; + vec2 fromCenter = cell + 0.5 - uEllipse.xy; + float angle = atan(fromCenter.y, fromCenter.x); + float wobble = 1.0 + 0.035 * sin(angle * 3.0); + vec2 stretched = fromCenter / (uEllipse.zw * wobble); + float radius = length(stretched); + vec2 gradient = fromCenter / max(uEllipse.zw * uEllipse.zw, vec2(0.001)); + float edge = (radius - 1.0) / max(length(gradient), 0.001); + + float padHit = 0.0; + float padCover = 0.0; + float nearestPad = 10.0; + float secondPad = 10.0; + for (int i = 0; i < 12; i++) { + vec2 pad = uPads[i].xy; + vec2 padPoint = cell + 0.5 - (uEllipse.xy + pad); + float dist = length(padPoint / vec2(1.0, 0.8)); + if (dist < uPads[i].z && (i >= 3 || radius < 1.0)) { + float relativeDist = dist / uPads[i].z; + if (relativeDist < nearestPad) { + secondPad = nearestPad; + nearestPad = relativeDist; + } else { + secondPad = min(secondPad, relativeDist); + } + float padAngle = atan(padPoint.y, padPoint.x); + float wedge = abs(atan(sin(padAngle - uPads[i].w), cos(padAngle - uPads[i].w))); + if (wedge > 0.38 || dist < 0.6) { + padHit = 1.0; + float vein = abs(sin((padAngle - uPads[i].w) * 4.0)); + padCover = max(padCover, clamp(0.35 + (1.0 - dist / uPads[i].z) * 0.5 + vein * 0.15, 0.0, 1.0)); + } + } + } + if (secondPad < 1.0 && secondPad - nearestPad < 0.14) padHit = 0.0; + + float rippleStrength = 0.0; + float rippleRadius = 0.0; + for (int i = 0; i < 6; i++) { + if (uRipples[i].w <= 0.001) continue; + float band = abs(length(css - uRipples[i].xy) - uRipples[i].z); + if (band < 1.6 * uCell && uRipples[i].w > rippleStrength) { + rippleStrength = uRipples[i].w; + rippleRadius = band / (1.6 * uCell); + } + } + + if (uPass < 0.5) { + float id = idAt(cell); + vec4 fish = materialCell(cell); + float along = fish.g; + float across = fish.b; + bool creature = id > 4.5 && id < 10.5 || id > 11.5; + if (bodyId(id) && padHit > 0.5) { + id = 4.0; + creature = true; + } + if (!creature && id < 10.5) { + if (padHit > 0.5) id = 4.0; + else if (rippleStrength > 0.02 && radius < 1.02) id = 10.0; + else if (edge >= 0.0 && edge < 2.7) id = 2.0; + else if (edge >= 2.7 && edge < 6.5) id = 3.0; + else if (radius < 1.0) id = 1.0; + else id = 0.0; + } + if (id > 10.5 && id < 11.5 && fish.g <= 0.02) id = radius < 1.0 ? 1.0 : 0.0; + + float shade = 0.5; + if (bodyId(id) && abs(id - 8.0) > 0.5 && abs(id - 9.0) > 0.5) { + float dist = edgeDistance(cell); + if (along > 0.74) { + float tail = clamp((along - 0.74) / 0.26, 0.0, 1.0); + shade = tail < 0.45 ? 0.0909 : 0.0; + } else if (across < 0.24 && dist > 1.7) { + shade = hash21(cell) > 0.42 ? 1.0 : 0.9091; + } else if (dist < 1.15 || across > 0.48) { + float edgePick = hash21(cell + 1.0); + shade = edgePick > 0.66 ? 0.4545 : edgePick > 0.33 ? 0.3636 : 0.2727; + } else { + float middle = hash21(cell + 2.0); + shade = middle > 0.66 ? 0.8182 : middle > 0.33 ? 0.7273 : 0.5455; + } + if (id > 6.5 && id < 7.5) shade = 1.0; + } + if (id > 12.5 && id < 13.5) shade = clamp(fish.g, 0.2, 0.9); + else if (id > 13.5 && id < 14.5) shade = 0.62; + else if (id > 14.5) shade = 0.88; + + if (id > 0.5 && id < 1.5) { + float nearest = 9.0; + bool fin = false; + for (int y = -2; y <= 2; y++) { + for (int x = -2; x <= 2; x++) { + float reach = length(vec2(float(x), float(y))); + if (reach < 0.5 || reach > 2.05) continue; + vec2 neighbor = cell + vec2(float(x), float(y)); + vec4 beside = materialCell(neighbor); + float besideId = idAt(neighbor); + if (besideId > 4.5 && besideId < 7.5 && beside.g > 0.12 && beside.g < 0.40 && beside.b > 0.40) { + fin = true; + nearest = min(nearest, reach); + } + } + } + if (fin) { + id = 8.0; + shade = nearest < 1.35 ? 0.3636 : 0.2727; + } + } + + vec2 stateUv = vec2((cell.x + 0.5) / uGrid.x, 1.0 - (cell.y + 0.5) / uGrid.y); + vec4 previous = texture2D(tState, stateUv); + float previousId = floor(previous.g * 255.0 + 0.5); + if (id < 10.5 && abs(previousId - id) < 0.5 && abs(shade - previous.r) < uHysteresis) shade = previous.r; + + float column = 0.0; + float row = 0.0; + float alpha = id < 0.5 ? 0.0 : uTheme[int(clamp(id, 0.0, 15.0))].a; + if (id < 0.5) { + alpha = 0.0; + } else if (id < 1.5) { + row = 2.0; + } else if (id < 2.5) { + row = 4.0; + column = hash21(cell + 4.0) > 0.5 ? 1.0 : 0.0; + alpha = mix(0.45, 0.35, clamp(edge / 2.7, 0.0, 1.0)); + } else if (id < 3.5) { + float t = clamp(1.0 - (edge - 2.7) / 3.8, 0.0, 1.0); + if (hash21(cell + 12.0) > 0.85 * t) alpha = 0.0; + row = 5.0; + column = floor(hash21(cell + 3.0) * 4.0); + if (alpha > 0.0) alpha = mix(0.35, 0.95, t); + } else if (id < 4.5) { + row = 3.0; + column = padCover > 0.85 ? 0.0 : padCover > 0.55 ? 1.0 : 2.0; + alpha = mix(0.6, 1.0, padCover); + } else if (id < 8.5) { + row = 0.0; + column = max(${POND_BODY_MIN_COLUMN}.0, rampColumn(shade)); + } else if (id < 9.5) { + row = 8.0; + } else if (id < 10.5) { + row = 6.0; + column = rippleStrength > 0.75 ? 3.0 : rippleStrength > 0.5 ? 2.0 : rippleStrength > 0.25 ? 1.0 : 0.0; + alpha *= rippleStrength * (1.0 - rippleRadius); + } else if (id < 11.5) { + float life = fish.g; + row = 7.0; + column = life > 0.66 ? 0.0 : life > 0.33 ? 2.0 : 1.0; + alpha *= life; + } else if (id < 12.5) { + row = 8.0; + column = hash21(cell) > 0.5 ? 1.0 : 0.0; + } else { + row = 0.0; + column = rampColumn(shade); + } + gl_FragColor = vec4(shade, id / 255.0, (row * 16.0 + column) / 255.0, alpha); + } + } + `, + }); + material.blending = THREE.NoBlending; + material.depthTest = false; + material.depthWrite = false; + material.toneMapped = false; + return material; +} + +function createScreenMaterial() { + const material = new THREE.ShaderMaterial({ + uniforms: { + tGlyphs: { value: null as THREE.Texture | null }, + tState: { value: null as THREE.Texture | null }, + uGrid: { value: new THREE.Vector2(1, 1) }, + uResolution: { value: new THREE.Vector2(1, 1) }, + uCell: { value: 12 }, + uTheme: { value: themeSet(true) }, + uCard: { value: new THREE.Vector4(-1, -1, -1, -1) }, + uDim: { value: 0.3 }, + uBackground: { value: new THREE.Vector3(37 / 255, 32 / 255, 49 / 255) }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + precision highp float; + uniform sampler2D tGlyphs; + uniform sampler2D tState; + uniform vec2 uGrid; + uniform vec2 uResolution; + uniform float uCell; + uniform vec4 uTheme[16]; + uniform vec4 uCard; + uniform float uDim; + uniform vec3 uBackground; + varying vec2 vUv; + + vec2 cssFromUv(vec2 uv) { + return vec2(uv.x, 1.0 - uv.y) * uResolution; + } + + float glyph(float row, float column, vec2 local) { + vec2 atlas = vec2((column + local.x) / ${ATLAS_COLS}.0, 1.0 - (row + local.y) / ${ATLAS_ROWS}.0); + return texture2D(tGlyphs, atlas).a; + } + + void main() { + vec2 screenCss = cssFromUv(vUv); + vec2 cell = clamp(floor(screenCss / uCell), vec2(0.0), uGrid - 1.0); + vec2 local = fract(screenCss / max(uCell, 0.001)); + vec2 stateUv = vec2((cell.x + 0.5) / uGrid.x, 1.0 - (cell.y + 0.5) / uGrid.y); + vec4 state = texture2D(tState, stateUv); + float id = floor(state.g * 255.0 + 0.5); + float alpha = state.a; + if (id < 0.5 || alpha < 0.004) { + gl_FragColor = vec4(uBackground, 1.0); + return; + } + float packed = floor(state.b * 255.0 + 0.5); + float row = floor(packed / 16.0); + float column = mod(packed, 16.0); + vec4 theme = uTheme[int(clamp(id, 0.0, 15.0))]; + float dim = screenCss.x > uCard.x && screenCss.x < uCard.z && screenCss.y > uCard.y && screenCss.y < uCard.w ? uDim : 1.0; + float glyphAlpha = glyph(row, column, local) * alpha * dim; + vec3 encoded = linearToOutputTexel(vec4(theme.rgb, 1.0)).rgb; + if (id > 3.5 && id < 4.5) { + float plate = step(0.045, local.x) * step(local.x, 0.955) * step(0.045, local.y) * step(local.y, 0.955) * alpha * dim; + vec3 fill = mix(uBackground, encoded * mix(0.7, 1.1, state.r), plate); + gl_FragColor = vec4(mix(fill, uBackground, glyphAlpha * 0.28), 1.0); + } else if (id > 12.5 && id < 13.5) { + float plate = step(0.045, local.x) * step(local.x, 0.955) * step(0.045, local.y) * step(local.y, 0.955) * alpha * dim; + vec3 fill = mix(uBackground, encoded * mix(0.45, 1.25, state.r), plate); + gl_FragColor = vec4(mix(fill, uBackground, glyphAlpha * 0.35), 1.0); + } else if (id > 4.5 && id < 7.5) { + float plate = step(0.055, local.x) * step(local.x, 0.945) * step(0.055, local.y) * step(local.y, 0.945) * alpha * dim; + vec3 fill = mix(uBackground, encoded, plate * 0.82); + gl_FragColor = vec4(mix(fill, uBackground, glyphAlpha * 0.65), 1.0); + } else { + gl_FragColor = vec4(encoded * glyphAlpha + uBackground * (1.0 - glyphAlpha), 1.0); + } + } + `, + }); + material.blending = THREE.NoBlending; + material.depthTest = false; + material.depthWrite = false; + material.toneMapped = false; + return material; +} + +export type PondRippleUniform = { x: number; y: number; radius: number; strength: number }; + +export class PondAscii { + private readonly renderer: THREE.WebGLRenderer; + private readonly material: THREE.ShaderMaterial; + private readonly screenMaterial: THREE.ShaderMaterial; + private readonly quad: FullScreenQuad; + private sceneTarget: THREE.WebGLRenderTarget; + private materialTarget: THREE.WebGLRenderTarget; + private stateRead: THREE.WebGLRenderTarget; + private stateWrite: THREE.WebGLRenderTarget; + private glyphs: THREE.Texture | null = null; + private readonly clearColor = new THREE.Color(); + readonly grid: PondAsciiGrid = { cell: 12, cols: 1, rows: 1 }; + ink: PondGlyphInk = { cssWidth: 0, cssHeight: 0, fontPx: 0, atlasCell: 0 }; + private dark = true; + private pixelRatio = 1; + + constructor(renderer: THREE.WebGLRenderer) { + this.renderer = renderer; + const size = renderer.getSize(new THREE.Vector2()); + const ratio = renderer.getPixelRatio(); + this.material = createAsciiMaterial(); + this.screenMaterial = createScreenMaterial(); + this.sceneTarget = makeTarget(2, 2); + this.materialTarget = makeTarget(2, 2); + this.stateRead = makeTarget(1, 1); + this.stateWrite = makeTarget(1, 1); + this.quad = new FullScreenQuad(this.material); + this.resize(size.x, size.y, ratio); + this.setTheme(window.matchMedia("(prefers-color-scheme: light)").matches ? false : true); + } + + get sceneBuffer() { + return this.sceneTarget; + } + + get materialBuffer() { + return this.materialTarget; + } + + resize(width: number, height: number, pixelRatio: number) { + const grid = pondAsciiGrid(width, height); + const rebake = this.glyphs === null || grid.cell !== this.grid.cell || pixelRatio !== this.pixelRatio; + this.grid.cell = grid.cell; + this.grid.cols = grid.cols; + this.grid.rows = grid.rows; + this.pixelRatio = pixelRatio; + this.sceneTarget.setSize(grid.cols, grid.rows); + this.materialTarget.setSize(grid.cols, grid.rows); + this.stateRead.setSize(grid.cols, grid.rows); + this.stateWrite.setSize(grid.cols, grid.rows); + const uniforms = this.material.uniforms; + const screen = this.screenMaterial.uniforms; + uniforms.uGrid.value.set(grid.cols, grid.rows); + screen.uGrid.value.set(grid.cols, grid.rows); + uniforms.uResolution.value.set(width, height); + screen.uResolution.value.set(width, height); + uniforms.uCell.value = grid.cell; + screen.uCell.value = grid.cell; + uniforms.tMaterial.value = this.materialTarget.texture; + if (rebake) { + const next = buildGlyphAtlas(grid.cell, pixelRatio); + this.glyphs?.dispose(); + this.glyphs = next.texture; + this.ink = next.ink; + uniforms.tGlyphs.value = this.glyphs; + screen.tGlyphs.value = this.glyphs; + } + const previous = this.renderer.getRenderTarget(); + this.renderer.getClearColor(this.clearColor); + const clearAlpha = this.renderer.getClearAlpha(); + this.renderer.setClearColor(0x000000, 0); + this.renderer.setRenderTarget(this.stateRead); + this.renderer.clear(); + this.renderer.setRenderTarget(this.stateWrite); + this.renderer.clear(); + this.renderer.setRenderTarget(previous); + this.renderer.setClearColor(this.clearColor, clearAlpha); + } + + setTheme(dark: boolean) { + this.dark = dark; + const background = dark + ? [20 / 255, 20 / 255, 20 / 255] + : [242 / 255, 241 / 255, 237 / 255]; + (this.material.uniforms.uBackground.value as THREE.Vector3).set(background[0], background[1], background[2]); + (this.screenMaterial.uniforms.uBackground.value as THREE.Vector3).set(background[0], background[1], background[2]); + const next = themeSet(dark); + const theme = this.material.uniforms.uTheme.value as THREE.Vector4[]; + const screenTheme = this.screenMaterial.uniforms.uTheme.value as THREE.Vector4[]; + next.forEach((color, index) => { + theme[index].copy(color); + screenTheme[index].copy(color); + }); + this.material.uniforms.uDark.value = dark ? 1 : 0; + } + + get isDark() { + return this.dark; + } + + setFrame(ellipse: THREE.Vector4, card: THREE.Vector4, ripples: readonly PondRippleUniform[], anchors: readonly THREE.Vector2[], fish: readonly THREE.Vector2[], time: number, reduced: boolean) { + this.material.uniforms.uEllipse.value.copy(ellipse); + this.material.uniforms.uCard.value.copy(card); + this.screenMaterial.uniforms.uCard.value.copy(card); + const slots = this.material.uniforms.uRipples.value as THREE.Vector4[]; + for (let index = 0; index < slots.length; index += 1) { + const ripple = ripples[index]; + if (ripple && ripple.strength > 0) slots[index].set(ripple.x, ripple.y, ripple.radius, ripple.strength); + else slots[index].set(-9999, -9999, 0, 0); + } + const cell = this.grid.cell; + const pads = this.material.uniforms.uPads.value as THREE.Vector4[]; + const positions = [ + new THREE.Vector2((ellipse.x - 0.34 * ellipse.z) * cell, (ellipse.y - 0.18 * ellipse.w) * cell), + new THREE.Vector2((ellipse.x + 0.38 * ellipse.z) * cell, (ellipse.y + 0.12 * ellipse.w) * cell), + new THREE.Vector2((ellipse.x - 0.04 * ellipse.z) * cell, (ellipse.y + 0.42 * ellipse.w) * cell), + ...anchors, + ]; + for (let index = 0; index < pads.length; index += 1) { + const position = positions[index]; + if (!position) { + pads[index].set(-9999, -9999, 0, 0); + continue; + } + let influence = 0; + for (const swimmer of fish) { + const dx = position.x - swimmer.x; + const dy = position.y - swimmer.y; + const distance = Math.hypot(dx, dy); + if (distance > 0 && distance < cell * 9) influence += dx / distance * (1 - distance / (cell * 9)); + } + const drift = reduced || index >= 3 ? 0 : Math.sin(time * 0.0008 + index * 1.7) * cell * 0.24 + Math.max(-1, Math.min(1, influence)) * cell * 0.18; + const size = index < 3 ? (cell > 10 ? 3.4 : 2.8) : (cell > 10 ? 4.8 : 4.4); + pads[index].set((position.x + drift) / cell - ellipse.x, position.y / cell - ellipse.y + (index < 3 ? 0 : 0.5), size, index * 1.7 + 0.5); + } + } + + compose(renderer: THREE.WebGLRenderer) { + const previous = renderer.getRenderTarget(); + renderer.getClearColor(this.clearColor); + const clearAlpha = renderer.getClearAlpha(); + renderer.setClearColor(0x000000, 0); + const uniforms = this.material.uniforms; + uniforms.uPass.value = 0; + uniforms.tState.value = this.stateRead.texture; + renderer.setRenderTarget(this.stateWrite); + renderer.clear(); + this.quad.render(renderer); + this.quad.material = this.screenMaterial; + this.screenMaterial.uniforms.tState.value = this.stateWrite.texture; + renderer.setRenderTarget(null); + this.quad.render(renderer); + this.quad.material = this.material; + renderer.setRenderTarget(previous); + renderer.setClearColor(this.clearColor, clearAlpha); + const swap = this.stateRead; + this.stateRead = this.stateWrite; + this.stateWrite = swap; + } + + dispose() { + this.sceneTarget.dispose(); + this.materialTarget.dispose(); + this.stateRead.dispose(); + this.stateWrite.dispose(); + this.glyphs?.dispose(); + this.material.dispose(); + this.screenMaterial.dispose(); + } +} diff --git a/components/concepts/signal-desk/pond-environment.module.css b/components/concepts/signal-desk/pond-environment.module.css index afe37a0..aaea46d 100644 --- a/components/concepts/signal-desk/pond-environment.module.css +++ b/components/concepts/signal-desk/pond-environment.module.css @@ -4,7 +4,7 @@ z-index: -2; overflow: hidden; pointer-events: none; - background: #073d3a; + background: var(--site-bg); } .pondInput { @@ -85,6 +85,24 @@ image-rendering: pixelated; } +.pondCanvas[data-presented="pending"] { + visibility: hidden; +} + +.pondHost > [data-pond-transition] { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + image-rendering: pixelated; + animation: pond-view-fade 300ms cubic-bezier(0.22, 0.72, 0.24, 1) both; +} + +@keyframes pond-view-fade { + from { opacity: 1; } + to { opacity: 0; } +} + .pondGrade { z-index: 3; background: @@ -106,8 +124,11 @@ animation: glint 11s ease-in-out infinite alternate; } +.pond[data-renderer="three"] .pondImage, +.pond[data-renderer="three"] .pondGrade, .pond[data-renderer="three"] .surfaceLight { - opacity: 0.16; + opacity: 0; + visibility: hidden; animation: none; } @@ -132,4 +153,9 @@ .surfaceLight { 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 bd94673..0b8e1c6 100644 --- a/components/concepts/signal-desk/pond-environment.tsx +++ b/components/concepts/signal-desk/pond-environment.tsx @@ -8,12 +8,20 @@ const subscribe = () => () => {}; const clientSnapshot = () => true; const serverSnapshot = () => false; -export function PondEnvironment({ reduced }: { reduced: boolean }) { +export function PondEnvironment({ reduced, mode = "ascii" }: { reduced: boolean; mode?: "ascii" | "pixel" }) { const hydrated = useSyncExternalStore(subscribe, clientSnapshot, serverSnapshot); 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]); + useEffect(() => { const pond = pondRef.current; const host = hostRef.current; diff --git a/components/concepts/signal-desk/pond-model.ts b/components/concepts/signal-desk/pond-model.ts index af99501..111b70e 100644 --- a/components/concepts/signal-desk/pond-model.ts +++ b/components/concepts/signal-desk/pond-model.ts @@ -8,6 +8,7 @@ export type PondTarget = { visible: boolean; attackable: boolean; interactionRange: number; + bodyRadius?: number; species?: string; danger?: number; }; @@ -80,35 +81,7 @@ export type RockAnchor = { surfaceArea: number; }; -export type CatWorldState = - | "idle" - | "observe" - | "notice-target" - | "approach" - | "anticipate-hop" - | "airborne" - | "land" - | "settle" - | "prepare-bat" - | "bat" - | "react" - | "recover"; - -export type CatRoutine = "resting" | "watching" | "hunting" | "patrolling"; - export type PondWorldEvent = - | { type: "transition"; from: CatWorldState; to: CatWorldState; reason: string } - | { type: "takeoff"; fromAnchor: string; toAnchor: string } - | { type: "land"; anchorId: string; position: PondPoint; impact: number } - | { - type: "bat"; - targetId: string; - targetType: PondTarget["type"]; - aim: PondPoint; - distance: number; - reach: number; - hit: boolean; - } | { 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 } @@ -123,28 +96,11 @@ export type PondWorldInput = { now: number; delta: number; targets: readonly PondTarget[]; - visibleAnchorIds: readonly string[]; + visibleAnchorIds?: readonly string[]; + foodApproach?: (fish: PondTarget, food: PondPoint, arrivalRadius: number, preferred: PondPoint) => PondPoint | null; }; export type PondWorldFrame = { - cat: { - state: CatWorldState; - routine: CatRoutine; - contact: PondPoint; - lift: number; - squashX: number; - squashY: number; - surfaceAngle: number; - facing: 1 | -1; - aim: PondPoint; - grounded: boolean; - anchorId: string; - rockId: string; - destinationAnchorId: string | null; - selectedTargetId: string | null; - selectedTargetType: PondTarget["type"] | null; - batReach: number; - }; environment: { wind: number; currentA: PondPoint; @@ -157,14 +113,7 @@ export type PondWorldFrame = { fish: readonly FishWorldIntent[]; flies: readonly FlyWorldIntent[]; events: readonly PondWorldEvent[]; - debug: { - seed: number; - reason: string; - grounded: boolean; - selectedTarget: string | null; - destinationAnchor: string | null; - routine: CatRoutine; - }; + }; export const ROCK_SURFACES: readonly RockSurface[] = [ @@ -266,8 +215,6 @@ export const ROCK_ANCHORS: readonly RockAnchor[] = ROCK_SURFACES.flatMap((rock) })), ); -export const CAT_INTEREST_RADIUS = 380; - export function pondDistance(a: PondPoint, b: PondPoint) { return Math.hypot(a.x - b.x, a.y - b.y); } diff --git a/components/concepts/signal-desk/pond-renderer.ts b/components/concepts/signal-desk/pond-renderer.ts index c07ec9e..8e393cc 100644 --- a/components/concepts/signal-desk/pond-renderer.ts +++ b/components/concepts/signal-desk/pond-renderer.ts @@ -1,11 +1,18 @@ import * as THREE from "three"; +import { + POND_ASCII_DPR_CAP, + POND_ASCII_FPS_CAP, + PondAscii, + pondAsciiGrid, + pondFrameBudget, + pondKoiQuadPixels, +} from "./pond-ascii.ts"; import { installPondInput } from "./pond-input.ts"; import { ROCK_ANCHORS, createPondRandom, pondAngleDelta, - type CatWorldState, type PondPoint, } from "./pond-model.ts"; import { @@ -15,6 +22,12 @@ import { } from "./pond-simulation.ts"; import { + attachFishBend, + attachPondWaterMask, + createBentFishGeometry, + createFishIdMaterial, + createFlatIdMaterial, + createWakeIdMaterial, createYDownPondQuad, createPondSprite, createPondWater, @@ -22,6 +35,7 @@ import { PondPixels, POND_RIPPLE_CAPACITY, setAtlasFrame, + type FishBendUniforms, type PondAsset, type PondSprite, } from "./pond-three.ts"; @@ -54,18 +68,12 @@ type FishVisual = { status: THREE.Group; bonk: PondPixels; stars: PondPixels; + bend: FishBendUniforms | null; + bendHeading: number; }; type FlyVisual = { container: THREE.Group; wings: PondPixels }; type FoodVisual = { container: THREE.Group; group: THREE.Group; pellets: PondPixels[]; shadow: PondPixels }; -const CAT_FRAME_CONTACT_Y = [ - [0.784, 0.784, 0.797, 0.797], - [0.628, 0.691, 0.691, 0.691], - [0.597, 0.572, 0.597, 0.653], - [0.603, 0.628, 0.628, 0.591], -] as const; -const CAT_TURN_CONTACT_Y = 255 / 320; -const CAT_TURN_DURATION = 420; 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 }], @@ -80,6 +88,7 @@ export async function startPondRenderer(options: StartOptions) { __POND_TEST_DIAGNOSTICS__?: boolean; }; const testPose = testWindow.__POND_TEST_KOI__; + const liveAscii = !testPose; const diagnostics = testWindow.__POND_TEST_DIAGNOSTICS__ === true; if (options.signal.aborted) return () => {}; host.dataset.pondState = "loading"; @@ -99,13 +108,6 @@ export async function startPondRenderer(options: StartOptions) { foodAffordance: "false", pondElementCount: "0", insectCount: "0", - catCount: "0", - catState: "idle", - catPounceCount: "0", - catBapCount: "0", - catEmptyBapCount: "0", - catWaterViolation: "false", - catRotation: "0.000", fishReacting: "false", fishWaterViolation: "false", }); @@ -115,7 +117,6 @@ export async function startPondRenderer(options: StartOptions) { const loads = new AbortController(); let disposed = false; let animationFrame: number | null = null; - let revealFrame = 0; let disposeInput = () => {}; let destroySimulation = () => {}; let releaseRenderer = () => {}; @@ -126,7 +127,6 @@ export async function startPondRenderer(options: StartOptions) { loads.abort(); options.signal.removeEventListener("abort", dispose); if (animationFrame !== null) window.cancelAnimationFrame(animationFrame); - window.cancelAnimationFrame(revealFrame); disposeInput(); removeListeners(); destroySimulation(); @@ -148,8 +148,9 @@ export async function startPondRenderer(options: StartOptions) { try { const canvas = document.createElement("canvas"); + if (liveAscii) canvas.dataset.presented = "pending"; const context = canvas.getContext("webgl2", { - alpha: true, + alpha: !liveAscii, antialias: false, powerPreference: "high-performance", preserveDrawingBuffer: Boolean(testPose), @@ -160,14 +161,20 @@ export async function startPondRenderer(options: StartOptions) { } let renderer: THREE.WebGLRenderer; try { - renderer = new THREE.WebGLRenderer({ canvas, context, alpha: true, antialias: false }); + renderer = new THREE.WebGLRenderer({ canvas, context, alpha: !liveAscii, 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"); }; + renderer.setClearColor(liveAscii ? 0x252031 : 0x000000, liveAscii ? 1 : 0); + if (liveAscii && window.matchMedia("(prefers-color-scheme: light)").matches) renderer.setClearColor(0xeee8f3, 1); + renderer.debug.onShaderError = (gl, program, vertexShader, fragmentShader) => { + const programLog = gl.getProgramInfoLog(program) ?? ""; + const vertexLog = gl.getShaderInfoLog(vertexShader) ?? ""; + const fragmentLog = gl.getShaderInfoLog(fragmentShader) ?? ""; + throw new Error(`Unable to compile the pond shader\n${programLog}\n${vertexLog}\n${fragmentLog}`); + }; const stage = new THREE.Scene(); const camera = new THREE.OrthographicCamera(0, window.innerWidth, 0, window.innerHeight, 0.1, 100); camera.position.z = 10; @@ -192,23 +199,37 @@ export async function startPondRenderer(options: StartOptions) { own(asset.texture); return asset; }; - const [pondTexture, waterTexture, koiAtlas, foregroundTexture, pondWaterTexture, catAtlas, catTurnAtlas] = await Promise.all([ + const koiAtlas = await load("pixel-koi-atlas.webp"); + const colorAssets = 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"), - load("pixel-tabby-atlas.webp"), - load("pixel-tabby-turn-atlas.webp"), ]); loads.signal.throwIfAborted(); + const blankAsset = (width: number, height: number): PondAsset => { + const surface = document.createElement("canvas"); + surface.width = 1; + surface.height = 1; + const texture = new THREE.Texture(surface); + texture.needsUpdate = true; + own(texture); + return { texture, image: surface as unknown as HTMLImageElement, width, height }; + }; + const pondTexture = colorAssets ? colorAssets[0] : blankAsset(1586, 992); + const waterTexture = colorAssets ? colorAssets[1] : blankAsset(1024, 1024); + const foregroundTexture = colorAssets ? colorAssets[2] : blankAsset(1586, 992); + const pondWaterTexture = colorAssets ? colorAssets[3] : blankAsset(1586, 992); 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; + let hitPixels = new Uint8ClampedArray(0); + if (colorAssets) { + 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); + hitPixels = hitContext.getImageData(0, 0, hitCanvas.width, hitCanvas.height).data; + } host.replaceChildren(canvas); canvas.className = options.canvasClassName; @@ -244,6 +265,12 @@ export async function startPondRenderer(options: StartOptions) { waterTexture.texture.minFilter = THREE.LinearFilter; waterTexture.texture.magFilter = THREE.LinearFilter; const water = createPondWater(quad, pondTexture.texture, pondWaterTexture.texture, waterTexture.texture); + const fishWaterMask = { + texture: { value: pondWaterTexture.texture }, + origin: water.uniforms.imageOrigin, + size: water.uniforms.imageSize, + enabled: { value: 0 }, + }; own(water.mesh.material); const fishLayer = layer(); stage.add(background, water.mesh, foreground); @@ -258,14 +285,58 @@ export async function startPondRenderer(options: StartOptions) { x: worldCenterX() + (x - window.innerWidth / 2) / viewScale, y: pondTexture.height / 2 + (y - window.innerHeight / 2) / viewScale, }); - const isWaterWorld = (x: number, y: number) => { + const pondFrame = { + cell: 12, + cx: window.innerWidth * 0.72, + cy: window.innerHeight * 0.42, + rx: 23, + ry: 18, + card: new THREE.Vector4(-1, -1, -1, -1), + }; + const ellipseUniform = new THREE.Vector4(); + const measureFrame = () => { + const width = window.innerWidth; + const { cell } = pondAsciiGrid(width, window.innerHeight); + const cx = width / 2; + const cy = window.innerHeight / 2; + const rx = width / cell; + const ry = window.innerHeight / cell; + pondFrame.cell = cell; + pondFrame.cx = cx; + pondFrame.cy = cy; + pondFrame.rx = rx; + pondFrame.ry = ry; + measureCard(); + }; + const measureCard = () => { + const cardEl = document.querySelector("[data-hero-card]"); + const cell = pondFrame.cell; + if (cardEl instanceof HTMLElement) { + const rect = cardEl.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + pondFrame.card.set(rect.left - cell, rect.top - cell, rect.right + cell, rect.bottom + cell); + if (diagnostics) { + host.dataset.pondCard = `${pondFrame.card.x.toFixed(1)},${pondFrame.card.y.toFixed(1)},${pondFrame.card.z.toFixed(1)},${pondFrame.card.w.toFixed(1)}`; + } + return; + } + } + pondFrame.card.set(-1, -1, -1, -1); + if (diagnostics) host.dataset.pondCard = "none"; + }; + const isPixelWater = (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 isWaterWorld = (x: number, y: number) => { + if (!liveAscii) return isPixelWater(x, y); + return x >= 0 && y >= 0 && x <= pondTexture.width && y <= pondTexture.height; + }; const isWaterScreen = (x: number, y: number) => { const world = screenToWorld(x, y); + if (pond.dataset.view === "pixel") return isPixelWater(world.x, world.y); return isWaterWorld(world.x, world.y); }; const nearestWater = (position: PondPoint) => { @@ -298,40 +369,74 @@ export async function startPondRenderer(options: StartOptions) { 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)))); + const pixelRatio = liveAscii + ? Math.min(window.devicePixelRatio || 1, POND_ASCII_DPR_CAP) + : Math.min(window.devicePixelRatio || 1, Math.max(0.65, Math.min(1, 1040 / window.innerWidth))); + renderer.setPixelRatio(pixelRatio); renderer.setSize(window.innerWidth, window.innerHeight, false); + measureFrame(); + ascii?.resize(window.innerWidth, window.innerHeight, pixelRatio); + if (diagnostics && ascii) { + host.dataset.glyphInk = `${ascii.ink.cssWidth.toFixed(2)},${ascii.ink.cssHeight.toFixed(2)}`; + } }; + let ascii: PondAscii | null = null; layout(); + if (liveAscii) { + await Promise.race([ + document.fonts.load(`700 ${pondFrame.cell}px "Space Mono"`), + new Promise((resolve) => { window.setTimeout(resolve, 1200); }), + ]); + if (disposed) throw new DOMException("Pond initialization cancelled", "AbortError"); + ascii = own(new PondAscii(renderer)); + 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, + const fishCells = new Map(); + const bodyScales = new Map(); + const tanchoIds = new Set(); + const fishDefinitions: FishDefinition[] = testPose + ? [{ + id: "fish-0", + row: testPose.row, + displayWidth: 160 * (window.innerWidth < 720 ? 0.8 : 1), position: nearestWater({ - x: worldCenterX() + (x - 0.5) * (window.innerWidth / viewScale) * 0.88, - y: pondTexture.height / 2 + (y - 0.5) * (window.innerHeight / viewScale) * 0.88, + x: worldCenterX() + (0.74 - 0.5) * (window.innerWidth / viewScale) * 0.88, + y: pondTexture.height / 2 + (0.45 - 0.5) * (window.innerHeight / viewScale) * 0.88, }), - heading, - cruise: Math.round(cruise * 1.8 + 5), - alpha, - species: ["kohaku", "ogon", "showa", "utsuri"][row], - })); + heading: testPose.heading, + cruise: Math.round(0 * 1.8 + 5), + alpha: 1, + species: ["kohaku", "ogon", "showa", "utsuri"][testPose.row] ?? "showa", + }] + : Array.from({ length: window.innerWidth < 640 ? 8 : 18 }, (_, index) => { + const entry = { + cells: (index < 7 ? (window.innerWidth < 640 ? 15 : 19) : (window.innerWidth < 640 ? 8 : 10)) + index % 3, + cruise: 24 + index % 5 * 3, + heading: index * 2.4, + nx: ((index * 7) % 20 + 0.5) / 20, + ny: ((index * 11) % 20 + 0.5) / 20, + }; + const id = `fish-${index}`; + fishCells.set(id, entry.cells); + const bodyScale = 1.12; + bodyScales.set(id, bodyScale); + const visualLength = entry.cells * pondFrame.cell / viewScale; + return { + id, + row: 0, + displayWidth: visualLength * bodyScale, + visualLength, + position: nearestWater({ + x: pondTexture.width * (window.innerWidth < 640 ? 0.4 + entry.nx * 0.4 : 0.08 + entry.nx * 0.84), + y: pondTexture.height * (0.08 + entry.ny * 0.84), + }), + heading: entry.heading, + cruise: entry.cruise, + alpha: 0.94, + species: "kohaku", + }; + }); if (testPose) { const point = worldToScreen(fishDefinitions[0].position); if (diagnostics) host.dataset.koiTest = JSON.stringify({ @@ -344,36 +449,87 @@ export async function startPondRenderer(options: StartOptions) { } const pondSeed = new URLSearchParams(window.location.search).get("pond-seed")?.slice(0, 128).trim() || "6c75"; if (diagnostics) host.dataset.pondSeed = pondSeed; - const simulation = createPondSimulation({ + const simulationOptions = { seed: pondSeed, width: pondTexture.width, height: pondTexture.height, fish: fishDefinitions, + headSeparation: 0, + avoidHeadOn: liveAscii, + shoreClearance: liveAscii ? 12 : undefined, + resizeAdmission: liveAscii, + recoverOutside: liveAscii, + waterBounds: liveAscii ? () => ({ minX: 0, maxX: pondTexture.width, minY: 0, maxY: pondTexture.height }) : undefined, 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, - }); + }; + let simulation: ReturnType; + while (true) { + try { + simulation = createPondSimulation(simulationOptions); + break; + } catch (error) { + if (!(error instanceof Error) || error.message !== "Pond cannot fit non-overlapping fish in water" || fishDefinitions.length <= 1) throw error; + fishDefinitions.pop(); + } + } destroySimulation = () => simulation.destroy(); const visualRandom = createPondRandom(`${pondSeed}:render`); const sourceCellHeight = koiAtlas.height / 4; + const fishGeometry = liveAscii ? own(createBentFishGeometry()) : quad; + type MaterialSwap = { + mesh: THREE.Mesh; + colorMaterial: THREE.Material; + idMaterial: THREE.Material; + sync?: () => void; + }; + const swaps: MaterialSwap[] = []; + const bindSwap = (mesh: THREE.Mesh, idMaterial: THREE.Material, sync?: () => void) => { + swaps.push({ mesh, colorMaterial: mesh.material as THREE.Material, idMaterial, sync }); + }; + const foodIdMaterial = own(createFlatIdMaterial(12)); + const flyIdMaterial = own(createFlatIdMaterial(14)); + const bonkIdMaterial = own(createFlatIdMaterial(15)); const fishVisuals = new Map(); for (const fish of fishDefinitions) { - const fishSprite = sprite(koiAtlas, 2, true); + const fishSprite = liveAscii + ? createPondSprite(fishGeometry, own(koiAtlas.texture.clone()), 2) + : sprite(koiAtlas, 2, true); + if (liveAscii) own(fishSprite.material); + const bend: FishBendUniforms | null = liveAscii ? { + time: { value: 0 }, + curve: { value: 0 }, + amount: { value: 1 }, + } : null; + if (bend) attachFishBend(fishSprite.material, bend); + if (liveAscii) attachPondWaterMask(fishSprite.material, fishWaterMask); + if (bend && fishSprite.material.map) { + const identified = createFishIdMaterial(fishSprite.material.map, bend, tanchoIds.has(fish.id)); + own(identified.material); + bindSwap(fishSprite, identified.material, identified.sync); + } 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, + texture: fishSprite.material.map!, + frames: testPose ? [testPose.column] : liveAscii ? [1] : [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, + speed: liveAscii ? 0 : 0.038 + fish.row * 0.003, + progress: liveAscii ? 0 : fish.row % 6, + frame: 0, }; animate(animation, 0); const shadow = pixels(2); + if (liveAscii) attachPondWaterMask(shadow.material, fishWaterMask); 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.rotation.z = fish.heading + Math.PI / 2; container.add(shadow, fishSprite); fishLayer.add(container); const bonk = pixels(9, 180); @@ -397,12 +553,19 @@ export async function startPondRenderer(options: StartOptions) { const status = new THREE.Group(); status.add(bonk, stars); status.visible = false; + if (liveAscii) status.scale.setScalar(Math.max(1, pondFrame.cell / 4)); stage.add(status); fishVisuals.set(fish.id, { container, sprite: fishSprite, shadow, animation, alpha: fish.alpha, - baseScaleX: fish.displayWidth, + baseScaleX: (fishCells.get(fish.id) ?? fish.displayWidth) * (fishCells.has(fish.id) ? pondFrame.cell / viewScale : 1), lastWake: 0, surfaced: false, status, bonk, stars, + bend, bendHeading: fish.heading, }); + if (liveAscii) { + shadow.visible = false; + bindSwap(bonk, bonkIdMaterial); + bindSwap(stars, bonkIdMaterial); + } } const insectLayer = layer(); @@ -431,6 +594,10 @@ export async function startPondRenderer(options: StartOptions) { container.add(wings, body); insectLayer.add(container); flyVisuals.set(fly.id, { container, wings }); + if (liveAscii) { + bindSwap(wings, flyIdMaterial); + bindSwap(body, flyIdMaterial); + } } const midgeLayer = pixels(6); insectLayer.add(midgeLayer); @@ -469,8 +636,11 @@ export async function startPondRenderer(options: StartOptions) { if (diagnostics) host.dataset.ringCount = String(rings.length); }; const addRipple = (x: number, y: number, size = 132, strength = 1) => { + if (liveAscii && reduced) return; const speed = 145 + strength * 45; - ripples.push({ x, y, size, strength, speed, born: performance.now(), life: size / speed * 1000 + 180 }); + const rippleSize = liveAscii ? 14 * pondFrame.cell : size; + const life = liveAscii ? 1200 : size / speed * 1000 + 180; + ripples.push({ x, y, size: rippleSize, strength, speed, born: performance.now(), life }); if (ripples.length > POND_RIPPLE_CAPACITY) ripples.shift(); if (diagnostics) host.dataset.rippleCount = String(ripples.length); }; @@ -482,6 +652,7 @@ export async function startPondRenderer(options: StartOptions) { shadow.ellipse(0, 3, 7, 3, 0x062d2c, 0.24); shadow.material.blending = THREE.MultiplyBlending; shadow.material.premultipliedAlpha = true; + if (liveAscii) shadow.visible = false; const group = new THREE.Group(); const pellets = Array.from({ length: 9 }, (_, index) => { const pellet = pixels(3.4, 1); @@ -491,6 +662,7 @@ export async function startPondRenderer(options: StartOptions) { 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); + if (liveAscii) bindSwap(pellet, foodIdMaterial); return pellet; }); const container = new THREE.Group(); @@ -509,44 +681,22 @@ export async function startPondRenderer(options: StartOptions) { stage.add(feedAffordance); let affordanceTarget = 0; - const catLayer = layer(); - const catSize = window.innerWidth < 720 ? 80 : 118; - const catSprite = sprite(catAtlas, 5.01, true); - catSprite.scale.set(catSize * 1.25, catSize * 1.25, 1); - const catAnimation: AtlasAnimation = { - texture: catSprite.material.map!, frames: [0, 1, 2, 3], row: 0, speed: 0.035, progress: 0, frame: 0, - }; - animate(catAnimation, 0); - const catBaseScale = { x: catSprite.scale.x, y: catSprite.scale.y }; - const catTurnSprite = sprite(catTurnAtlas, 5.02, true); - catTurnSprite.scale.copy(catSprite.scale); - catTurnSprite.visible = false; - const catShadow = pixels(5); - catShadow.ellipse(0, 2, catSize * 0.28, catSize * 0.1, 0x071c1a, 0.28); - catShadow.material.blending = THREE.MultiplyBlending; - catShadow.material.premultipliedAlpha = true; - const catContainer = new THREE.Group(); - catContainer.add(catSprite, catTurnSprite); - catLayer.add(catShadow, catContainer); - let catAnimationRow = 0; - let renderedFacing: 1 | -1 = 1; - let turnFrom: 1 | -1 = 1; - let turnTo: 1 | -1 = 1; - let turnStarted = 0; - let turning = false; - let catScreen = { x: -1000, y: -1000 }; - const animationRowFor = (state: CatWorldState) => { - if (state === "airborne") return 2; - if (state === "prepare-bat" || state === "bat") return 3; - if (["approach", "anticipate-hop", "react", "recover"].includes(state)) return 1; - return 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 lastPointerWake = 0; + let glyphWakes: { x: number; y: number; born: number; life: number }[] = []; + const addGlyphWake = (x: number, y: number, life: number) => { + if (!liveAscii || reduced) return; + glyphWakes.push({ x, y, born: performance.now(), life }); + if (glyphWakes.length > 120) glyphWakes = glyphWakes.slice(-120); + }; + const wakeLayer = pixels(1.2, 760); + wakeLayer.material = own(createWakeIdMaterial()) as unknown as THREE.MeshBasicMaterial; + wakeLayer.visible = false; + stage.add(wakeLayer); let primaryImpactCount = 0; let lastFoodRequested: PondPoint | null = null; let lastFoodDropped: PondPoint | null = null; @@ -576,6 +726,10 @@ export async function startPondRenderer(options: StartOptions) { addRing(screen.x, screen.y); lastTrailRing = now; } + if (liveAscii && now - lastPointerWake > 40) { + addGlyphWake(screen.x, screen.y, 1000); + lastPointerWake = now; + } } pointer = { screen, world, velocity, time: energy > 0 ? now : 0, energy }; }, @@ -589,7 +743,6 @@ export async function startPondRenderer(options: StartOptions) { 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); } - if (Math.hypot(screen.x - catScreen.x, screen.y - catScreen.y) < 280) simulation.requestHop(); }, onFoodDrop: (_screen, world) => { if (diagnostics) lastFoodRequested = { ...world }; @@ -610,74 +763,12 @@ export async function startPondRenderer(options: StartOptions) { let foodDroppedCount = 0; let foodExpiredCount = 0; let fishFedCount = 0; - let catPounceCount = 0; - let catBapCount = 0; - let catEmptyBapCount = 0; let revealed = false; const current = { ax: 0, ay: 0, bx: 0, by: 0 }; - - const renderCat = (next: PondSimulationFrame, now: number, delta: number) => { - const row = animationRowFor(next.cat.state); - if (row !== catAnimationRow) { - catAnimationRow = row; - catAnimation.row = row; - catAnimation.speed = row === 2 || row === 3 ? 0.12 : row === 1 ? 0.07 : 0.035; - catAnimation.progress = 0; - } - animate(catAnimation, delta); - const contactY = CAT_FRAME_CONTACT_Y[row][catAnimation.frame] ?? CAT_FRAME_CONTACT_Y[row][0]; - const contact = worldToScreen(next.cat.contact); - const aim = worldToScreen(next.cat.aim); - const lift = next.cat.lift * viewScale * (reduced ? 0.25 : 1); - const desiredFacing = Math.abs(aim.x - contact.x) < 16 ? renderedFacing : next.cat.facing; - if (!turning && desiredFacing !== renderedFacing) { - turnFrom = renderedFacing; - turnTo = desiredFacing; - turnStarted = now; - turning = true; - } - if (turning) { - const progress = Math.min(1, (now - turnStarted) / CAT_TURN_DURATION); - const turnFrame = Math.min(4, Math.round(progress * 4)); - setAtlasFrame(catTurnSprite.material.map!, turnFrom === 1 ? turnFrame : 4 - turnFrame, 0, 5, 1); - catTurnSprite.visible = turnFrame > 0 && turnFrame < 4; - catSprite.visible = !catTurnSprite.visible; - if (progress >= 1) { - renderedFacing = turnTo; - turning = false; - catTurnSprite.visible = false; - catSprite.visible = true; - } - } - const aimLean = ["prepare-bat", "bat"].includes(next.cat.state) - ? Math.max(-0.055, Math.min(0.055, Math.atan2(aim.y - contact.y, Math.abs(aim.x - contact.x)) * 0.16)) - : 0; - catScreen = contact; - catContainer.position.set(Math.round(contact.x), Math.round(contact.y - lift), 0); - catContainer.rotation.z += pondAngleDelta(catContainer.rotation.z, next.cat.surfaceAngle + aimLean) * Math.min(1, delta * 12); - const breathing = next.cat.grounded && next.cat.routine === "resting" - ? Math.sin(simulationNow * 0.0018) * 0.009 * motionScale - : 0; - catSprite.scale.set(catBaseScale.x * renderedFacing * (next.cat.squashX - breathing * 0.4), catBaseScale.y * (next.cat.squashY + breathing), 1); - catSprite.position.y = (0.5 - contactY) * catSprite.scale.y; - catTurnSprite.scale.set(catBaseScale.x * next.cat.squashX, catBaseScale.y * next.cat.squashY, 1); - catTurnSprite.position.y = (0.5 - CAT_TURN_CONTACT_Y) * catTurnSprite.scale.y; - catShadow.position.set(Math.round(contact.x), Math.round(contact.y), 0); - catShadow.rotation.z = next.cat.surfaceAngle; - catShadow.scale.set(1 - Math.min(0.22, lift / Math.max(1, catSize) * 0.34), 1 - Math.min(0.1, lift / Math.max(1, catSize) * 0.12), 1); - catShadow.material.opacity = 0.95 - Math.min(0.58, lift / Math.max(1, catSize)); - }; - 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); - const visibleAnchorIds = ROCK_ANCHORS.filter((anchor) => { - const point = worldToScreen(anchor.position); - const horizontal = catSize * 0.625 + 4; - return point.x > horizontal && point.x < window.innerWidth - horizontal && - point.y > catSize * 1.075 + 4 && point.y < window.innerHeight - catSize * 0.175 - 4; - }).map((anchor) => anchor.id); frame = simulation.step({ now: simulationNow, delta: simulationDelta, @@ -687,7 +778,6 @@ export async function startPondRenderer(options: StartOptions) { influence: pointerInfluence, energy: pointer.energy, }, - visibleAnchorIds, }); const { environment } = frame; @@ -709,7 +799,6 @@ export async function startPondRenderer(options: StartOptions) { 140 + waterEnergy * 80, waterEnergy > 0.005 ? -0.105 * waterEnergy * motionScale : 0); for (const event of frame.events) { - if (event.type === "takeoff") catPounceCount += 1; if (event.type === "ambient-ripple") { const point = worldToScreen(event.position); if (isWaterScreen(point.x, point.y)) { @@ -729,18 +818,6 @@ export async function startPondRenderer(options: StartOptions) { addParticle(point.x, point.y, 0, -7, 340, 2, 0xffe9a8); } else if (event.type === "food-expired") { foodExpiredCount += 1; - } else if (event.type === "land") { - const point = worldToScreen(event.position); - addRing(point.x, point.y + 2, 12 + event.impact * 12, 360, 0x9ab276); - } else if (event.type === "bat") { - if (event.distance > event.reach) catEmptyBapCount += 1; - else { - catBapCount += 1; - const point = worldToScreen(event.aim); - if (event.targetType === "fish" && isWaterScreen(point.x, point.y)) { - addRing(point.x, point.y, event.hit ? 38 : 27, 620, 0xf7dfa0); - } - } } } @@ -752,7 +829,8 @@ export async function startPondRenderer(options: StartOptions) { 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)); + const foodScale = (0.76 + food.pelletSize / 13) * (food.state === "depleted" ? 0.72 : 1); + visual.group.scale.setScalar(liveAscii ? foodScale * Math.max(1, pondFrame.cell / 2.4) : foodScale); 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); }); } @@ -760,6 +838,8 @@ export async function startPondRenderer(options: StartOptions) { if (!liveFood.has(id)) { visual.container.removeFromParent(); for (const mesh of [visual.shadow, ...visual.pellets]) { + const index = swaps.findIndex((swap) => swap.mesh === mesh); + if (index >= 0) swaps.splice(index, 1); mesh.geometry.dispose(); mesh.material.dispose(); resources.delete(mesh.geometry); @@ -771,7 +851,6 @@ export async function startPondRenderer(options: StartOptions) { feedAffordance.material.opacity += (affordanceTarget - feedAffordance.material.opacity) * Math.min(1, delta * 8); feedAffordance.rotation.z += environment.wind * delta * 0.025; - renderCat(frame, now, delta); for (const element of pondElements) { const base = worldToScreen(element.position); @@ -800,8 +879,16 @@ export async function startPondRenderer(options: StartOptions) { 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); + if (liveAscii) { + const angle = fly.id === "fly-0" ? -2.35 : 0.55; + const band = 1.15 + Math.sin(simulationNow * 0.001 + (fly.id === "fly-0" ? 0.4 : 2.1)) * 0.35; + visual.container.position.set( + pondFrame.cx + Math.cos(angle) * (pondFrame.rx + band) * pondFrame.cell, + pondFrame.cy + Math.sin(angle) * (pondFrame.ry + band) * pondFrame.cell, + 0, + ); + } else visual.container.position.set(point.x, point.y, 0); + visual.container.scale.setScalar((1 + fly.lift * 0.008) * (liveAscii ? 1.7 : 1)); 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); @@ -816,8 +903,16 @@ export async function startPondRenderer(options: StartOptions) { 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 targetHeading = (testPose?.heading ?? fish.heading) + Math.PI / 2; + visual.container.rotation.z += pondAngleDelta(visual.container.rotation.z, targetHeading) * Math.min(1, delta * 6); const effort = Math.min(2.5, fish.speedScale); + if (visual.bend) { + const turn = pondAngleDelta(visual.bendHeading, fish.heading); + visual.bendHeading = fish.heading; + const curve = Math.max(-0.22, Math.min(0.22, turn * 1.5)); + visual.bend.curve.value += (curve - visual.bend.curve.value) * Math.min(1, delta * 8); + visual.bend.time.value += delta * motionScale * (2.5 + effort * 2); + } 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); @@ -834,11 +929,18 @@ export async function startPondRenderer(options: StartOptions) { 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); + if (liveAscii && !testPose) { + const span = pondKoiQuadPixels(fishCells.get(fish.id) ?? 14, pondFrame.cell); + visual.baseScaleX = span.scaleY / viewScale; + visual.sprite.position.y = 0; + visual.sprite.scale.set(span.scaleX * peck, span.scaleY * (2 - peck), 1); + } else { + 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; + const headOffset = visual.baseScaleX * 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); @@ -863,7 +965,18 @@ export async function startPondRenderer(options: StartOptions) { 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)) { + if (liveAscii && !reduced && speed > 6 && now - visual.lastWake > Math.max(90, 90 * 28 / Math.max(speed, 6))) { + const tail = worldToScreen({ + x: fish.position.x - Math.cos(fish.heading) * visual.baseScaleX * 0.42, + y: fish.position.y - Math.sin(fish.heading) * visual.baseScaleX * 0.42, + }); + const lateral = (visualRandom() - 0.5) * 3 * pondFrame.cell; + const wakeX = tail.x + Math.cos(fish.heading + Math.PI / 2) * lateral; + const wakeY = tail.y + Math.sin(fish.heading + Math.PI / 2) * lateral; + if (isWaterScreen(wakeX, wakeY)) addGlyphWake(wakeX, wakeY, 1200); + visual.lastWake = now; + } + if (!liveAscii && !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, @@ -909,6 +1022,15 @@ export async function startPondRenderer(options: StartOptions) { return true; }); ripples = ripples.filter((ripple) => now - ripple.born < ripple.life); + wakeLayer.clear(); + glyphWakes = glyphWakes.filter((wake) => { + const age = now - wake.born; + if (age >= wake.life) return false; + const life = 1 - age / wake.life; + const size = pondFrame.cell * 0.9; + wakeLayer.rectangle(wake.x - size / 2, wake.y - size / 2, size, size, 0xffffff, life); + return true; + }); for (let index = 0; index < POND_RIPPLE_CAPACITY; index += 1) { const ripple = ripples[index]; if (ripple) { @@ -921,12 +1043,14 @@ export async function startPondRenderer(options: StartOptions) { } frameCount += 1; - if (diagnostics && frameCount % 12 === 0) { + if (diagnostics && (frameCount === 1 || 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.fishIds = frame.fish.map((fish) => fish.id).join(";"); + host.dataset.fishWidths = frame.fish.map((fish) => fish.displayWidth.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 @@ -938,7 +1062,7 @@ export async function startPondRenderer(options: StartOptions) { 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.visibleAnchorIds = visibleAnchorIds.join(","); + host.dataset.padAnchorIds = ROCK_ANCHORS.map((anchor) => anchor.id).join(","); host.dataset.rippleCount = String(ripples.length); host.dataset.wakeCount = String(particles.length); host.dataset.ringCount = String(rings.length); @@ -949,31 +1073,6 @@ export async function startPondRenderer(options: StartOptions) { 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.catState = frame.cat.state; - host.dataset.catRoutine = frame.cat.routine; - host.dataset.catPosition = `${catScreen.x.toFixed(1)},${catScreen.y.toFixed(1)}`; - host.dataset.catAimScreen = `${worldToScreen(frame.cat.aim).x.toFixed(1)},${worldToScreen(frame.cat.aim).y.toFixed(1)}`; - host.dataset.catFacing = String(frame.cat.facing); - host.dataset.catPounceCount = String(catPounceCount); - host.dataset.catBapCount = String(catBapCount); - host.dataset.catEmptyBapCount = String(catEmptyBapCount); - const catAnchorId = frame.cat.anchorId; - const groundedAnchor = ROCK_ANCHORS.find((anchor) => anchor.id === catAnchorId); - const missedAuthoredSurface = frame.cat.grounded && (!groundedAnchor || Math.hypot( - frame.cat.contact.x - groundedAnchor.position.x, - frame.cat.contact.y - groundedAnchor.position.y, - ) > 0.01); - host.dataset.catWaterViolation = String(missedAuthoredSurface); - host.dataset.catOverWater = String(missedAuthoredSurface); - host.dataset.catRotation = catContainer.rotation.z.toFixed(3); - host.dataset.catAnchor = frame.cat.anchorId; - host.dataset.catRock = frame.cat.rockId; - host.dataset.catDestination = frame.cat.destinationAnchorId ?? "none"; - host.dataset.catTarget = frame.cat.selectedTargetId ?? "none"; - host.dataset.catTargetType = frame.cat.selectedTargetType ?? "none"; - host.dataset.catGrounded = String(frame.cat.grounded); - host.dataset.catReason = frame.debug.reason; - host.dataset.catAim = `${frame.cat.aim.x.toFixed(1)},${frame.cat.aim.y.toFixed(1)}`; 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)}`; @@ -989,53 +1088,236 @@ export async function startPondRenderer(options: StartOptions) { for (const child of stage.children) child.visible = child === fishLayer; for (const visual of fishVisuals.values()) visual.shadow.visible = false; } + if (liveAscii) { + background.visible = false; + water.mesh.visible = false; + foreground.visible = false; + ambientLayer.visible = false; + particleLayer.visible = false; + midgeLayer.visible = false; + feedAffordance.visible = false; + } + + const idClear = new THREE.Color(); + const present = (view = pond.dataset.view) => { + if (liveAscii && view === "pixel") { + fishWaterMask.enabled.value = 1; + background.visible = true; + water.mesh.visible = true; + foreground.visible = true; + ambientLayer.visible = true; + particleLayer.visible = true; + midgeLayer.visible = true; + feedAffordance.visible = true; + try { + renderer.render(stage, camera); + } finally { + fishWaterMask.enabled.value = 0; + } + background.visible = false; + water.mesh.visible = false; + foreground.visible = false; + ambientLayer.visible = false; + particleLayer.visible = false; + midgeLayer.visible = false; + feedAffordance.visible = false; + return; + } + if (!liveAscii || !ascii) { + renderer.render(stage, camera); + return; + } + const previousTarget = renderer.getRenderTarget(); + renderer.getClearColor(idClear); + const clearAlpha = renderer.getClearAlpha(); + renderer.setClearColor(0x000000, 0); + wakeLayer.visible = true; + for (const swap of swaps) { + swap.sync?.(); + swap.mesh.material = swap.idMaterial; + } + try { + renderer.setRenderTarget(ascii.materialBuffer); + renderer.clear(); + renderer.render(stage, camera); + } finally { + for (const swap of swaps) swap.mesh.material = swap.colorMaterial; + wakeLayer.visible = false; + renderer.setRenderTarget(previousTarget); + renderer.setClearColor(idClear, clearAlpha); + } + const stamp = performance.now(); + ellipseUniform.set(pondFrame.cx / pondFrame.cell, pondFrame.cy / pondFrame.cell, pondFrame.rx, pondFrame.ry); + ascii.setFrame(ellipseUniform, pondFrame.card, ripples.slice(0, POND_RIPPLE_CAPACITY).map((ripple) => { + const elapsed = Math.min(1, (stamp - ripple.born) / Math.max(1, ripple.life)); + const eased = 1 - (1 - elapsed) * (1 - elapsed); + return { + x: ripple.x, + y: ripple.y, + radius: eased * 14 * pondFrame.cell, + strength: (1 - elapsed) * ripple.strength, + }; + }), ROCK_ANCHORS.map((anchor) => { + const point = worldToScreen(anchor.position); + return new THREE.Vector2(point.x, point.y); + }), (frame?.fish ?? []).map((fish) => { + const point = worldToScreen(fish.position); + return new THREE.Vector2(point.x, point.y); + }), simulationNow, reduced); + ascii.compose(renderer); + }; let previousTime: number | null = null; + let frameBudget = 0; + let heroOnScreen = true; 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); + if (disposed || document.hidden || (liveAscii && !heroOnScreen)) { + if (!disposed) host.dataset.pondState = "paused"; + return; + } + const deltaMs = previousTime === null ? 1000 / POND_ASCII_FPS_CAP : Math.min(Math.max(now - previousTime, 0), 80); previousTime = now; + const frameInterval = 1000 / POND_ASCII_FPS_CAP; + let stepMs = deltaMs; + if (liveAscii && !reduced) { + const paced = pondFrameBudget(frameBudget, deltaMs, frameInterval); + frameBudget = paced.budget; + if (paced.stepMs === 0) { + animationFrame = window.requestAnimationFrame(draw); + return; + } + stepMs = paced.stepMs; + } + const delta = Math.min(0.08, Math.max(1 / 120, stepMs / 1000)); try { step(now, delta); - renderer.render(stage, camera); + present(); } 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"; - }); + if (liveAscii) { + host.style.transition = "none"; + canvas.dataset.presented = "ascii"; + pond.dataset.asciiFrame = "1"; + } + pond.dataset.renderer = "three"; } animationFrame = window.requestAnimationFrame(draw); }; const visibility = () => { if (disposed) return; previousTime = null; - if (document.hidden) { + frameBudget = 0; + if (document.hidden || (liveAscii && !heroOnScreen)) { if (animationFrame !== null) window.cancelAnimationFrame(animationFrame); animationFrame = null; host.dataset.pondState = "paused"; } else { - if (animationFrame === null) animationFrame = window.requestAnimationFrame(draw); host.dataset.pondState = "running"; + if (animationFrame === null) animationFrame = window.requestAnimationFrame(draw); + } + }; + const onResize = () => { + layout(); + if (!liveAscii) return; + const updates: { id: string; displayWidth: number; visualLength: number; visible: boolean }[] = []; + for (const fish of fishDefinitions) { + const visual = fishVisuals.get(fish.id); + const index = Number(fish.id.slice(5)); + const cells = testPose ? fishCells.get(fish.id) : (index < 7 ? (window.innerWidth < 640 ? 15 : 19) : (window.innerWidth < 640 ? 8 : 10)) + index % 3; + const bodyScale = bodyScales.get(fish.id); + if (!visual || !cells || bodyScale === undefined) continue; + fishCells.set(fish.id, cells); + const visualLength = cells * pondFrame.cell / viewScale; + visual.baseScaleX = visualLength; + fish.displayWidth = visualLength * bodyScale; + fish.visualLength = visualLength; + const position = worldToScreen(simulation.fishPosition(fish.id) ?? fish.position); + updates.push({ id: fish.id, displayWidth: fish.displayWidth, visualLength, visible: position.x >= 0 && position.x <= window.innerWidth && position.y >= 0 && position.y <= window.innerHeight }); + } + const beforeResize = diagnostics ? updates.map(({ id }) => simulation.fishPosition(id)) : []; + const admitted = new Set(simulation.resizeFish(updates)); + if (diagnostics) { + host.dataset.resizeFishDisplacement = String(Math.max(0, ...updates.map(({ id }, index) => { + const before = beforeResize[index]; + const after = simulation.fishPosition(id); + return before && after ? Math.hypot(after.x - before.x, after.y - before.y) : 0; + }))); + host.dataset.resizeViewport = `${window.innerWidth}x${window.innerHeight}`; + } + for (const [id, visual] of fishVisuals) { + visual.container.visible = admitted.has(id); + if (!admitted.has(id)) visual.status.visible = false; + } + if (diagnostics) host.dataset.fishCount = String(admitted.size); + if (reduced && frameCount > 0) { + step(performance.now(), 1 / 60); + present(); + } + }; + const onScroll = () => { + if (!liveAscii || disposed) return; + measureCard(); + if (reduced && frameCount > 0) present(); + }; + const heroCard = liveAscii ? document.querySelector("[data-hero-card]") : null; + const heroObserver = heroCard ? new IntersectionObserver((entries) => { + heroOnScreen = entries.some((entry) => entry.isIntersecting); + visibility(); + }) : null; + if (heroCard && heroObserver) heroObserver.observe(heroCard); + const themeMedia = window.matchMedia("(prefers-color-scheme: light)"); + const onTheme = () => { + ascii?.setTheme(!themeMedia.matches); + 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(); }; - window.addEventListener("resize", layout); + host.addEventListener("pond-view-change", onViewChange); + if (liveAscii) themeMedia.addEventListener("change", onTheme); + window.addEventListener("resize", onResize); + window.addEventListener("scroll", onScroll, { capture: true, passive: true }); document.addEventListener("visibilitychange", visibility); removeListeners = () => { - window.removeEventListener("resize", layout); + 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); }; if (diagnostics) { host.dataset.fishCount = String(fishDefinitions.length); host.dataset.pondElementCount = String(pondElements.length); host.dataset.insectCount = String(midges.length + flyVisuals.size); - host.dataset.catCount = "1"; host.dataset.fishLogic = "authoritative-seeded-world-steering"; - host.dataset.worldModel = "seeded-routine-rock-target-food-environment"; + host.dataset.worldModel = "seeded-fish-food-environment"; } visibility(); return dispose; diff --git a/components/concepts/signal-desk/pond-simulation.ts b/components/concepts/signal-desk/pond-simulation.ts index ab14ac4..92b679b 100644 --- a/components/concepts/signal-desk/pond-simulation.ts +++ b/components/concepts/signal-desk/pond-simulation.ts @@ -29,6 +29,7 @@ export type FishDefinition = { cruise: number; alpha: number; species: string; + visualLength?: number; }; export type FlyDefinition = { @@ -85,7 +86,6 @@ export type PondSimulationFrame = Omit & { flies: readonly FlySimulationFrame[]; }; -type StartleMemory = { until: number; origin: PondPoint }; type FishMotion = { activity: FishActivity; @@ -109,8 +109,6 @@ type FishMotion = { type FishAgent = FishDefinition & { vehicle: YUKA.Vehicle; pointerFlee: YUKA.FleeBehavior; - catFlee: YUKA.FleeBehavior; - catOrigin: YUKA.Vector3; homeSeek: YUKA.SeekBehavior; goalArrive: YUKA.ArriveBehavior; goalTarget: YUKA.Vector3; @@ -118,23 +116,22 @@ type FishAgent = FishDefinition & { routeGoal: PondPoint; nextRouteAt: number; routeVisits: number; - startle: StartleMemory | null; motion: FishMotion; previous: PondPoint; maxStep: number; bodyRadius: number; + shoreRadius: number; stun: FishSimulationFrame["stun"]; stunSerial: number; stunHeading: number | null; }; -type FishPair = { first: FishAgent; second: FishAgent; side: 1 | -1; touching: boolean; passing: boolean }; +type FishPair = { first: FishAgent; second: FishAgent; side: 1 | -1; touching: boolean; passing: boolean; yieldFirst: boolean }; type FishMove = { position: PondPoint; heading: number }; type FlyAgent = FlyDefinition & { worldPosition: PondPoint; velocity: PondPoint; - startle: StartleMemory | null; motion: { heading: number; hoverPhase: number; @@ -153,13 +150,19 @@ type SimulationOptions = { fish: readonly FishDefinition[]; flies: readonly FlyDefinition[]; isWater: (x: number, y: number) => boolean; + waterBounds?: () => { minX: number; minY: number; maxX: number; maxY: number }; + headSeparation?: number; + recoverOutside?: boolean; + avoidHeadOn?: boolean; + shoreClearance?: number; + resizeAdmission?: boolean; }; type SimulationInput = { now: number; delta: number; pointer: PondPointerInput; - visibleAnchorIds: readonly string[]; + visibleAnchorIds?: readonly string[]; }; const SHORE_ROUTES: readonly PondPoint[] = [ @@ -182,12 +185,11 @@ const IDLE_MOTION: Record 1 - Math.exp(-rate * delta); -const startleStrength = (startle: StartleMemory | null, now: number, duration: number) => - startle ? pondClamp((startle.until - now) / duration, 0, 1) : 0; function awayHeading(position: PondPoint, origin: PondPoint, fallback: number) { return pondDistance(position, origin) > 0.001 @@ -274,6 +276,11 @@ class SeededWanderBehavior extends YUKA.SteeringBehavior { this.turnIn = 0.7 + random() * 1.8; } + aim(heading: number) { + this.angle = heading; + this.targetAngle = heading; + } + calculate(vehicle: YUKA.Vehicle, force: YUKA.Vector3, delta: number) { this.turnIn -= delta; if (this.turnIn <= 0) { @@ -292,6 +299,7 @@ function nearestWater( isWater: SimulationOptions["isWater"], clearance = 12, fallback?: PondPoint, + bounds?: { minX: number; minY: number; maxX: number; maxY: number }, ) { const hasClearance = (candidate: PondPoint) => { if (candidate.x < 20 + clearance || candidate.x > width - 20 - clearance || @@ -318,8 +326,12 @@ function nearestWater( 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 minX = bounds ? Math.max(20 + clearance, bounds.minX) : 20 + clearance; + const maxX = bounds ? Math.min(width - 20 - clearance, bounds.maxX) : width - 20 - clearance; + const minY = bounds ? Math.max(20 + clearance, bounds.minY) : 20 + clearance; + const maxY = bounds ? Math.min(height - 20 - clearance, bounds.maxY) : height - 20 - clearance; + for (let x = minX; x < maxX; x += 8) { + for (let y = minY; y < maxY; y += 8) { const candidate = { x, y }; const distance = pondDistance(position, candidate); if (distance < closestDistance && hasClearance(candidate)) { @@ -358,16 +370,17 @@ export function createPondSimulation(options: SimulationOptions) { width, height, isWater, - agent.bodyRadius, + agent.shoreRadius, { x: agent.vehicle.position.x, y: agent.vehicle.position.z }, + options.waterBounds?.(), ); 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; + if (waterPath(from, Math.atan2(goal.y - from.y, goal.x - from.x), pondDistance(from, goal), agent.shoreRadius)) 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)) { + if (waterPath(from, localHeading, length, agent.shoreRadius)) { return { x: from.x + Math.cos(localHeading) * length, y: from.y + Math.sin(localHeading) * length }; } } @@ -375,11 +388,30 @@ export function createPondSimulation(options: SimulationOptions) { return from; }; + function goalInBounds(agent: FishAgent, desired: PondPoint) { + const bounds = options.waterBounds?.(); + if (!bounds) return desired; + const center = { x: (bounds.minX + bounds.maxX) / 2, y: (bounds.minY + bounds.maxY) / 2 }; + let projected = center; + try { + projected = nearestWater(desired, width, height, isWater, Math.min(agent.bodyRadius, 12), center, bounds); + } catch { + projected = center; + } + const dx = projected.x - center.x; + const dy = projected.y - center.y; + const angle = Math.atan2(dy, dx) + agent.routeVisits * 0.9; + const ring = Math.min(bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) * 0.18; + const goal = { x: center.x + Math.cos(angle) * ring, y: center.y + Math.sin(angle) * ring }; + return isWater(goal.x, goal.y) ? goal : center; + } + 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 shoreRadius = Math.min(bodyRadius, options.shoreClearance ?? bodyRadius); + const initial = nearestWater(definition.position, width, height, isWater, shoreRadius, undefined, options.waterBounds?.()); const vehicle = new YUKA.Vehicle(); vehicle.position.set(initial.x, 0, initial.y); vehicle.velocity.set( @@ -396,8 +428,6 @@ export function createPondSimulation(options: SimulationOptions) { const wander = new SeededWanderBehavior(random, definition.heading); const pointerFlee = new YUKA.FleeBehavior(pointerAgent.position, 410); - const catOrigin = new YUKA.Vector3(-1000, 0, -1000); - const catFlee = new YUKA.FleeBehavior(catOrigin, 260); 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); @@ -410,13 +440,11 @@ export function createPondSimulation(options: SimulationOptions) { cohesion.weight = 0.11; pointerFlee.active = false; pointerFlee.weight = 0; - catFlee.active = false; homeSeek.active = false; goalArrive.active = true; goalArrive.weight = 0.5; vehicle.steering .add(pointerFlee) - .add(catFlee) .add(homeSeek) .add(separation) .add(goalArrive) @@ -430,8 +458,6 @@ export function createPondSimulation(options: SimulationOptions) { position: initial, vehicle, pointerFlee, - catFlee, - catOrigin, homeSeek, goalArrive, goalTarget, @@ -439,7 +465,6 @@ export function createPondSimulation(options: SimulationOptions) { routeGoal: initial, nextRouteAt: 0, routeVisits: Math.floor(random() * 4), - startle: null, motion: { activity: "swimming", activityIndex: Math.floor(random() * IDLE_ACTIVITIES.length), @@ -461,6 +486,7 @@ export function createPondSimulation(options: SimulationOptions) { previous: initial, maxStep: 0, bodyRadius, + shoreRadius, stun: null, stunSerial: 0, stunHeading: null, @@ -474,15 +500,16 @@ export function createPondSimulation(options: SimulationOptions) { 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 }); + pairs.push({ first: orderedFish[first], second: orderedFish[second], side: 1, touching: false, passing: false, yieldFirst: true }); } } + const allFish = [...fish]; + const allPairs = [...pairs]; const flies = options.flies.map((definition): FlyAgent => ({ ...definition, worldPosition: { ...definition.position }, velocity: { x: 0, y: 0 }, - startle: null, motion: { heading: definition.phase, hoverPhase: definition.phase, @@ -495,6 +522,120 @@ export function createPondSimulation(options: SimulationOptions) { })); let lastFrame: PondSimulationFrame | null = null; + let headSeparation = options.headSeparation ?? 0; + let simulationNow = 0; + let settledBounds = options.waterBounds?.() ?? null; + const recoverOutside = options.recoverOutside === true; + + function boundsMoved(next: { minX: number; minY: number; maxX: number; maxY: number } | null) { + const previous = settledBounds; + if (!previous || !next) return false; + return Math.abs(previous.minX - next.minX) > BOUNDS_SHIFT + || Math.abs(previous.minY - next.minY) > BOUNDS_SHIFT + || Math.abs(previous.maxX - next.maxX) > BOUNDS_SHIFT + || Math.abs(previous.maxY - next.maxY) > BOUNDS_SHIFT; + } + + function snapHome(agent: FishAgent, seed: PondPoint, allowRim = true) { + const bounds = options.waterBounds?.(); + const center = bounds + ? { x: (bounds.minX + bounds.maxX) / 2, y: (bounds.minY + bounds.maxY) / 2 } + : { x: width / 2, y: height / 2 }; + const nearby = waterWithin(seed.x, seed.y); + const blocked = (point: PondPoint) => fish.some((other) => { + if (other === agent) return false; + const otherPoint = { x: other.vehicle.position.x, y: other.vehicle.position.z }; + if (!isWater(otherPoint.x, otherPoint.y)) return false; + return pondDistance(point, otherPoint) < (agent.displayWidth + other.displayWidth) * 0.36; + }); + if (allowRim && nearby && pondDistance(nearby, seed) <= 64 && !blocked(nearby)) return nearby; + const search = (origin: PondPoint, radius: number, reach: number) => { + for (let ring = 0; ring <= reach; ring += 8) { + const samples = ring === 0 ? 1 : 24; + for (let sample = 0; sample < samples; sample += 1) { + const angle = agent.motion.heading + sample / samples * TAU; + const candidate = { + x: origin.x + Math.cos(angle) * ring, + y: origin.y + Math.sin(angle) * ring, + }; + if (waterClearance(candidate, radius) && !blocked(candidate)) return candidate; + } + } + return null; + }; + for (const radius of [agent.shoreRadius, Math.min(12, agent.shoreRadius)]) { + const local = search(seed, radius, 280); + if (local) return local; + const inland = search(center, radius, Math.max(bounds ? bounds.maxX - bounds.minX : width, bounds ? bounds.maxY - bounds.minY : height)); + if (inland) return inland; + } + return nearestWater(seed, width, height, isWater, 0, center, bounds); + } + + function waterWithin(x: number, y: number) { + for (let radius = 4; radius <= 64; radius += 4) { + for (let sample = 0; sample < 16; sample += 1) { + const angle = (sample + 0.5) / 16 * Math.PI * 2; + const candidate = { x: x + Math.cos(angle) * radius, y: y + Math.sin(angle) * radius }; + if (isWater(candidate.x, candidate.y)) return candidate; + } + } + return null; + } + + function headPoint(agent: FishAgent) { + const length = agent.visualLength ?? agent.displayWidth; + return { + x: agent.vehicle.position.x + Math.cos(agent.motion.heading) * length * 0.46, + y: agent.vehicle.position.z + Math.sin(agent.motion.heading) * length * 0.46, + }; + } + + function nudgeFish(agent: FishAgent, x: number, y: number) { + const next = { x: agent.vehicle.position.x + x, y: agent.vehicle.position.z + y }; + if (!waterClearance(next, agent.shoreRadius)) return; + agent.vehicle.position.x = next.x; + agent.vehicle.position.z = next.y; + } + + function shoreSlide(agent: FishAgent, seed: PondPoint) { + const bounds = options.waterBounds?.(); + const center = bounds + ? { x: (bounds.minX + bounds.maxX) / 2, y: (bounds.minY + bounds.maxY) / 2 } + : { x: width / 2, y: height / 2 }; + const inward = Math.atan2(center.y - seed.y, center.x - seed.x); + const tangent = agent.motion.heading; + const headings = [inward, tangent, tangent + Math.PI, inward + 0.7, inward - 0.7]; + for (let distance = 1; distance <= 8; distance += 1) { + for (const heading of headings) { + const candidate = { + x: seed.x + Math.cos(heading) * distance, + y: seed.y + Math.sin(heading) * distance, + }; + if (waterClearance(candidate, agent.shoreRadius)) return candidate; + } + } + return null; + } + + function separateHeads() { + if (headSeparation <= 0) return; + for (let pass = 0; pass < 2; pass += 1) { + for (const pair of pairs) { + const first = headPoint(pair.first); + const second = headPoint(pair.second); + const dx = second.x - first.x; + const dy = second.y - first.y; + const distance = Math.hypot(dx, dy); + if (distance >= headSeparation || distance < 1e-4) continue; + const push = Math.min(8, (headSeparation - distance) / 2); + const nx = dx / distance; + const ny = dy / distance; + nudgeFish(pair.first, -nx * push, -ny * push); + nudgeFish(pair.second, nx * push, ny * push); + } + } + } function fishTargets(input: SimulationInput): PondTarget[] { return fish.map((agent) => { @@ -504,7 +645,6 @@ export function createPondSimulation(options: SimulationOptions) { 1, input.pointer.influence * Math.max(0, 1 - pointerDistance / 360) * (0.42 + input.pointer.energy * 0.78), ); - const startleDanger = startleStrength(agent.startle, input.now, 1250); return { id: agent.id, type: "fish" as const, @@ -513,8 +653,9 @@ export function createPondSimulation(options: SimulationOptions) { visible: isWater(position.x, position.y), attackable: true, interactionRange: Math.max(18, agent.displayWidth * 0.3), + bodyRadius: agent.bodyRadius, species: agent.species, - danger: Math.max(pointerDanger, startleDanger), + danger: pointerDanger, }; }); } @@ -530,10 +671,7 @@ export function createPondSimulation(options: SimulationOptions) { visible: true, attackable: true, interactionRange: 12, - danger: Math.max( - input.pointer.influence * Math.max(0, 1 - pointerDistance / 170), - startleStrength(agent.startle, input.now, 950), - ), + danger: input.pointer.influence * Math.max(0, 1 - pointerDistance / 170), }; }); } @@ -568,14 +706,14 @@ export function createPondSimulation(options: SimulationOptions) { 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; + if (retained !== null && waterPath(from, retained, Math.min(lookAhead, Math.max(4, agent.motion.speed * 0.5)), agent.shoreRadius) && + (agent.motion.shoreRemaining > 0 || !waterPath(from, heading, lookAhead, agent.shoreRadius))) return retained; + if (waterPath(from, heading, lookAhead, agent.shoreRadius)) 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)) { + if (waterPath(from, candidate, lookAhead, agent.shoreRadius)) { agent.motion.shoreRemaining = 1.4; return candidate; } @@ -587,7 +725,7 @@ export function createPondSimulation(options: SimulationOptions) { 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; + if (!waterPath(from, candidate, length, agent.shoreRadius)) continue; const turn = Math.abs(pondAngleDelta(agent.motion.heading, candidate)); if (length > bestLength || length === bestLength && turn < bestTurn) { bestHeading = candidate; @@ -605,15 +743,15 @@ export function createPondSimulation(options: SimulationOptions) { 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; + if (motion.shoreHeading !== null && !waterPath(previous, travelHeading, distance, agent.shoreRadius) && + waterPath(previous, motion.shoreHeading, distance, agent.shoreRadius)) 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); + }, agent.shoreRadius); for (let sample = 1; sample <= samples; sample += 1) { const nextFraction = sample / samples; const nextDistance = distance * nextFraction; @@ -636,10 +774,10 @@ export function createPondSimulation(options: SimulationOptions) { const placed: FishAgent[] = []; for (const agent of orderedFish) { const pose = fishPose(agent); - const free = (position: PondPoint, escapeLength: number) => waterClearance(position, agent.bodyRadius) && + const free = (position: PondPoint, escapeLength: number) => waterClearance(position, agent.shoreRadius) && 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)); + .some((heading) => waterPath(position, heading, escapeLength, agent.shoreRadius)); if (!free(pose.position, 60)) { let replacement: PondPoint | null = null; for (const escapeLength of [60, 30, 12]) { @@ -701,7 +839,7 @@ export function createPondSimulation(options: SimulationOptions) { 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) { + if (convergingNoses && !options.avoidHeadOn) { courses.set(first, first.motion.heading); courses.set(second, second.motion.heading); continue; @@ -783,7 +921,7 @@ export function createPondSimulation(options: SimulationOptions) { 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 && + const bonk = !options.avoidHeadOn && !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) { @@ -829,7 +967,7 @@ export function createPondSimulation(options: SimulationOptions) { 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)) { + if (waterPath(start.position, Math.atan2(candidate.y - start.position.y, candidate.x - start.position.x), pondDistance(start.position, candidate), agent.shoreRadius)) { end.position = candidate; if (bonk) end.heading = pose.heading; } else { @@ -853,13 +991,16 @@ export function createPondSimulation(options: SimulationOptions) { 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]]) { + const individual = pair.yieldFirst + ? [[firstLength, 0], [0, secondLength]] : [[0, secondLength], [firstLength, 0]]; + pair.yieldFirst = !pair.yieldFirst; + for (const [aLength, bLength] of [[sharedLength, sharedLength], ...individual]) { + for (const direction of count >= 8 ? [] : directions) { 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; + if (!waterPath(firstBefore.position, direction, aLength, pair.first.shoreRadius) || + !waterPath(secondBefore.position, direction, bLength, pair.second.shoreRadius)) continue; let blocked = false; for (let firstDisc = 0; firstDisc < 3; firstDisc += 1) { for (let secondDisc = 0; secondDisc < 3; secondDisc += 1) { @@ -936,19 +1077,10 @@ export function createPondSimulation(options: SimulationOptions) { input.pointer.position, ); const pointerThreat = input.pointer.influence * Math.max(0, 1 - pointerDistance / 360) * input.pointer.energy; - const catDistance = pondDistance( - { x: position.x, y: position.z }, - frame.cat.contact, - ); - const catEnergy = ["prepare-bat", "bat", "react"].includes(frame.cat.state) ? 1 : frame.cat.state === "airborne" ? 0.28 : 0; - const impactStartle = startleStrength(agent.startle, input.now, 1250); - const catThreat = Math.max(catEnergy * Math.max(0, 1 - catDistance / 225), impactStartle); - const totalThreat = pondClamp(Math.max(pointerThreat, catThreat), 0, 1); - const catOrigin = impactStartle > 0 && agent.startle ? agent.startle.origin : frame.cat.contact; - agent.catOrigin.set(catOrigin.x, 0, catOrigin.y); + const totalThreat = pondClamp(pointerThreat, 0, 1); agent.motion.threat = totalThreat; agent.motion.escapeHeading = totalThreat > 0.04 - ? awayHeading(agent.previous, pointerThreat > catThreat ? input.pointer.position : catOrigin, agent.motion.heading + Math.PI) + ? awayHeading(agent.previous, input.pointer.position, agent.motion.heading + Math.PI) : null; const idle = !intent?.goal && totalThreat <= 0.04 && @@ -966,11 +1098,9 @@ export function createPondSimulation(options: SimulationOptions) { agent.pointerFlee.weight += (pointerThreat * 7.4 - agent.pointerFlee.weight) * response(26, input.delta); agent.pointerFlee.active = agent.pointerFlee.weight > 0.012; - agent.catFlee.active = catThreat > 0.012; - agent.catFlee.weight = 2.8 + catThreat * 2.8; if (intent?.goal) { - const goal = nearestWater(intent.goal, width, height, isWater, agent.bodyRadius, agent.previous); + const goal = nearestWater(intent.goal, width, height, isWater, agent.shoreRadius, agent.previous, options.waterBounds?.()); 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; @@ -1037,7 +1167,7 @@ export function createPondSimulation(options: SimulationOptions) { 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)); + motion.shoreHeading = shorelineHeading(agent, desiredHeading, Math.max(36, agent.shoreRadius + 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; @@ -1068,12 +1198,39 @@ export function createPondSimulation(options: SimulationOptions) { } resolveFishContacts(starts, ends, input.delta); + separateHeads(); 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; + const point = { x: position.x, y: position.z }; + const outside = !isWater(point.x, point.y); + const shell = !outside && !waterClearance(point, agent.shoreRadius); + if (recoverOutside && (outside || shell)) { + const slid = shell ? shoreSlide(agent, point) : null; + const destination = slid ?? snapHome(agent, point, outside); + const distance = pondDistance(point, destination); + const recoveryStep = agent.cruise * 3.5 * input.delta; + const fraction = shell && distance > recoveryStep ? recoveryStep / distance : 1; + const home = { + x: point.x + (destination.x - point.x) * fraction, + y: point.y + (destination.y - point.y) * fraction, + }; + const shift = pondDistance(point, home); + position.x = home.x; + position.z = home.y; + if (shell && (slid || shift <= 8)) { + const along = motion.speed * input.delta; + agent.previous = { + x: home.x - Math.cos(motion.heading) * along, + y: home.y - Math.sin(motion.heading) * along, + }; + } else { + agent.previous = { x: home.x, y: home.y }; + } + } 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; @@ -1084,7 +1241,7 @@ export function createPondSimulation(options: SimulationOptions) { 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) { + if (recoveredFish.has(agent) && !intents.get(agent.id)?.goal && !isWater(agent.routeGoal.x, agent.routeGoal.y)) { agent.routeGoal = routePoint(agent); agent.goalTarget.set(agent.routeGoal.x, 0, agent.routeGoal.y); agent.nextRouteAt = input.now + duration(3200, 7200); @@ -1112,15 +1269,13 @@ export function createPondSimulation(options: SimulationOptions) { 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 batDanger = startleStrength(agent.startle, input.now, 950); - const danger = Math.max(pointerDanger, batDanger); + 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 = batDanger >= pointerDanger && agent.startle ? agent.startle.origin : input.pointer.position; - const heading = awayHeading(agent.worldPosition, origin, motion.heading); + const heading = awayHeading(agent.worldPosition, input.pointer.position, motion.heading); targetX = Math.cos(heading) * (baseSpeed + 95 * danger); targetY = Math.sin(heading) * (baseSpeed + 95 * danger); } @@ -1145,19 +1300,6 @@ export function createPondSimulation(options: SimulationOptions) { } } - function applyConsequences(frame: PondWorldFrame, now: number) { - for (const event of frame.events) { - if (event.type !== "bat") continue; - if (event.targetType === "fish") { - const target = fish.find((agent) => agent.id === event.targetId); - if (target) target.startle = { until: now + (event.hit ? 1250 : 760), origin: { ...frame.cat.contact } }; - } else { - const target = flies.find((agent) => agent.id === event.targetId); - if (target) target.startle = { until: now + 950, origin: { ...frame.cat.contact } }; - } - } - } - 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])); @@ -1210,14 +1352,23 @@ export function createPondSimulation(options: SimulationOptions) { function step(input: SimulationInput) { input = { ...input, delta: Number.isFinite(input.delta) ? pondClamp(input.delta, 0, 0.1) : 0 }; + simulationNow = input.now; const targets = [...fishTargets(input), ...flyTargets(input)]; const frame = world.step({ now: input.now, delta: input.delta, targets, visibleAnchorIds: input.visibleAnchorIds, + 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.shoreRadius, agent.previous, options.waterBounds?.()); + if (waterClearance(point, agent.shoreRadius) && pondDistance(point, food) < arrivalRadius) return point; + } + return null; + }, }); - applyConsequences(frame, input.now); if (input.delta > 0) { updateFish(input, frame); updateFlies(input, frame); @@ -1228,10 +1379,69 @@ export function createPondSimulation(options: SimulationOptions) { separateInitialFish(); + function resizeFish(updates: readonly { id: string; displayWidth: number; visualLength?: number; visible?: boolean }[]) { + for (const update of updates) { + const agent = allFish.find((item) => item.id === update.id); + if (!agent) continue; + agent.displayWidth = update.displayWidth; + agent.bodyRadius = update.displayWidth * 0.56; + agent.shoreRadius = Math.min(agent.bodyRadius, options.shoreClearance ?? agent.bodyRadius); + if (update.visualLength !== undefined) agent.visualLength = update.visualLength; + } + if (options.resizeAdmission) { + const admitted: FishAgent[] = []; + const visible = new Set(updates.filter((update) => update.visible).map((update) => update.id)); + const candidates = [...allFish].sort((a, b) => Number(visible.has(b.id)) - Number(visible.has(a.id)) || Number(fish.includes(b)) - Number(fish.includes(a))); + for (const agent of candidates) { + const pose = fishPose(agent); + if (!waterClearance(pose.position, agent.shoreRadius) || admitted.some((other) => bodiesOverlap(agent, pose, other, fishPose(other)))) continue; + admitted.push(agent); + } + const admittedSet = new Set(admitted); + for (const agent of allFish) { + const active = fish.includes(agent); + if (active && !admittedSet.has(agent)) manager.remove(agent.vehicle); + if (!active && admittedSet.has(agent)) manager.add(agent.vehicle); + } + fish.splice(0, fish.length, ...allFish.filter((agent) => admittedSet.has(agent))); + orderedFish.splice(0, orderedFish.length, ...fish.slice().sort((a, b) => a.id.localeCompare(b.id))); + pairs.splice(0, pairs.length, ...allPairs.filter((pair) => admittedSet.has(pair.first) && admittedSet.has(pair.second))); + } + const nextBounds = options.waterBounds?.() ?? null; + const moved = boundsMoved(nextBounds); + settledBounds = nextBounds; + if (!nextBounds) return fish.map((agent) => agent.id); + for (const agent of fish) { + if (!moved && isWater(agent.routeGoal.x, agent.routeGoal.y)) continue; + const here = { x: agent.vehicle.position.x, y: agent.vehicle.position.z }; + agent.routeGoal = goalInBounds(agent, agent.routeGoal); + agent.goalTarget.set(agent.routeGoal.x, 0, agent.routeGoal.y); + agent.nextRouteAt = Math.max(agent.nextRouteAt, simulationNow + 6000); + agent.motion.activityIndex = 0; + agent.motion.activity = "swimming"; + agent.motion.activityRemaining = 8; + const heading = Math.atan2(agent.routeGoal.y - here.y, agent.routeGoal.x - here.x); + if (Number.isFinite(heading)) { + agent.wander.aim(heading); + agent.motion.heading = heading; + } + } + return fish.map((agent) => agent.id); + } + + function setHeadSeparation(distance: number) { + headSeparation = distance; + } + return { dropFood: world.dropFood, - requestHop: world.requestHop, step, + resizeFish, + fishPosition: (id: string) => { + const agent = allFish.find((item) => item.id === id); + return agent ? { x: agent.vehicle.position.x, y: agent.vehicle.position.z } : null; + }, + setHeadSeparation, destroy: () => manager.clear(), get frame() { return lastFrame; diff --git a/components/concepts/signal-desk/pond-three.ts b/components/concepts/signal-desk/pond-three.ts index d4e16d7..f5716c4 100644 --- a/components/concepts/signal-desk/pond-three.ts +++ b/components/concepts/signal-desk/pond-three.ts @@ -35,6 +35,219 @@ export function createYDownPondQuad() { return geometry; } +export function createBentFishGeometry() { + const geometry = new THREE.PlaneGeometry(1, 1, 1, 12); + const uv = geometry.getAttribute("uv"); + for (let index = 0; index < uv.count; index += 1) uv.setY(index, 1 - uv.getY(index)); + return geometry; +} + +export type FishBendUniforms = { + time: { value: number }; + curve: { value: number }; + amount: { value: number }; +}; + +const FISH_BEND_SNIPPET = ` +float pondS = clamp(position.y + 0.5, 0.0, 1.0); +float pondWave = sin(7.539822 * pondS - uBendTime); +transformed.x += (0.08 * (0.25 + 0.75 * pondS) * pondWave + uBendCurve * pondS * pondS) * uBendAmount; +`; + +export function attachFishBend(material: THREE.Material, bend: FishBendUniforms) { + material.customProgramCacheKey = () => "pond-fish-bend"; + material.onBeforeCompile = (shader) => { + shader.uniforms.uBendTime = bend.time; + shader.uniforms.uBendCurve = bend.curve; + shader.uniforms.uBendAmount = bend.amount; + shader.vertexShader = `uniform float uBendTime;\nuniform float uBendCurve;\nuniform float uBendAmount;\n${shader.vertexShader}`; + shader.vertexShader = shader.vertexShader.replace("#include ", `vec3 transformed = vec3(position);\n${FISH_BEND_SNIPPET}`); + }; +} + +export type PondWaterMaskUniforms = { + texture: { value: THREE.Texture }; + origin: { value: THREE.Vector2 }; + size: { value: THREE.Vector2 }; + enabled: { value: number }; +}; + +export function attachPondWaterMask(material: THREE.Material, mask: PondWaterMaskUniforms) { + const compile = material.onBeforeCompile; + const cacheKey = material.customProgramCacheKey(); + material.customProgramCacheKey = () => `${cacheKey}:pond-water-mask`; + material.onBeforeCompile = function (shader, renderer) { + compile.call(this, shader, renderer); + shader.uniforms.uPondWaterMask = mask.texture; + shader.uniforms.uPondWaterOrigin = mask.origin; + shader.uniforms.uPondWaterSize = mask.size; + shader.uniforms.uPondWaterMaskEnabled = mask.enabled; + shader.vertexShader = `varying vec2 vPondWaterPosition;\n${shader.vertexShader}`; + shader.vertexShader = shader.vertexShader.replace( + "#include ", + "vPondWaterPosition = (modelMatrix * vec4(transformed, 1.0)).xy;\n#include ", + ); + shader.fragmentShader = `uniform sampler2D uPondWaterMask;\nuniform vec2 uPondWaterOrigin;\nuniform vec2 uPondWaterSize;\nuniform float uPondWaterMaskEnabled;\nvarying vec2 vPondWaterPosition;\n${shader.fragmentShader}`; + shader.fragmentShader = shader.fragmentShader.replace( + "#include ", + `#include + if (uPondWaterMaskEnabled > 0.5) { + vec2 pondMaskPoint = (vPondWaterPosition - uPondWaterOrigin) / uPondWaterSize; + if (any(lessThan(pondMaskPoint, vec2(0.0))) || any(greaterThanEqual(pondMaskPoint, vec2(1.0)))) discard; + if (texture2D(uPondWaterMask, vec2(pondMaskPoint.x, 1.0 - pondMaskPoint.y)).a < 0.01) discard; + }`, + ); + }; + material.needsUpdate = true; +} + +export const POND_FISH_COVERAGE_CUT = 0.35; + +export function pondFishCellInside(alphas: readonly number[], cut = POND_FISH_COVERAGE_CUT) { + if (alphas.length === 0) return false; + let hits = 0; + for (const alpha of alphas) if (alpha >= cut) hits += 1; + return hits / alphas.length >= cut; +} + +export function createFishIdMaterial(map: THREE.Texture, bend: FishBendUniforms, tancho: boolean) { + const uniforms = { + map: { value: map }, + uOffset: { value: new THREE.Vector2() }, + uRepeat: { value: new THREE.Vector2(1, 1) }, + uTancho: { value: tancho ? 1 : 0 }, + uBendTime: bend.time, + uBendCurve: bend.curve, + uBendAmount: bend.amount, + }; + const material = new THREE.ShaderMaterial({ + uniforms, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + forceSinglePass: true, + toneMapped: false, + vertexShader: ` + uniform float uBendTime; + uniform float uBendCurve; + uniform float uBendAmount; + varying vec2 vUv; + varying float vS; + varying float vAcross; + void main() { + vUv = uv; + vec3 transformed = vec3(position); + ${FISH_BEND_SNIPPET} + vS = clamp(position.y + 0.5, 0.0, 1.0); + vAcross = position.x + 0.5; + gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); + } + `, + fragmentShader: ` + uniform sampler2D map; + uniform vec2 uOffset; + uniform vec2 uRepeat; + uniform float uTancho; + varying vec2 vUv; + varying float vS; + varying float vAcross; + void main() { + vec2 footprint = vUv * uRepeat + uOffset; + vec2 dx = dFdx(vUv) * uRepeat; + vec2 dy = dFdy(vUv) * uRepeat; + float hits = 0.0; + if (dot(dx, dx) + dot(dy, dy) < 1e-10) { + hits = texture2D(map, footprint).a >= ${POND_FISH_COVERAGE_CUT} ? 1.0 : 0.0; + } else { + for (int y = 0; y < 4; y++) { + for (int x = 0; x < 4; x++) { + vec2 sampleUv = footprint + + dx * ((float(x) + 0.5) / 4.0 - 0.5) + + dy * ((float(y) + 0.5) / 4.0 - 0.5); + if (texture2D(map, sampleUv).a >= ${POND_FISH_COVERAGE_CUT}) hits += 1.0; + } + } + hits /= 16.0; + } + if (hits < ${POND_FISH_COVERAGE_CUT}) discard; + vec4 tex = texture2D(map, footprint); + float across = clamp(abs(vAcross - 0.5) * 2.0, 0.0, 1.0); + if (tex.a < ${POND_FISH_COVERAGE_CUT}) { + gl_FragColor = vec4(5.0 / 255.0, vS, across, 1.0); + return; + } + float luma = dot(tex.rgb, vec3(0.2126, 0.7152, 0.0722)); + float id = 5.0; + bool eye = luma < 0.14 && vS > 0.05 && vS < 0.22 && abs(vAcross - 0.5) > 0.07 && abs(vAcross - 0.5) < 0.24; + bool crown = abs(vAcross - 0.5) < 0.09 && vS > 0.035 && vS < 0.20; + if (uTancho > 0.5) { + if (eye) id = 9.0; + if (crown) id = 7.0; + } else { + id = luma < 0.4 ? 6.0 : 5.0; + if (tex.r > tex.g * 1.18 && tex.r > tex.b * 1.25 && tex.r > 0.3) id = 7.0; + if (eye) id = 9.0; + } + gl_FragColor = vec4(id / 255.0, vS, across, 1.0); + } + `, + }); + return { + material, + sync() { + uniforms.uOffset.value.copy(map.offset); + uniforms.uRepeat.value.copy(map.repeat); + }, + }; +} + +export function createFlatIdMaterial(id: number) { + return new THREE.ShaderMaterial({ + uniforms: { uId: { value: id } }, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + forceSinglePass: true, + toneMapped: false, + vertexShader: ` + void main() { + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform float uId; + void main() { + gl_FragColor = vec4(uId / 255.0, 0.0, 0.0, 1.0); + } + `, + }); +} + +export function createWakeIdMaterial() { + return new THREE.ShaderMaterial({ + uniforms: {}, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + forceSinglePass: true, + toneMapped: false, + vertexShader: ` + attribute vec4 color; + varying float vLife; + void main() { + vLife = color.a; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + varying float vLife; + void main() { + gl_FragColor = vec4(11.0 / 255.0, vLife, 0.0, 1.0); + } + `, + }); +} + export function createPondSprite(geometry: THREE.BufferGeometry, texture: THREE.Texture, order: number) { const material = new THREE.MeshBasicMaterial({ map: texture, diff --git a/components/concepts/signal-desk/pond-world.ts b/components/concepts/signal-desk/pond-world.ts index e020368..25978e8 100644 --- a/components/concepts/signal-desk/pond-world.ts +++ b/components/concepts/signal-desk/pond-world.ts @@ -1,13 +1,10 @@ import { - ROCK_ANCHORS, createPondRandom, pondClamp as clamp, pondDistance as distance, pondHashSeed as hashSeed, pondLerp as lerp, pondSmoothstep as smoothstep, - type CatRoutine, - type CatWorldState, type FishFoodEntity, type FishFoodStateName, type FishWorldIntent, @@ -18,7 +15,6 @@ import { type PondWorldEvent, type PondWorldFrame, type PondWorldInput, - type RockAnchor, } from "./pond-model.ts"; export * from "./pond-model.ts"; @@ -40,6 +36,8 @@ type FishBrain = { slot: number; slotAngle: number; nextBiteAt: number; + progressDistance: number; + progressAt: number; reason: string; }; @@ -52,30 +50,8 @@ type FlyBrain = { reason: string; }; -type AnchorRoute = { - anchor: RockAnchor; - path: readonly RockAnchor[]; - distance: number; -}; - const MAX_FOOD = 8; const FOOD_MERGE_DISTANCE = 32; -const CAT_HOP_MIN = 55; -const CAT_HOP_MAX = 360; -const LEGAL_CAT_TRANSITIONS: Record = { - idle: ["notice-target", "approach", "observe"], - observe: ["notice-target", "approach", "observe"], - "notice-target": ["recover", "prepare-bat", "approach", "observe"], - approach: ["recover", "anticipate-hop"], - "anticipate-hop": ["recover", "airborne"], - airborne: ["land"], - land: ["settle"], - settle: ["notice-target", "idle"], - "prepare-bat": ["recover", "bat"], - bat: ["react"], - react: ["recover"], - recover: ["idle"], -}; const LEGAL_FISH_TRANSITIONS: Record = { cruising: ["detecting-food", "startled"], "detecting-food": ["approaching-food", "returning-to-cruise", "startled"], @@ -95,33 +71,12 @@ const LEGAL_FLY_TRANSITIONS: Record export function createPondWorld(seedValue: string | number = "6c75") { const seed = hashSeed(seedValue); const random = createPondRandom(seed); - const anchorCooldowns = new Map(); - const anchorVisits = new Map(); - const targetCooldowns = new Map(); 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 initialized = false; - let hopRequested = false; - let state: CatWorldState = "idle"; - let routine: CatRoutine = "watching"; - let stateStarted = 0; - let stateDuration = 3200; - let currentAnchor = ROCK_ANCHORS[0]; - let takeoffAnchor = currentAnchor; - let destinationAnchor: RockAnchor | null = null; - let selectedTargetId: string | null = null; - let huntExpiresAt = 0; - let huntHops = 0; - let nextHuntAt = 0; - let nextTargetScan = 0; - let nextPatrolAt = 0; - const recentAnchors: string[] = []; - let lastReason = "initialised"; - let idleAim = { x: currentAnchor.position.x - 90, y: currentAnchor.position.y - 10 }; let wind = 0; let windTarget = 0.25; let nextWindShift = 0; @@ -331,7 +286,7 @@ export function createPondWorld(seedValue: string | number = "6c75") { } function feedingRadius(fish: PondTarget, food: FoodInternal) { - return 30 + fish.interactionRange * 0.78 + food.pelletSize; + return Math.max(30 + fish.interactionRange * 0.78, fish.bodyRadius ?? 0) + food.pelletSize; } function updateFish(input: PondWorldInput, events: PondWorldEvent[]) { @@ -358,6 +313,8 @@ export function createPondWorld(seedValue: string | number = "6c75") { slot: 0, slotAngle: random() * Math.PI * 2, nextBiteAt: 0, + progressDistance: Infinity, + progressAt: input.now, reason: "cruising", }; fishBrains.set(fish.id, brain); @@ -370,6 +327,9 @@ export function createPondWorld(seedValue: string | number = "6c75") { 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); @@ -391,13 +351,29 @@ export function createPondWorld(seedValue: string | number = "6c75") { } 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) { + 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; - transitionFish(fish.id, brain, "returning-to-cruise", "food-reservation-full", input.now, duration(620, 1280), events); + 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 }); } @@ -412,14 +388,27 @@ export function createPondWorld(seedValue: string | number = "6c75") { 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 (elapsed >= brain.stateDuration) { + } else if (input.foodApproach && !approach) { releaseReservation(fish.id, brain); brain.foodId = null; - transitionFish(fish.id, brain, "returning-to-cruise", "approach-timed-out", input.now, duration(720, 1500), events); + 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) { + 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 { @@ -428,21 +417,27 @@ export function createPondWorld(seedValue: string | number = "6c75") { 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) { - 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, - }); + 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); } - 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) { @@ -452,7 +447,7 @@ export function createPondWorld(seedValue: string | number = "6c75") { 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) - ? foodGoal(fish, food, brain, input.now) + ? approach ?? foodGoal(fish, food, brain, input.now) : null; return { fishId: fish.id, @@ -558,142 +553,6 @@ export function createPondWorld(seedValue: string | number = "6c75") { }); } - function transition(next: CatWorldState, reason: string, now: number, nextDuration: number, events: PondWorldEvent[]) { - if (!LEGAL_CAT_TRANSITIONS[state].includes(next)) { - throw new Error(`Illegal cat transition ${state} -> ${next}`); - } - const previous = state; - state = next; - stateStarted = now; - stateDuration = nextDuration; - lastReason = reason; - events.push({ type: "transition", from: previous, to: next, reason }); - } - - function predictedTarget(target: PondTarget) { - const lead = target.type === "fly" ? 0.11 : 0.2; - return { - x: target.position.x + target.velocity.x * lead, - y: target.position.y + target.velocity.y * lead, - }; - } - - function anchorRoutes(visible: Set, now: number) { - const visibleAnchors = ROCK_ANCHORS.filter( - (anchor) => - visible.has(anchor.id) && - (anchor.id === currentAnchor.id || (anchorCooldowns.get(anchor.id) ?? 0) <= now), - ); - const anchors = visibleAnchors.some((anchor) => anchor.id === currentAnchor.id) - ? visibleAnchors - : [currentAnchor, ...visibleAnchors]; - const routes = new Map([ - [currentAnchor.id, { anchor: currentAnchor, path: [currentAnchor], distance: 0 }], - ]); - const visited = new Set(); - - while (visited.size < anchors.length) { - const route = [...routes.values()] - .filter((candidate) => !visited.has(candidate.anchor.id)) - .sort((a, b) => a.distance - b.distance)[0]; - if (!route) break; - visited.add(route.anchor.id); - - for (const anchor of anchors) { - if (visited.has(anchor.id)) continue; - const hop = distance(route.anchor.position, anchor.position); - if (hop < CAT_HOP_MIN || hop > CAT_HOP_MAX) continue; - const nextDistance = route.distance + hop; - const known = routes.get(anchor.id); - if (!known || nextDistance < known.distance) { - routes.set(anchor.id, { - anchor, - path: [...route.path, anchor], - distance: nextDistance, - }); - } - } - } - - return [...routes.values()]; - } - - function targetPlan(target: PondTarget, routes: readonly AnchorRoute[]) { - const targetPosition = predictedTarget(target); - return routes - .map((route) => { - const targetDistance = distance(route.anchor.position, targetPosition); - const reach = route.anchor.clearance + 72 + target.interactionRange * 0.42; - const verticalReach = 88 + route.anchor.clearance * 0.48; - if (targetDistance > reach || Math.abs(targetPosition.y - route.anchor.position.y) > verticalReach) return null; - const visits = anchorVisits.get(route.anchor.id) ?? 0; - const recentIndex = recentAnchors.lastIndexOf(route.anchor.id); - const recent = recentIndex < 0 - ? 0 - : Math.max(0, 72 - (recentAnchors.length - 1 - recentIndex) * 18); - return { - route, - score: - 460 - - targetDistance * 1.15 - - route.distance * 0.055 - - (route.path.length - 1) * 10 + - (visits === 0 ? 110 : Math.max(0, 28 - visits * 8)) - - recent, - }; - }) - .filter((plan): plan is { route: AnchorRoute; score: number } => plan !== null) - .sort((a, b) => b.score - a.score)[0] ?? null; - } - - function chooseTarget(targets: readonly PondTarget[], now: number, catPosition: PondPoint, visible: Set) { - const routes = anchorRoutes(visible, now); - return targets - .filter((target) => target.visible && target.attackable && (targetCooldowns.get(target.id) ?? 0) <= now) - .map((target) => { - const plan = targetPlan(target, routes); - if (!plan) return null; - const typeInterest = target.type === "fish" ? 130 : 0; - const proximity = Math.max(0, 80 - distance(catPosition, target.position) * 0.06); - return { target, score: typeInterest + proximity + plan.score + random() * 14 }; - }) - .filter((candidate): candidate is { target: PondTarget; score: number } => candidate !== null) - .sort((a, b) => b.score - a.score)[0]?.target ?? null; - } - - function chooseDestination(visible: Set, now: number, target: PondTarget | null) { - const currentPosition = currentAnchor.position; - if (target) { - const plan = targetPlan(target, anchorRoutes(visible, now)); - return plan?.route.path[1] ?? null; - } - const reachable = ROCK_ANCHORS - .filter((anchor) => anchor.id !== currentAnchor.id && visible.has(anchor.id)) - .filter((anchor) => (anchorCooldowns.get(anchor.id) ?? 0) <= now) - .filter((anchor) => { - const hopDistance = distance(currentPosition, anchor.position); - return hopDistance >= CAT_HOP_MIN && hopDistance <= CAT_HOP_MAX; - }); - const previousAnchor = recentAnchors.at(-2); - const pool = reachable.filter((anchor) => anchor.id !== previousAnchor); - return pool - .map((anchor) => { - const hopDistance = distance(currentPosition, anchor.position); - const areaScore = Math.min(90, anchor.surfaceArea / 130); - const distanceScore = 120 - Math.abs(hopDistance - 175) * 0.42; - const newRock = anchor.rockId === currentAnchor.rockId ? 0 : 38; - const recentIndex = recentAnchors.lastIndexOf(anchor.id); - const recentPenalty = recentIndex < 0 ? 0 : (recentAnchors.length - recentIndex) * 78; - const unvisitedBonus = (anchorVisits.get(anchor.id) ?? 0) === 0 ? 96 : 0; - return { - anchor, - score: distanceScore + areaScore + newRock - recentPenalty + unvisitedBonus + random() * 14, - }; - }) - .filter((candidate): candidate is { anchor: RockAnchor; score: number } => candidate !== null) - .sort((a, b) => b.score - a.score)[0]?.anchor ?? null; - } - function updateEnvironment(input: PondWorldInput, events: PondWorldEvent[]) { const { now, delta } = input; if (now >= nextWindShift) { @@ -736,260 +595,21 @@ export function createPondWorld(seedValue: string | number = "6c75") { } } - function requestHop() { - hopRequested = true; - } - function step(input: PondWorldInput): PondWorldFrame { const events: PondWorldEvent[] = []; - const visible = new Set(input.visibleAnchorIds); updateEnvironment(input, events); updateFoods(input.now, events); const fish = updateFish(input, events); const flies = updateFlies(input, events); - if (!initialized) { - const initial = ROCK_ANCHORS - .filter((anchor) => visible.has(anchor.id)) - .sort((a, b) => b.surfaceArea - a.surfaceArea)[0]; - if (initial) currentAnchor = initial; - takeoffAnchor = currentAnchor; - idleAim = { x: currentAnchor.position.x - 80, y: currentAnchor.position.y - 10 }; - stateStarted = input.now; - stateDuration = duration(2200, 4800); - routine = "watching"; - recentAnchors.push(currentAnchor.id); - anchorVisits.set(currentAnchor.id, 1); - nextTargetScan = input.now + duration(240, 720); - nextHuntAt = input.now + duration(1200, 3000); - nextPatrolAt = input.now + duration(7000, 14000); - nextWindShift = input.now + duration(900, 2600); - nextCurrentShift = input.now + duration(1100, 3100); - nextAmbientRipple = input.now + duration(1400, 3600); - initialized = true; - } - - const elapsed = input.now - stateStarted; - const target = selectedTargetId ? input.targets.find((candidate) => candidate.id === selectedTargetId) ?? null : null; - const targetAim = target ? predictedTarget(target) : null; - const targetDistance = targetAim ? distance(currentAnchor.position, targetAim) : Number.POSITIVE_INFINITY; - const batReach = currentAnchor.clearance + 72 + (target?.interactionRange ?? 0) * 0.42; - const batVerticalReach = 88 + currentAnchor.clearance * 0.48; - - if (state === "idle" || state === "observe") { - const scanDue = input.now >= nextTargetScan && input.now >= nextHuntAt; - const candidate = scanDue ? chooseTarget(input.targets, input.now, currentAnchor.position, visible) : null; - if (scanDue) nextTargetScan = input.now + duration(520, 1200); - - if (hopRequested) { - destinationAnchor = chooseDestination(visible, input.now, null); - hopRequested = false; - if (destinationAnchor) { - selectedTargetId = null; - routine = "patrolling"; - nextPatrolAt = input.now + duration(8000, 16000); - transition("approach", "pointer-requested-rock-hop", input.now, duration(180, 360), events); - } - } else if (candidate) { - selectedTargetId = candidate.id; - huntExpiresAt = input.now + duration(12000, 18000); - huntHops = 0; - routine = "hunting"; - transition("notice-target", `noticed-${candidate.type}`, input.now, duration(240, 520), events); - } else if (elapsed >= stateDuration) { - destinationAnchor = input.now >= nextPatrolAt ? chooseDestination(visible, input.now, null) : null; - if (destinationAnchor) { - selectedTargetId = null; - routine = "patrolling"; - nextPatrolAt = input.now + duration(8000, 16000); - transition("approach", "routine-patrol-to-fresh-vantage", input.now, duration(220, 420), events); - } else { - selectedTargetId = null; - routine = random() < 0.38 ? "resting" : "watching"; - idleAim = { - x: currentAnchor.position.x + duration(-130, 130), - y: currentAnchor.position.y + duration(-55, 45), - }; - transition("observe", routine === "resting" ? "settled-into-rest" : "chose-to-stay-and-look", input.now, duration(3200, 7200), events); - } - } - } else if (state === "notice-target") { - if (!target || !target.visible || !target.attackable) { - selectedTargetId = null; - nextHuntAt = input.now + duration(4200, 8200); - transition("recover", "target-lost-before-action", input.now, duration(420, 760), events); - } else if (elapsed >= stateDuration) { - if (targetDistance <= batReach && Math.abs(targetAim!.y - currentAnchor.position.y) <= batVerticalReach) { - transition("prepare-bat", "target-entered-paw-range", input.now, duration(320, 560), events); - } else if (input.now < huntExpiresAt && huntHops < 5 && (destinationAnchor = chooseDestination(visible, input.now, target))) { - huntHops += 1; - transition("approach", "moving-to-better-rock", input.now, duration(220, 420), events); - } else { - targetCooldowns.set(target.id, input.now + duration(3200, 5200)); - selectedTargetId = null; - nextHuntAt = input.now + duration(4800, 9000); - routine = "watching"; - transition("observe", "target-visible-but-unreachable", input.now, duration(900, 1800), events); - } - } - } else if (state === "approach") { - if (!destinationAnchor || !visible.has(destinationAnchor.id)) { - destinationAnchor = null; - transition("recover", "destination-no-longer-visible", input.now, duration(420, 720), events); - } else if (elapsed >= stateDuration) { - transition("anticipate-hop", "destination-validated", input.now, duration(300, 520), events); - } - } else if (state === "anticipate-hop") { - if (!destinationAnchor || !visible.has(destinationAnchor.id)) { - destinationAnchor = null; - transition("recover", "hop-cancelled", input.now, duration(420, 720), events); - } else if (elapsed >= stateDuration) { - takeoffAnchor = currentAnchor; - const hopDistance = distance(takeoffAnchor.position, destinationAnchor.position); - anchorCooldowns.set(takeoffAnchor.id, input.now + duration(7200, 11800)); - transition("airborne", "takeoff", input.now, clamp(580 + hopDistance * 1.55, 680, 1140), events); - events.push({ type: "takeoff", fromAnchor: takeoffAnchor.id, toAnchor: destinationAnchor.id }); - } - } else if (state === "airborne" && destinationAnchor && elapsed >= stateDuration) { - currentAnchor = destinationAnchor; - destinationAnchor = null; - anchorVisits.set(currentAnchor.id, (anchorVisits.get(currentAnchor.id) ?? 0) + 1); - recentAnchors.push(currentAnchor.id); - if (recentAnchors.length > 4) recentAnchors.shift(); - events.push({ type: "land", anchorId: currentAnchor.id, position: currentAnchor.position, impact: 0.55 + random() * 0.3 }); - transition("land", "contact-on-authored-surface", input.now, duration(110, 180), events); - } else if (state === "land" && elapsed >= stateDuration) { - transition("settle", "landing-compression-complete", input.now, duration(320, 620), events); - } else if (state === "settle" && elapsed >= stateDuration) { - const settledTarget = selectedTargetId - ? input.targets.find((candidate) => candidate.id === selectedTargetId) ?? null - : null; - const settledAim = settledTarget ? predictedTarget(settledTarget) : null; - if (settledTarget?.visible && settledTarget.attackable && settledAim && input.now < huntExpiresAt && huntHops <= 5) { - transition("notice-target", "target-reacquired-after-landing", input.now, duration(220, 440), events); - } else { - if (settledTarget) targetCooldowns.set(settledTarget.id, input.now + duration(3200, 5200)); - selectedTargetId = null; - nextHuntAt = input.now + duration(3200, 6200); - routine = "watching"; - transition("idle", "stable-contact-restored", input.now, duration(2200, 5200), events); - } - } else if (state === "prepare-bat") { - const valid = Boolean( - target && - target.visible && - target.attackable && - targetAim && - targetDistance <= batReach && - Math.abs(targetAim.y - currentAnchor.position.y) <= batVerticalReach, - ); - if (!valid) { - transition("recover", "target-left-paw-range", input.now, duration(480, 820), events); - } else if (elapsed >= stateDuration && target && targetAim) { - const hit = targetDistance <= batReach * 0.9; - events.push({ - type: "bat", - targetId: target.id, - targetType: target.type, - aim: targetAim, - distance: targetDistance, - reach: batReach, - hit, - }); - targetCooldowns.set(target.id, input.now + duration(4200, 7600)); - nextHuntAt = input.now + duration(2800, 5600); - transition("bat", hit ? "paw-contact" : "target-evaded-paw", input.now, duration(190, 280), events); - } - } else if (state === "bat" && elapsed >= stateDuration) { - transition("react", "registered-bat-consequence", input.now, duration(220, 440), events); - } else if (state === "react" && elapsed >= stateDuration) { - transition("recover", "action-recovery", input.now, duration(480, 860), events); - } else if (state === "recover" && elapsed >= stateDuration) { - selectedTargetId = null; - routine = "watching"; - nextTargetScan = input.now + duration(520, 1200); - idleAim = { - x: currentAnchor.position.x + duration(-100, 100), - y: currentAnchor.position.y + duration(-45, 35), - }; - transition("idle", "ready", input.now, duration(2400, 5600), events); - } - - const stateProgress = stateDuration > 0 ? clamp((input.now - stateStarted) / stateDuration, 0, 1) : 1; - let contact = currentAnchor.position; - let lift = 0; - let surfaceAngle = currentAnchor.surfaceAngle; - let squashX = 1; - let squashY = 1; - const grounded = state !== "airborne"; - - if (state === "anticipate-hop") { - squashX = 1.04 + stateProgress * 0.04; - squashY = 0.98 - stateProgress * 0.1; - } else if (state === "airborne" && destinationAnchor) { - const progress = stateProgress; - contact = { - x: lerp(takeoffAnchor.position.x, destinationAnchor.position.x, progress), - y: lerp(takeoffAnchor.position.y, destinationAnchor.position.y, progress), - }; - const hopDistance = distance(takeoffAnchor.position, destinationAnchor.position); - lift = 4 * progress * (1 - progress) * clamp(24 + hopDistance * 0.16, 30, 72); - surfaceAngle = lerp(takeoffAnchor.surfaceAngle, destinationAnchor.surfaceAngle, progress); - const extension = Math.sin(progress * Math.PI); - squashX = 1 - extension * 0.04; - squashY = 1 + extension * 0.06; - } else if (state === "land") { - squashX = lerp(1.12, 1.04, stateProgress); - squashY = lerp(0.82, 0.94, stateProgress); - } else if (state === "settle") { - squashX = stateProgress < 0.45 ? lerp(1.04, 0.98, stateProgress / 0.45) : lerp(0.98, 1, (stateProgress - 0.45) / 0.55); - squashY = stateProgress < 0.45 ? lerp(0.94, 1.025, stateProgress / 0.45) : lerp(1.025, 1, (stateProgress - 0.45) / 0.55); - } else if (state === "prepare-bat") { - squashX = 1.02; - squashY = 0.96; - } else if (state === "bat") { - squashX = 1.05; - squashY = 0.97; - } - - const selectedTarget = selectedTargetId ? input.targets.find((candidate) => candidate.id === selectedTargetId) ?? null : null; - const aim = selectedTarget ? predictedTarget(selectedTarget) : destinationAnchor?.position ?? idleAim; - const facing: 1 | -1 = aim.x < contact.x ? -1 : 1; - return { - cat: { - state, - routine, - contact, - lift, - squashX, - squashY, - surfaceAngle, - facing, - aim, - grounded, - anchorId: currentAnchor.id, - rockId: currentAnchor.rockId, - destinationAnchorId: destinationAnchor?.id ?? null, - selectedTargetId, - selectedTargetType: selectedTarget?.type ?? null, - batReach: currentAnchor.clearance + 72 + (selectedTarget?.interactionRange ?? 0) * 0.42, - }, environment: { wind, currentA, currentB, surfaceA, surfaceB, light }, foods: foodFrames(input.now), fish, flies, events, - debug: { - seed, - reason: lastReason, - grounded, - selectedTarget: selectedTargetId, - destinationAnchor: destinationAnchor?.id ?? null, - routine, - }, }; } - return { dropFood, requestHop, step }; + return { dropFood, step }; } diff --git a/components/concepts/signal-desk/project-explorer.module.css b/components/concepts/signal-desk/project-explorer.module.css index 46f6855..12b6bb0 100644 --- a/components/concepts/signal-desk/project-explorer.module.css +++ b/components/concepts/signal-desk/project-explorer.module.css @@ -14,7 +14,7 @@ .projectNav { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; min-width: 0; padding: 5px; @@ -51,14 +51,15 @@ .projectMarker { position: absolute; - top: 50%; + top: 0; + bottom: 0; right: 12px; + margin-block: auto; width: 7px; height: 7px; border-radius: 50%; background: var(--site-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--site-accent) 18%, transparent); - transform: translateY(-50%); } .projectNav strong { diff --git a/components/concepts/signal-desk/signal-desk.module.css b/components/concepts/signal-desk/signal-desk.module.css index 643168c..725812e 100644 --- a/components/concepts/signal-desk/signal-desk.module.css +++ b/components/concepts/signal-desk/signal-desk.module.css @@ -140,6 +140,54 @@ pointer-events: none; } +.pondSwitcher { + position: absolute; + top: 110px; + right: 0; + width: 264px; + margin: 0; + padding: 10px 17px 9px; + border: 1px solid var(--glass-edge); + border-radius: var(--site-radius, 24px); + color: var(--site-text); + background: linear-gradient(140deg, var(--glass-top), var(--glass-bottom)), var(--site-panel-bg); + box-shadow: var(--glass-panel-shadow); + backdrop-filter: blur(18px) saturate(1.3); + pointer-events: auto; +} + +.page:has([data-renderer="fallback"]) .pondSwitcher { + display: none; +} + +.pondSwitcher legend { + float: left; + width: 100%; + padding: 0; + font-size: 12px; + font-weight: 700; +} + +.pondChoices { display: flex; clear: both; } +.pondChoice { position: relative; display: grid; width: 114px; height: 44px; place-items: center; cursor: pointer; } +.pondChoice input { position: absolute; width: 1px; height: 1px; opacity: 0; } +.pondChoice span { display: flex; align-items: center; gap: 7px; width: 112px; height: 32px; padding: 0 14px; border-radius: var(--site-control-radius, 999px); font-size: 13px; font-weight: 700; } +.pondChoice input:checked + span { background: var(--glass-selected); } +.pondChoice input:focus-visible + span { outline: 3px solid var(--site-accent); outline-offset: -3px; } +.pondChoice i { display: block; flex: 0 0 20px; width: 20px; height: 20px; background: currentColor; opacity: .8; } +.asciiStone { clip-path: polygon(20% 0, 40% 0, 40% 20%, 80% 20%, 80% 40%, 100% 40%, 100% 60%, 80% 60%, 80% 80%, 60% 80%, 60% 100%, 20% 100%, 20% 80%, 0 80%, 0 40%, 20% 40%); } +.pixelStone { clip-path: polygon(0 20%, 20% 20%, 20% 0, 60% 0, 60% 20%, 80% 20%, 80% 40%, 100% 40%, 100% 80%, 80% 80%, 80% 100%, 40% 100%, 40% 80%, 0 80%); } +.pondCurrent { color: var(--site-muted); font-size: 11px; font-weight: 600; } + +@media (max-width: 600px) { + .pondSwitcher { top: 145px; } +} + +@media (forced-colors: active) { + .pondChoice input:checked + span { border: 2px solid Highlight; } + .pondChoice input:focus-visible + span { outline-color: Highlight; } +} + .eyebrow, .contactLabel, .footer { @@ -895,3 +943,54 @@ transition: none; } } + +.content { + gap: clamp(64px, 8vw, 112px); +} + +.heroCopy { + background: linear-gradient(135deg, rgb(8 10 12 / .72), rgb(8 10 12 / .64)); +} + +.sectionIntro, +.aboutLead { + width: 100%; + padding: 0 0 24px; + border: 0; + border-radius: 0; + color: var(--site-text); + background: none; + box-shadow: none; + text-shadow: none; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.sectionIntro > p, +.aboutLead > p { + color: var(--site-muted); +} + +.aboutLead { + grid-column: 1 / -1; +} + +.moreWork { + grid-column: auto; + grid-template-columns: 1fr; + align-content: start; +} + +.contactSection { + margin-top: 0; +} + +@media (max-width: 820px) { + .content { gap: 64px; } + .sectionIntro, .aboutLead { padding: 0 0 18px; } + .aboutLead, .moreWork { grid-column: auto; } +} + +@media (max-width: 600px) { + .header nav { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} diff --git a/components/concepts/signal-desk/signal-desk.tsx b/components/concepts/signal-desk/signal-desk.tsx index c867115..229de8f 100644 --- a/components/concepts/signal-desk/signal-desk.tsx +++ b/components/concepts/signal-desk/signal-desk.tsx @@ -236,11 +236,12 @@ function ProjectExplorer({ reduced, entrance, ready }: { reduced: boolean; entra export function SignalDesk() { const { ready, reduced } = useReducedMotionPreference(); + const [pondView, setPondView] = useState<"ascii" | "pixel">("ascii"); const entrance = ready && !reduced; return (
- +

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

@@ -257,6 +258,19 @@ export function SignalDesk() {
+ +
+ Pond view +
+ {(["ascii", "pixel"] as const).map((view) => ( +