krokodeal/quack
π¦
Commit 09d5420 Β· User Name Β· 2026-10-18T23:59:00+02:00
Comments
No comments yet.
Diff
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d6ccfe0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+.wrangler/
+*.log
+temp/
+public/quack-source.zip
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..107634c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 CELPHASE
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..333b811
--- /dev/null
+++ b/README.md
@@ -0,0 +1,107 @@
+# quack
+
+A tranquil 3-D duck pond in the browser, made for phones first. No UI, no HUD β
+the scene is the interface. Drag to look around, pinch to lean in, and tap the
+water to throw a piece of bread.
+
+quack is a port of [celphase's duck-pond](https://github.com/celphase/duck-pond) β
+a high-quality duck *audio* synthesizer written in Rust β from sound to sight.
+The ducks live the same lives: the time budget of dabbling, resting and preening,
+the depth bands they drift between, broods trailing their mother, a drake
+escorting his mate. The quacks are silent (no audio yet), but the calling model is
+here: the same call timers, social answers and brood recalls, spoken in posture
+and emitted as `CallEvent`s for the coming audio pass.
+
+## Run
+
+Node 20.19+ or 22.12+ (what Vite needs).
+
+```sh
+npm install
+npm run dev # dev server (Vite)
+npm run build # production build into dist/
+npm run preview # serve the build locally
+```
+
+Add `?seed=N` for a repeatable pond. The default seed is `20261018`.
+
+Production builds always write source maps. The fifth title card points at
+`SOURCE.src_url` in `src/config.ts`; while that is empty it downloads
+`quack-source.zip`, packed from the working tree by `scripts/source-zip.mjs`
+(credentials, build output and the vendored Rust clone stay out).
+
+## Controls
+
+| input | action |
+| --- | --- |
+| drag | look around β the head turns in place, clamped so the pond stays in view |
+| pinch / wheel | zoom β open the view wide, or lean in close (clamped) |
+| tap / click | throw bread β it leaves your hand on release |
+| a press that drifts past 12 px | that was a camera drag, not a throw |
+| cursor (fine pointer) | every duck's head follows it across the screen |
+
+Hidden conveniences for whoever opens the console: `space` pauses, `T` runs the
+clock at a quarter speed, `Y` skips a season, and `1`β`4` pin the year at
+spring / summer / autumn / winter.
+
+## The pond
+
+- Twelve mallards: hens **Mabel**, **Clover** and **Hazel**; drakes **Rufus**
+ (Clover's mate) and **Bramble**, on his own; and two broods β Mabel trailing
+ Pip, Dot, Bean and Fig, Hazel with Nib, Tuft and Sprout.
+- A full year passes in 28 minutes, seven minutes to a season, each blending
+ into the next. The sun wanders and the light warms and cools; winter freezes
+ the pond from every edge inwards, but for one patch of open water at its
+ middle β the lead β which the flock gathers to and keeps open all season, and
+ out onto the ice of which they will walk for bread.
+- The flock runs duck-pond's activity model β swim, dabble, rest (and sleep:
+ head turned onto the back, bill tucked in but still visible), preen, stray.
+ Ducklings follow their mother and wait when they lose her; a drake keeps
+ near his mate.
+- Bread: one piece per throw, two or three bites each, and a stretch of
+ begging where the bread landed until the ducks give up and drift back to
+ their day. Bread nobody comes for sinks away.
+- You stand at the water's edge on the near bank. The ground climbs clear of the
+ water, so the shore is a border at your feet β tilt down and it is right there β
+ and `World.shore` marks out the walkable stretch of it (130 m each way, 18 m
+ deep).
+
+## Layout
+
+```
+index.html entry point: canvas host, loading card, error card
+src/main.ts boot, frame loop, input (tap-throw, hidden keys), debug handle
+src/config.ts every tuning dial, in one export per scope
+src/world.ts terrain, water, sky, shore, forest, grass, season paint
+src/seasons.ts the year: four keyframe seasons, always blending
+src/pond.ts the flock: roster, time budget, families, bread and begging
+src/duck.ts duck rig and poses
+src/camera.ts first-person rig: a planted eye, turning and zooming
+src/effects.ts ripples, splashes, dust and sinking particles
+src/hints.ts the title cards: controls, credit, source
+src/utils.ts RNG, value noise, easing, geometry helpers
+vite.config.ts production build shape: source maps always on
+public/ loading-card stills, generated source zip
+scripts/shot.mjs puppeteer screenshot harness used while tuning
+scripts/sample.mjs reads a shot back as a colour column, for tuning
+scripts/source-zip.mjs packs the source-card fallback from the working tree
+scripts/loader-shot.mjs re-shoots the card's backdrop and packs it to JPEG
+docs/port-notes.md port decisions, tuned constants, debug recipes
+docs/deploy.md build, deploy, rollback, loading-card re-shoots
+```
+
+Two folders on the side, deliberately kept out of this repo's history:
+
+- `duck-pond/` β the vendored Rust clone and the study notes behind the port.
+- `temp/` β scratch space (screenshots and such), git-ignored.
+
+## Credits
+
+- **[duck-pond](https://github.com/celphase/duck-pond)** by celphase (MIT):
+ the behavior model, time budgets, family rules and speeds are ported from
+ its `scene.rs`.
+- **[three.js](https://threejs.org)** (MIT): everything you see.
+
+## License
+
+MIT β see [LICENSE](LICENSE).
diff --git a/docs/behavior-divergence.md b/docs/behavior-divergence.md
new file mode 100644
index 0000000..7f3f481
--- /dev/null
+++ b/docs/behavior-divergence.md
@@ -0,0 +1,748 @@
+# Behaviour divergence: quack vs duck-pond
+
+A line-by-line comparison of the behaviour model in **quack** (the 3-D
+TypeScript pond, `src/pond.ts`) against **duck-pond** (the 2-D Rust audio
+pond, `duck-pond/src/scene.rs`), asking three questions:
+
+1. What does the Rust model do that quack **has not ported** yet?
+2. Where has quack taken **creative freedom** β invented behaviour the Rust
+ never had?
+3. Where does the behaviour **differ because of the two things quack has that
+ the Rust cannot**: the **flight** animation and the **winter** freeze?
+
+`docs/port-notes.md` is the working record of *how* the port was done. This
+file is the *balance sheet* β what came across, what did not, what was added,
+and what changed for a reason.
+
+Both projects are read-only for this document; nothing was modified.
+
+**Update.** The calling model has since been ported as behaviour: the nine
+intervals, sleeping suppression, hen lottery, brood calls and one-hop answer
+chain now live in `src/pond.ts`, tuned by `CALLS` in `src/config.ts`. The
+synthesis, propagation and spatialization are still absent, so calls are silent
+head/bill gestures plus `CallEvent`s on `Pond.onCall` for the audio core to
+come. Sections 2.1β2.4 and 4.4 have been rewritten to record what closed; the
+remaining audio gap is Β§2.5 and the dropped appendix. Line numbers in those
+rewritten sections are from the tree before the call port.
+
+**Conventions.** quack's own files are always written with their `src/` or
+`docs/` prefix (`src/pond.ts:611`). The Rust originals are quoted by their
+bare file name β `scene.rs`, `synth.rs`, `main.rs` β and always mean the
+vendored copy under `duck-pond/src/`. Line numbers are from the working tree
+at the time of writing (`src/pond.ts` 1643 lines, `src/duck.ts` 1423,
+`src/config.ts` 538, `src/world.ts` 1330, `scene.rs` 1494). This file uses the
+British "behaviour" the quack source uses; the Rust source uses "behaviour"
+too in its comments, and the Rust *docs* use "behavior" in the README.
+
+---
+
+## 1. The scorecard
+
+| | duck-pond (Rust, 2-D, audio) | quack (TypeScript, 3-D, visual) |
+| --- | --- | --- |
+| Activities | 6: `Swim Dabble Rest Preen Stray Feed` | 8: `swim dabble rest preen stray feed` **+ `beg` `fly`** |
+| Sub-actions (poses) | none β a duck is a letter in a map | 12: `sleep stretch look idle preen peck dabble upend drink chirp beg` (+ `startle`, dead) |
+| Calling model | 9 inter-call intervals, a 4-way call lottery, a social flag, a one-hop answer chain | **ported** β the same timing, lottery and chain, silent gestures and `CallEvent`s for the missing synth |
+| Food | 12β20 peas per throw, one peck each | one crust per throw, 2β3 bites, plus begging |
+| Water | one medium, deep enough to swim | three: swim (0.3 m), wade (0.05 m), walk the ice lid (0.5 frozen) |
+| Year | none β a timeless breeding pond | four seasons, 28 min a year, ice from every edge |
+| Locomotion | swim, waddle | swim, waddle, **lap**, **fly** |
+| Depth bands | 3β10 / 10β25 / 25β46 m from the listener | world-z bands 22β38 / 0β22 / β38β¦4 |
+| Personal space | a 0.6 m radius nudge for adults | a per-duck ellipse hull, family vs stranger gaps, and a hard separation pass |
+| RNG | ChaCha12 (`StdRng`), `seed_from_u64` | mulberry32, `seed ^ 0x51ab`, plus a private stream per duck |
+
+The **decision structure came across essentially intact**. Every transition
+weight, every bout length, every family rule and every speed constant is
+present in the TS with the same value. The timing of the *vocal* half is now
+present too; only its sound is missing. What is new is a whole visual and
+seasonal layer.
+
+---
+
+## 2. Part A β The calling model and remaining gaps
+
+### 2.1 The calling model β ported, silent for now
+
+The Rust's `Scene::call()` (`scene.rs:1189-1292`) is roughly a third of the
+whole simulation. **Its timing half is now ported** into `src/pond.ts`
+(`tickCalls`, `callInterval`, `call`, `showCall`), tuned by `CALLS` in
+`src/config.ts`. What remains absent is the synthesis, propagation and
+spatialization: quack turns each call into a silent head/bill gesture and emits
+a `CallEvent` β `{ kind, label, notes, duration, social, answering }` β on
+`Pond.onCall`, with the caller attached.
+
+The ported pieces:
+
+| Rust behaviour | Where | Status in quack |
+| --- | --- | --- |
+| Nine inter-call intervals, armed in precedence order | `scene.rs:1201-1211` | **ported** β `CALLS.interval`, same precedence |
+| Sleeping ducks swallow 80β90 % of their calls | `scene.rs:1213-1216` | **ported** β `CALLS.sleepSkip` |
+| The hen's call lottery: 45 % single quack / 25 % social series / 20 % decrescendo / 10 % chatter, with a 60 % feeding branch in front of it | `scene.rs:1219-1249` | **ported** β same branches, `CallKind` names |
+| The `social` flag β only the series and the decrescendo can set others off | `scene.rs:1241, 1244` | **ported** β `CallEvent.social` |
+| The answer chain: mate p = 0.6, others `0.3Β·(1 β dist/40)`, within 40 m, one hop only | `scene.rs:1278-1291` | **ported** β `CALLS.answer`, `event.answering` closes the chain |
+| A resting duck woken by a neighbour answers | `scene.rs:1198` | **ported** β `answering` overrides the sleep suppression |
+| The drake's "mate out of reach" rule (8 m, 8β20 s instead of 45β180 s) | `scene.rs:1196, 1207-1208` | **ported** β `CALLS.mateAway`, `CALLS.interval.drakeAway` |
+| The hen's brood call and its 3.7 notes/s rate | `scene.rs:1229-1233`, `synth.rs:518-530` | **ported** β `CALLS.broodNotes`, `CallEvent.duration` |
+
+`CallEvent.duration` uses the same note-count formulas as `synth.rs`, so the
+future audio core can start a call at the right moment and fill in the same
+envelope. The old decision from `docs/port-notes.md` still holds for the sound
+itself β *"deliberately not ported for v1: the whole audio stack"* β but the
+timing, social chain and brood recall are behaviour, and they are now in the
+pond. The mirror rule (Β§3.12) is no longer the only source of the pond's
+lively-and-drowsy stretches.
+
+### 2.2 `gathering` β now read by the brood call
+
+`gathering` is set when a hen with young starts a swim bout, and it is now
+read by the brood branch of `call()`. A gathering hen gives the 3β6-note
+`kwek-kwek`; a hen with lost young gives the 6β10-note version. Both cue any
+*stray* duckling back to her. The flag is cleared when the call fires, so one
+gathering makes one call.
+
+The visible gesture is the same silent `chirp` head/bill lift used by every
+call. It deliberately does not borrow the `beg` pose: `beg` is an activity
+pose, and starting it from the call would interrupt whatever the hen was
+really doing.
+
+### 2.3 Two duckling states β both present
+
+The Rust distinguishes two duckling calls with quite different meanings
+(`scene.rs:1255-1256`):
+
+- **`peep peep`** β loud distress whistles, only when the duckling is *lost*
+ (`kind: 'peeps'`, repeated every 0.2β1 s);
+- **`pip-pip`** β soft contact notes, otherwise (`kind: 'contact'`, 2β5 notes,
+ every 4β15 s, or 1.5β5 s while feeding).
+
+The old random 0.3 Hz lost chirp in `updateRig` has been removed; the call
+scheduler now owns the gesture, so a content duckling is no longer silent and a
+lost one peeps at the Rust's pace.
+
+### 2.4 The duckling "peep β mother answers β return" chain β complete
+
+The Rust's chain is: a lost duckling peeps β the mother's call timer is cut to
+0.5β2.5 s (`scene.rs:1265-1269`) β she gives a brood call β every *stray*
+duckling is cued back to her (`scene.rs:1270-1277`).
+
+All three steps are now in `call()` and `tickCalls()`. A lost peep shortens the
+mother's timer, her next call is forced into the brood branch by `lostYoung`,
+and the stray callbacks run while the call is being made. The separate
+feed-time `cue` remains as a safety net; the call is now the normal way home.
+
+### 2.5 The dabbling splash rhythm and the bill clack
+
+The Rust schedules a **sound** for dabbling β a 2β7 s patter of tiny splashes
+every 3β15 s while an adult feeds on the water (`scene.rs:884-886`,
+`950-960`) β and a clack-and-swallow when a duck eats a pea
+(`scene.rs:897-903`). These are audio, so they cannot port directly.
+
+But the Rust's dabbling rule carries a behavioural rule that *is* portable and
+was not carried: **only a duck on the water dabbles audibly; a duck grazing
+ashore is silent** (`scene.rs:884`). The TS reproduces the *visual* half
+correctly β a duck on the bank pecks at the grass rather than dabbling
+(`src/duck.ts:892-897`) β so this one is covered, but by a different mechanism.
+
+### 2.6 The Rust's "restore" bug β and the port's fix
+
+This is the one place where the two projects **disagree about the original's
+behaviour**, and quack is right.
+
+The Rust's `stop_feeding` (`scene.rs:699-717`) does **not** restore what a
+duck was doing before the peas arrived. A leader gets a brand-new activity
+(50 % Dabble / 25 % Rest / 25 % Preen) with a fresh 60β180 s bout; a follower
+is re-cued from its leader. `README.md:29` in the Rust project claims "Once the
+peas are gone, they go back to what they were doing", and that claim is false
+β a discrepancy documented in `duck-pond/behaviors.md` Β§7.1.
+
+quack **does** restore it:
+
+```ts
+// src/pond.ts:1056-1069
+private resumeAfterFeed(d: Duck): void {
+ const pre = d.preFeed;
+ d.preFeed = null;
+ ...
+ if (pre && pre.activity !== 'feed' && pre.activity !== 'beg') {
+ d.setActivity(pre.activity);
+ d.activityLeft = this.boutLength(pre.activity);
+ } else {
+ const r = this.rng.next();
+ const a: Activity = r < 0.5 ? 'dabble' : r < 0.75 ? 'rest' : 'preen';
+ d.setActivity(a);
+ d.activityLeft = this.rng.range(FLOCK.bout.afterFeed.min, FLOCK.bout.afterFeed.max);
+ }
+```
+
+`d.preFeed` is captured when a duck notices the bread (`src/pond.ts:624`) and
+restored here, with the Rust's 50/25/25 lottery kept as the fallback for a duck
+that was not doing anything in particular. The port matches the README and the
+original does not. **The Rust is the one that should be fixed.**
+
+### 2.7 Things the Rust has that were *replaced* rather than dropped
+
+These are not gaps so much as substitutions β the TS reached for a different
+mechanism for the same job.
+
+| Rust | quack's replacement |
+| --- | --- |
+| `PERSONAL_SPACE = 1.0` m from the listener (`scene.rs:79-80`) | a per-duck ellipse hull with family and stranger gaps (`src/config.ts:453-464`, `src/pond.ts:1097-1183`) |
+| Metric distance bands, 3β10 / 10β25 / 25β46 m (`scene.rs:95-99`) | world-z bands, `near` 22β38 / `mid` 0β22 / `far` β38β¦4 (`src/config.ts:155-157`) β a different axis entirely, because the listener is a stand point in a 3-D scene, not an origin |
+| Adults separate within 0.6 m (`scene.rs:1121-1128`) | `SPACE.reach` 1.3Γ the hull distance, up to 0.28 m/s, plus two hard settling passes (`src/pond.ts:1127-1183`) |
+| `bank_point` on a 18 Γ 7.5 m strip (`scene.rs:77-78, 117-124`) | a 52 m-wide beach, z 46.6β51.5, at least 2 m (5 m for a hen with young) from the thrower (`src/config.ts:143`, `src/pond.ts:562-569`) |
+| Two regions: `in_pond` and `on_bank` (`scene.rs:82-93`) | three media: swim, wade, walk the lid (`src/pond.ts:132-148`, `src/config.ts:171`) |
+| The shore-slide fallback (full / x / y / tangential step, `scene.rs:1166-1180`) | **ported** β the same four-step fallback (`src/pond.ts:1317-1344`) |
+| `stop_feeding` for a follower β `cue(i)` (`scene.rs:701-704`) | `resumeAfterFeed` β `followLead` (`src/pond.ts:1072`) |
+
+### 2.8 The RNG
+
+The two ponds are **not** bit-compatible, and cannot be. The Rust uses
+`rand`'s ChaCha12 via `StdRng::seed_from_u64` (`main.rs:140`);
+quack uses mulberry32 (`src/utils.ts:56-93`) seeded with `seed ^ 0x51ab`
+(`src/pond.ts:66`), with a private stream derived per duck
+(`src/duck.ts:726`).
+
+This is not a porting failure β it is unavoidable, since the two programs draw
+different numbers of randoms per frame (quack spends draws on laps, flights,
+bread, begging and the sub-action layer; the Rust spends them on calls,
+bubbles and the water's normal samples). But it has one consequence worth
+recording: **quack's per-duck private streams make the roster order
+load-bearing.** Change anything drawn before the ducklings and every later
+duck's spawn position and body scale moves (`src/pond.ts:111-116`).
+
+There is also one unseeded escape hatch: `between(min, max)` uses raw
+`Math.random()` (`src/utils.ts:15-18`), used for three cosmetic bread-ring
+values (`src/pond.ts:1466, 1555, 1559-1560`). These never feed back into a
+duck decision, but they mean a seeded quack is not perfectly reproducible
+frame-for-frame.
+
+---
+
+## 3. Part B β Creative freedom
+
+Everything in this part is behaviour quack has that duck-pond never had. Some
+of it is invention; some of it is the visual layer doing work the Rust left
+to the audio layer.
+
+### 3.1 Two new activities
+
+**`beg`** (`src/duck.ts:15`) β there are two distinct kinds, and they are
+good:
+
+- **The duck that got a bite and wants more.** `stopFeeding` sends any duck
+ that ate and finds the pond empty into `beg` at the spot of its last bite
+ for 25β40 s (`src/pond.ts:1037-1054`). This is the behaviour the Rust's
+ `stop_feeding` *comment* gestures at β *"though ducks on their own hang about
+ for a while in case more come"* (`scene.rs:697-698`) β but the Rust never
+ implements it. quack does.
+- **The curious visitor**, Β§3.5.
+
+**`fly`** (Β§4) β a duck on the wing.
+
+### 3.2 The sub-action layer
+
+The Rust's ducks have a position, a velocity and an activity. quack's ducks
+have a **third level**: a pose, re-rolled every few seconds, that carries the
+body.
+
+`Duck._nextAction()` (`src/duck.ts:875-929`) is a dice table per activity:
+
+| activity | sub-actions and their chances |
+| --- | --- |
+| `rest` | `sleep` 0.5 (20β70 s) / `stretch` 0.12 (2.6 s) / `look` 0.18 (4β12 s) / `idle` 0.2 (3β10 s) |
+| `preen` | `preen` 0.68 (6β18 s) / `stretch` 0.10 / `look` 0.12 / `idle` 0.10 |
+| `dabble`, afloat, adult | `upend` 0.26 (3.6β5.8 s, only where the water is over 0.8 m) / `dabble` 0.34 (2β7 s) / `drink` 0.15 (2β4 s) / `idle` 0.25 |
+| `dabble`, afloat, duckling | `drink` 0.35 (1.6β3 s) / `look` 0.15 / `idle` 0.5 β "picking insects off the surface" |
+| `dabble`, not afloat | `peck` 0.5 (2β5 s) / `look` 0.25 / `idle` 0.25 β grazing the bank, or the ice |
+| `swim` / `stray` | `look` 0.2 (3β8 s) / `drink` 0.04 / `idle` 0.76 |
+| `feed` | `idle` only (1.5β5 s) β "making for food: there is no time for sightseeing" |
+| `beg` | `beg`, with a duration of `1e9` so it never expires on its own |
+
+### 3.3 `upend` β bottom feeding, which the Rust never modelled
+
+The Rust's `Dabble` covers sieving the surface and grazing, and adds a
+*splash sound*; it never has a duck stand on its head. quack does, as a
+whole-body pivot about the waterline (`src/duck.ts:1009-1039`): a 0.8 s ramp
+over, a hold, a 0.75 s ramp back, the feet kicking at 7.5 rad/s, the stern
+wagging, and a ring-and-droplet burst at each end. The body tips by
+`DUCK.upend.pitch` = 1.62 rad, "a little past vertical" (`src/duck.ts:1368-1372`).
+
+This is a genuine addition, and an ecologically correct one β upending is a
+core mallard feeding behaviour, and the Rust's model has no way to express it
+because there are no bodies.
+
+### 3.4 The waddle
+
+The whole leg solve (`src/duck.ts:153-168`, `1296-1324`) has no analogue at
+all. One hinge per leg at the hip, the stance ramp as an arcsine, the planted
+sole holding its spot, the body riding whichever leg is down, the ankle
+cancelling the hinge, the lean onto the planted foot and the stern's
+answering wag. The cadence falls out of the speed rather than being keyframed,
+so a hen at 0.45 m/s waddles at 1.32 cycles/s and a duckling at its shorter
+legs waddles faster.
+
+### 3.5 The curious visitor
+
+`src/pond.ts:925-930` β at a bout end, a duck in the water within 46 m of the
+stand, with no bread down, may decide to come and look at you:
+`CURIOUS.chance` 0.08, at most one at a time, 150β420 s apart. It crosses at a
+purpose, then stands about in the shallows in front of you for 25β60 s with
+its head up and the odd imploring bill-open β **begging at nothing**.
+
+This is the single most quack-native idea in the port. It is a *reading* of
+the human, not a reaction to food, and it gives the viewer a relationship with
+the flock that the Rust β which has a listener but no visible one β never
+had.
+
+### 3.6 The lap of the lake
+
+`src/pond.ts:331-395` β a duck that would settle to the nearest pastime
+sometimes goes off **around** the water instead: `LAP.chance` 0.14 of bout
+ends. The circle is drawn fresh through the bird's own position each time, so
+the whole bout *is* the round; a random radius (4.5β9.5 m), a random way
+round, and two incommensurate harmonics bowing the path in and out by up to a
+quarter of the radius. `stepLap` walks the mark 3.5 m ahead along the arc, so
+the line the bird swims bows the way the lap does. 0.55β1.5 turns a bout, and
+30 % of them swing out of the picture and back.
+
+### 3.7 The sight cone
+
+`src/pond.ts:176-183` β a duck with its head up sees a landing spot within
+55 m and inside 1.15 rad of its bill, and sets off for the splash at 25β70 %
+of the piece's flight time, so it is *already moving* before the crust lands.
+Everyone else only hears it, and comes late.
+
+The Rust can only model the hearing half: peas are *heard*, at
+`U(0.3,1.5) + dist/12` seconds (`scene.rs:684`). The sight cone is a
+behavioural rule the 2-D original had no way to express, because a 2-D duck
+has no bill and no line of sight.
+
+### 3.8 The cursor gaze and the camera
+
+`src/pond.ts:1580-1602` and `src/duck.ts:1202-1211` β every duck pans and
+tilts for the pointer, shaped with `tanh` so the middle of the screen is
+gentle and the corners reach the full limit (yaw 1.55 rad, pitch 0.72 rad). A
+duck that is walking, sleeping, preening, dabbling or pecking ignores the
+cursor; only a swimming or loafing, idle/looking/begging duck tracks it. And
+when it is making for food, the gaze locks dead ahead β the food overrides the
+cursor.
+
+### 3.9 Personal space: hulls and hard separation
+
+`src/pond.ts:1097-1183`. Every duck carries an ellipse hull (0.31 m long,
+0.18 m wide, in body-scale units, so a drake is a bigger obstacle than a
+duckling) plus a gap: 0.03 m inside a family, 0.10 m between strangers. It is
+a nudge first β a soft push from 1.3Γ the hull distance out, at up to
+0.28 m/s β and then `Pond.separate()` runs two passes over every pair,
+shares out any overlap and takes the closing speed out of the pair, so the
+next frame cannot drive them straight back in. A sleeper or an upended duck
+takes a quarter share and is given no new velocity at all.
+
+The Rust has only the first half: adults push apart within 0.6 m
+(`scene.rs:1121-1128`), and there is no hard pass, so two ducks in the Rust
+can overlap.
+
+### 3.10 The out-of-view rules
+
+`src/pond.ts:161-164, 186-190, 921-924` β a duck that ends a bout outside the
+camera's wedge is sent back to its band's water with p = 0.8, and a pastime
+already running out there is cut to 50 s. `dabble` is offered only in water
+inside the wedge, which was the leak that let a dabbler drift over the line
+and then hold position out there for a whole bout.
+
+This is a **camera rule masquerading as a behaviour rule**, and it is worth
+naming as such: the Rust's pond is a circle around a fixed listener, so the
+whole pond is always in view and the question never arises. quack has a
+first-person camera that pans and zooms, so it has to decide what "off
+screen" means for a bird.
+
+### 3.11 Bread: a different food economy
+
+The Rust's food is a handful of peas: 12β20 at a time, all landing within
+Β±35Β° at 2β7 m, each eaten in one peck, sinking after 180 s
+(`scene.rs:666-678, 897-903`).
+
+quack's is one crust at a time, thrown by hand, with a much richer lifecycle:
+
+- the throw leaves the hand 1.2 m along the view direction, 1.4 m down
+ (`src/main.ts:245-255`), arcs over `clamp(1.0 + 0.06Β·dist, 1.2, 5.5)` m, and
+ lands in `clamp(0.45 + 0.04Β·dist, 0.6, 1.9)` s;
+- the landing is clamped into the chase region β open water, shallows, the
+ ice, or the bank β and skewed off the cursor if it lands on top of existing
+ bread, re-rolled until it clears every piece by 0.24 m
+ (`src/pond.ts:1406-1432`, `src/config.ts:384-389`);
+- it takes 2 or 3 bites, shrinking to 80 % each time
+ (`src/pond.ts:1076-1088`), and every duck that saw or heard it comes;
+- it floats with a little ring every 1.4β4.2 s so it stays findable without
+ shouting like the splash it landed with β but a piece on the lid lies still
+ and does not ring at all (`src/pond.ts:1552-1563`);
+- unclaimed, it shrinks away over 4 s once it is 100 s old
+ (`src/pond.ts:1565-1569`).
+
+### 3.12 The mirror rule β ported, and it matters
+
+`src/pond.ts:886-891` keeps the Rust's 0.35 chance for a duck to copy a
+leaderless neighbour's activity, sampling only ducks with no leader of their
+own, and only for `dabble`, `rest` or `preen`. This is the Rust's
+`others_doing` rule (`scene.rs:1063-1066`), verbatim. It is no longer the
+*only* mechanism producing the pond's "lively and drowsy spells": the calling
+chain (Β§2.1) is back beside it.
+
+Measured on the Rust: the four leaderless ducks share an activity 11.5 % of
+the time against a 4.9 % chance baseline β a 2.3Γ effect, modest but real
+(`duck-pond/behaviors.md` Β§3.8).
+
+### 3.13 The `startle` pose that never fires
+
+`src/duck.ts:963-966` and `1145-1154` implement a full startle β *"a whirr
+that swings from the waterline up over the back, never down through the
+surface the duck is sitting on"* β and it is wired into the wing-visibility
+test at `src/duck.ts:1260`. But `startAction('startle'` appears **nowhere**.
+It is dead code inherited from the bird rig the port came from.
+
+It is still unwired. The new `CallEvent`s give it an obvious trigger later β a
+nearby answer could startle a duck once there is a loudness model β but without
+audio there is no "loud" yet, so the pose stays dormant rather than firing at
+random.
+
+---
+
+## 4. Part C β Changed by the flight
+
+The Rust has **no flight at all**: no wings, no `fly` activity, and the only
+thing that is ever "airborne" is a pea in the air (`scene.rs:187, 676-677`).
+A duck in duck-pond never leaves the water.
+
+quack's flight is a whole subsystem, and it changes behaviour in six ways.
+
+### 4.1 The flight itself
+
+`FLIGHT.chance` 0.18 of bout ends, scaled by `WINTER.bouts` 0.55 in a freeze
+(`src/pond.ts:915-916`), become a low pass over the water: 16β34 m long
+(`WINTER.flightReach` 5β14 m in winter), 4.2β8.5 s in the air, riding
+0.8β1.45 m Γ body scale over the surface at its peak, with the landing
+re-rolled until it is clear of the ducks already there and within 40 m of the
+thrower (`src/pond.ts:965-978`, `src/config.ts:310-328`). A bird that has just
+come down stays down for 20β45 s, so flights never chain into a shuttle.
+
+### 4.2 A flying duck leaves the flock's economy
+
+`src/pond.ts:1190` skips the flying duck in the steering pass entirely, and
+`src/pond.ts:1188` excludes it from the hull, the ripple wake, the cursor gaze
+and unclaimed bread. Nothing on the water may nudge it off. It is a bird
+above the flock, not of it.
+
+### 4.3 Who is allowed to fly
+
+```ts
+// src/pond.ts freeWing()
+/** Ducks that follow no one and have no brood fly and roam alone. */
+private freeWing(d: Duck): boolean {
+ return !d.leader && d.kind !== 'duckling' && this.young(d).length === 0;
+}
+```
+
+That admits **Clover** and **Bramble** only:
+
+| duck | may fly? | why |
+| --- | --- | --- |
+| Mabel | no | has a brood |
+| Hazel | no | has a brood |
+| Clover | **yes** | no leader, no brood |
+| Bramble | **yes** | no leader, no brood |
+| Rufus | no | has a leader (Clover) |
+| the 7 ducklings | no | `kind !== 'duckling'` |
+
+The earlier comment said "nobody to keep station on", which reads as *who
+follows you*, though the test is `leader` β who you follow. It now says
+"follows no one", which is what the code means. Clover's drake still keeps
+station on **her**; she is the one flying.
+
+### 4.4 The CloverβRufus wrinkle
+
+Because `cue()` copies the leader's activity unless the leader is feeding,
+straying or begging (`src/pond.ts:785-786`), and `fly` is not on that list,
+Clover taking off **cues Rufus into `fly`** β an activity he can never
+perform, because `freeWing` excludes him.
+
+He recovers on the next frame: `case 'fly': if (!d.flying) this.nextActivity(d)`
+(`src/pond.ts:706-709`) re-picks him a fresh activity. So the visible effect is
+that Rufus's activity label reads `fly` for a frame or two, and β more
+substantively β **Rufus re-rolls his whole decision the moment his mate takes
+off and again when she lands** (`landFlight` calls `nextActivity`,
+`src/pond.ts:469`). A mated drake therefore re-plans his day on his mate's
+flight schedule rather than on his own.
+
+**Fixed.** `cue()` now treats `fly` like `feed`, `stray` and `beg`: a follower
+whose leader takes off is mapped to `swim`, so Rufus never wears an activity he
+cannot perform. The alternative β making `freeWing` test "does anything follow
+me" β was rejected because it would ground Clover, the one bird whose
+independent flight is part of the pond's character.
+
+Rufus still keeps station on Clover while she is away and picks up her
+post-flight activity when she lands. That is the follower rule doing its job,
+not an impossible `fly` re-roll.
+
+### 4.5 Flying for bread
+
+`src/pond.ts:536-560` β a free duck that sees a piece of bread 15 m or more
+away may take the wing for it, the chance rising linearly to 0.55 at 42 m. It
+comes down in the water 1.4β2.6 m off the piece on a random bearing and
+paddles the last of the way, so it never lands on its own mouthful, and only
+one bird flies for a piece β the rest swim, which is what keeps a far throw
+from emptying the pond into the air.
+
+### 4.6 The flight changes the picture rules
+
+A duck on the wing is not in the water, so it is not "in frame"
+(`src/pond.ts:162-164` requires `inPond` and not on the lid), and the lap's
+wedge test skips the winter case (`src/pond.ts:352`). The flight is
+therefore the one behaviour that can take a bird out of the picture on
+purpose β and `FLIGHT.far` = 40 m of the thrower is the constraint that keeps
+it readable.
+
+---
+
+## 5. Part D β Changed by the winter
+
+The Rust has **no seasons, no ice, no snow and no year** β a grep for
+`season|winter|ice|snow` across `duck-pond/src/` returns nothing but
+incidental matches inside `voice` and `f64`. duck-pond is a timeless
+breeding-season pond.
+
+quack's year is four keyframe seasons, 7 minutes each, 28 minutes round
+(`src/seasons.ts:43-114`, `src/config.ts:119`). The **only** variable that
+reaches the behaviour model is `world.ice` (`src/world.ts:495-498`); `snow`,
+`wind`, `leaf`, `flower` and the light are consumed by the paint and the
+particle systems alone. The winter test is `world.ice > WORLD.ice.season`
+(0.3), and autumn's `ice: 0.08` never reaches it.
+
+**So the behaviour model has exactly two regimes: open water, and freeze.**
+There is no behavioural difference between spring, summer and autumn.
+
+### 5.1 The twenty seasonal rules
+
+| # | rule | summer | winter | where |
+| --- | --- | --- | --- | --- |
+| 1 | where the flock lives | the depth bands | **the lead** | `src/pond.ts:237-255` |
+| 2 | roam target | anywhere in the ellipse | inside `0.95 Γ r0` of the lead | `src/pond.ts:280-303` |
+| 3 | minimum roam distance from self | 14 m | 5 m | `src/pond.ts:281` |
+| 4 | lap radius | 4.5β9.5 m | 3β5.5 m | `src/pond.ts:333-336` |
+| 5 | lap / flight chance multiplier | 1 | **0.55** | `src/pond.ts:900` |
+| 6 | flight reach | 16β34 m | 5β14 m (a hop over the lead) | `src/pond.ts:969-970` |
+| 7 | a bid to dabble | stays a dabble | **half become loafing** | `src/pond.ts:960` |
+| 8 | a swim bout bound for the lid | β | 4.5 % of them | `src/pond.ts:238-252` |
+| 9 | a spell ashore | 35β90 s | 15β45 s on the lid | `src/pond.ts:155-159` |
+| 10 | the water pulls at a bout end | 0.75 | 0.85 | `src/pond.ts:910` |
+| 11 | a visit ashore | 0.35, or 0.5 for a hen with young | both halved | `src/pond.ts:997-999` |
+| 12 | frozen water | swimmable | **not** swimmable β the duck walks on it | `src/pond.ts:133-135` |
+| 13 | the ice | β | a legal place to stand | `src/pond.ts:147` |
+| 14 | a duck on the lid | β | out of the picture | `src/pond.ts:162-164` |
+| 15 | a dabbler on the lid | dabbles | **grazes** β pecks at the ice | `src/duck.ts:892-897` |
+| 16 | upending | allowed in water over 0.8 m | never on the lid | `src/duck.ts:903` |
+| 17 | a flight landing | a splash | a puff of snow | `src/pond.ts:463-468` |
+| 18 | a nibble | a ripple | dust, and no water stirred | `src/pond.ts:1085-1086` |
+| 19 | bread landing | a splash | dust | `src/pond.ts:1531-1536` |
+| 20 | bread on the lid | floats, bobs, rings | grounded, still, silent | `src/pond.ts:1542-1563` |
+
+### 5.2 The lead
+
+`WORLD.lead` = centre (β3.5, 29), r0 = 8.5 m, r1 = 11.5 m, rim wobbled by
+Β±1.85 m in two integer harmonics (`src/config.ts:180-183`,
+`src/world.ts:25-32`, `465-477`). The ice closes on it from every edge; the
+lead itself never closes.
+
+In a freeze the depth bands are **abandoned entirely**: `pondPoint` sends the
+flock to a disc of radius `0.9 Γ r0` = 7.65 m around the lead, in water
+deeper than 0.8 m and not frozen (`src/pond.ts:240-255`). The bands and the
+camera's wedge no longer describe where the flock may be.
+
+### 5.3 The ice is a third medium, not a texture
+
+`src/pond.ts:132-148` β a duck may be in the water deep enough to paddle, in
+the shallows (0.05β0.3 m, too shallow to paddle and shallow enough to walk
+through), or on the lid of the ice. The **steering is identical in all
+three**; only the allowed region and the footing change. The same waddle
+solve runs on the ice; the duck simply stands at the water level instead of
+on the swell (`src/duck.ts:1351-1362`).
+
+`Pond.stranded()` (`src/pond.ts:1381-1396`) is the safety net: a duck standing
+where nothing allows it any more β the freeze closed around it, the thaw took
+the lid from under it β is walked out to the nearest legal spot, because
+steering cannot free a duck that has nowhere to steer.
+
+### 5.4 The half-dabble rule, and why it is right
+
+```ts
+// src/pond.ts:957-960
+// a freeze leaves a dabble little to find under the surface: half the
+// bids to dabble become loafing, so the flock rests and preens its way
+// through the cold instead of poking at the same few inches of water
+if (this.world.ice > WORLD.ice.season && next === 'dabble' && this.rng.chance(FLOCK.winterDabbleRest)) next = 'rest';
+```
+
+This is the best single line of behaviour in the port. Mallards do not feed
+the same way all year β winter foraging is a different, much more
+opportunistic business β and this is the one place quack says so.
+
+---
+
+## 6. Part E β the "fly/winter" reckoning
+
+Two things are true at once, and it is worth separating them.
+
+**The Rust is right about its own world.** duck-pond is a breeding-season
+pond: three hens, two drakes, two broods, and no time axis. A timeless pond
+with a breeding flock is a coherent thing to be. Nothing is wrong with it.
+
+**quack inherited that cast into a world where it stops being right.** quack
+has a year. And in a year, the flock that duck-pond models is a flock that
+should not exist in January.
+
+### 6.1 What quack's winter gets right
+
+The *behaviour* of quack's winter is better than the Rust's absence of
+winter, and in places better than a naive reading of the Rust:
+
+- **Gathering at open water.** The lead is exactly what wintering mallards
+ do: they concentrate on the last open water. The Rust has no analogue
+ because it has no winter.
+- **Walking on ice and snow.** Real, and modelled down to the swell being
+ pinned on the lid while the open pool still answers the clock
+ (`src/world.ts:453-463`).
+- **Feeding less.** The half-dabble rule (Β§5.4) is ecologically sound.
+- **Shorter spells on the ice, and more of a pull back to the water**
+ (`SHORE.iceStay` 15β45 s against `SHORE.stay` 35β90 s; `returnFromIce` 0.85
+ against `returnToWater` 0.75).
+- **Boldness.** The curious visitor begging at nothing is a *wintering*
+ behaviour β mallards at urban and park ponds learn to approach people, and
+ they are bolder in winter than in the breeding season. quack has this
+ behaviour all year, but it belongs to winter.
+
+### 6.2 What quack's winter gets wrong
+
+| # | the problem | where |
+| --- | --- | --- |
+| 1 | **The flock never changes.** The same twelve birds are on the pond in January and in July. | `src/pond.ts:76` β `buildFlock()` is called once and never again |
+| 2 | **Seven ducklings in January.** Pip, Dot, Bean, Fig, Nib, Tuft and Sprout are hatched in spring; by winter they are adults, or gone. The TS has seven ducklings standing on the ice with their mothers. | `src/pond.ts:105-117` |
+| 3 | **No migration at all.** A grep for `migrat` across `src/` finds two hits and both are *negations* β `// excursion rather than a migration out to the far water` (`src/pond.ts:989`) and *"not a migration"* (`docs/port-notes.md:266`). | β |
+| 4 | **No plumage change.** A drake in January wears eclipse plumage β drab mottled brown, not the green head. The TS's palettes are static constants (`src/duck.ts:250-336`); a drake in January looks exactly like a drake in June. | `src/duck.ts:250-294` |
+| 5 | **No departures.** In reality the brood goes south, or is shot, or simply does not make it. Nothing in quack ever leaves except by flying a lap and coming back. | β |
+| 6 | **The flock is a breeding flock, not a wintering flock.** Wintering mallards flock loosely and in numbers; a hen with a brood of four is a spring bird. The pair-and-brood structure is a breeding-season structure. | `src/pond.ts:83-118` |
+
+### 6.3 The options, honestly
+
+This is a design decision, not a bug, and there are three defensible answers.
+
+1. **Keep the cast and own it.** The pond is one year in a timeless place: the
+ same twelve birds, always. The `docs/port-notes.md:266` line β *"a visit is
+ one to three bouts β not instant, and not a migration"* β is already
+ half this argument. It should be said more plainly somewhere, because a
+ viewer who watches a year will notice that the ducklings never grow up.
+
+2. **Make the year a year.** Ducklings hatch in spring and are fledged by
+ autumn; in winter the flock is a different flock. This is the real answer
+ ecologically, and it is a large change: the cast has to become dynamic, the
+ plumage has to change with the season, and the breeding-brood structure
+ has to dissolve into a wintering flock.
+
+3. **Drop the winter, or drop the year.** If the cast is to stay fixed, the
+ ice is the thing that makes it visibly wrong, because ice is what says
+ "winter" to a viewer. The lead, the lid, the snow β the whole of Part D
+ would go, and with it quack's best seasonal behaviour.
+
+Of the three, option 1 is the chosen answer: quack is one year in a timeless
+place, with the same twelve birds throughout. That sentence now lives in
+`docs/port-notes.md`, where the winter notes can point at it. Options 2 and 3
+are both larger than they look.
+
+### 6.4 The smaller fly/winter point
+
+Flight and winter are entangled in one place: **`WINTER.bouts` = 0.55** scales
+both `FLIGHT.chance` and `LAP.chance` (`src/pond.ts:900, 916, 931`). The
+flock flies and swims about less in the cold, and the reason is the same for
+both β there is less to do and less to go for.
+
+That is the right way round, and it is worth noting that the Rust has no such
+coupling, because the Rust has no winter and no flight to couple.
+
+---
+
+## 7. Appendix A β the constant map
+
+### Ported with the same value
+
+| Rust | quack | value |
+| --- | --- | --- |
+| `HULL_SPEED` | `FLOCK.hullSpeed` | 0.71 m/s |
+| `DUCKLING_HULL_SPEED` | `FLOCK.ducklingHullSpeed` | 0.43 m/s |
+| `DUCKLING_BURST` | `FLOCK.ducklingBurst` | 1.26 m/s |
+| `BROOD_PACE` | `FLOCK.broodPace` | 0.3 m/s |
+| `LOST` | `FLOCK.lost` | 4.0 m |
+| `STRAY_EVERY` | `FLOCK.strayEvery` | 900 s |
+| swim bout | `FLOCK.swimBout` | 180 s |
+| dabble / rest bout | `FLOCK.bout.long` | 60β240 s |
+| preen bout | `FLOCK.bout.preen` | 30β120 s |
+| the mirror chance | `FLOCK.mirrorChance` | 0.35 |
+| the band re-roll | `FLOCK.bandChange` | 0.3 |
+| the arrive threshold | `FLOCK.arrive` | 0.7 m |
+| the cue divergences | hard-coded in `cue()` | 0.7 / 0.5 / 0.3 |
+| the call intervals | `CALLS.interval` | 0.5β8 s first; 0.2β180 s between calls |
+| the answer chain | `CALLS.answer` | mate 0.6, others `0.3Β·(1 β dist/40)`, within 40 m |
+| the transition table | hard-coded in `nextActivity()` | 0.65/0.85, 0.45/0.75, 0.4/0.75, 0.5/0.7 |
+| cruise speeds | `src/duck.ts:733` | 0.45 / 0.5 / 0.33 m/s |
+| the line behind the mother | `src/pond.ts:859-862` | `β0.8 β 0.55Β·rank`, lateral Γ0.25 |
+| the drake's shove | `src/pond.ts:840-844` | +0.6 m along his own bearing |
+| the straggler threshold | `src/pond.ts:1193-1196` | 3.5 m, speed 0 |
+| the stray bout | `src/pond.ts:869-879` | 15β40 s, 5β9 m from the mother |
+| the lost hysteresis | `src/pond.ts:642-648` | 1.5 m, and Γ2.5 while feeding |
+| the four-step shore slide | `src/pond.ts:1317-1344` | full / x / y / tangential |
+
+### Changed
+
+| Rust | quack | why |
+| --- | --- | --- |
+| 12β20 peas per throw | one crust per throw, 2β3 bites | a hand, not a machine |
+| peas only *heard* | heard **and** seen (Β§3.7) | a bill and a line of sight |
+| the pre-pea activity is lost | **restored** (Β§2.6) | the port fixed the original |
+| one region for the water | three media | the ice |
+| no flight | a whole flight system | the 3-D rig |
+| no year | four seasons | the year |
+| personal space by radius | hulls and gaps | body size matters |
+| one RNG stream | a master plus one per duck | per-duck jitter and poses |
+| `StandardNormal` for the water | value noise for the terrain | the terrain, not the audio |
+
+### Dropped
+
+| Rust | quack | note |
+| --- | --- | --- |
+| the synthesis, propagation and spatialization | silent `CallEvent`s on `Pond.onCall` | Β§2.1; the behaviour half is ported, the sound is the next pass |
+| the dabbling splash rhythm | β | the visual dabble does the work; a later audio hook can follow the calls |
+| the pea plop and bank tap | the bread's splash and dust | the effects table |
+| `PERSONAL_SPACE` from the listener | β | the camera replaced the listener's radius |
+| the water bed, drips, reverb | β | audio, out of scope |
+
+---
+
+## 8. Appendix B β where to look
+
+| concern | quack | duck-pond |
+| --- | --- | --- |
+| the activity set | `src/duck.ts:15` | `scene.rs:153-167` |
+| the roster | `src/pond.ts:83-118` | `scene.rs:1296-1327` |
+| the frame loop | `src/pond.ts:1606-1623` | `scene.rs:787-838` |
+| thinking | `src/pond.ts:611-772` | `scene.rs:850-946` |
+| the cue rule | `src/pond.ts:775-789` | `scene.rs:1006-1021` |
+| station keeping | `src/pond.ts:803-880` | `scene.rs:965-1000` |
+| the next activity | `src/pond.ts:884-1025` | `scene.rs:1042-1107` |
+| steering | `src/pond.ts:1187-1379` | `scene.rs:1109-1184` |
+| separation | `src/pond.ts:1097-1183` | `scene.rs:1121-1128` |
+| the calling model | `src/pond.ts` (`tickCalls`, `call`), `src/config.ts` (`CALLS`) | `scene.rs:1189-1292` |
+| the poses | `src/duck.ts:875-929` | β |
+| the waddle | `src/duck.ts:153-168, 1296-1324` | β |
+| flight | `src/pond.ts:427-500` | β |
+| the ice and the lead | `src/world.ts:465-498` | β |
+| the season keys | `src/seasons.ts:43-114` | β |
+| every dial | `src/config.ts` | `scene.rs:313-343` |
+| the RNG | `src/utils.ts:56-93` | `main.rs:139-142` |
+| the working porting record | `docs/port-notes.md` | `duck-pond/exploration.md` |
diff --git a/docs/deploy.md b/docs/deploy.md
new file mode 100644
index 0000000..e631507
--- /dev/null
+++ b/docs/deploy.md
@@ -0,0 +1,113 @@
+# Deploying quack
+
+quack is a static Vite build: `npm run build` writes `dist/`, and Cloudflare
+serves that directory as a Workers static-assets site. No server, no bindings.
+
+## What goes up
+
+`dist/` and nothing else:
+
+```
+index.html entry point, loading card and title-card styles
+assets/index-*.js the bundled pond (content-hashed by Vite)
+assets/index-*.js.map its source map (production builds always write one)
+pond-loader-*.jpg the loading card's opening-pond stills (from public/)
+quack-source.zip the fifth title card's source download (generated)
+```
+
+`node_modules/`, `temp/`, local credential files and the Rust study clone never
+leave the machine. `dist/` is git-ignored; it is rebuilt from the working tree.
+The source *does* ship: `npm run build` writes `public/quack-source.zip` (and
+copies it into `dist/`) for the fifth title card, and the source maps expose the
+bundled modules. If `SOURCE.src_url` in `src/config.ts` is filled in, the card
+opens that URL instead; the zip is still generated.
+
+## One-time setup
+
+Node 20.19+ or 22.12+ (what Vite needs), then:
+
+```sh
+npm install
+npm exec --yes wrangler@latest -- login
+```
+
+`login` opens the browser once and stores an OAuth token. Check who Wrangler is
+going to deploy as:
+
+```sh
+npm exec --yes wrangler@latest -- whoami
+```
+
+If a `CLOUDFLARE_API_TOKEN` is already exported, Wrangler picks it up instead and
+`login` is not needed.
+
+## Deploying an update
+
+```sh
+npm run build
+npm exec --yes wrangler@latest -- deploy ./dist --name quack --compatibility-date 2026-10-19
+```
+
+Wrangler reads `dist/`, uploads the assets that changed, and prints the live URL
+and a version ID. The URL does not change between deployments.
+
+- `--name quack` is required for a static-assets deploy; there is no
+ `wrangler.toml` in this repo, by design.
+- `--compatibility-date` is the Workers runtime date stamped on the deploy. Bump
+ it when you want newer runtime behaviour; the date above is the first
+ deployment's.
+- The JS bundle and its source map are content-hashed by Vite, so `index.html`
+ always names the right build. The two loading JPEGs and `quack-source.zip`
+ keep fixed names; the zip is regenerated by every `npm run build`. Rename any
+ of them (and update `index.html` or `src/hints.ts`) if you need to defeat a
+ long cache.
+
+## Rolling back
+
+```sh
+npm exec --yes wrangler@latest -- deployments list --name quack
+npm exec --yes wrangler@latest -- rollback <version-id> --name quack
+```
+
+The list prints the version ID to pass to `rollback`.
+
+## Temporary / no-account preview
+
+For a throwaway URL without deploying under an account:
+
+```sh
+npm run build
+npm exec --yes wrangler@latest -- deploy ./dist --name quack --temporary --compatibility-date 2026-10-19
+```
+
+That only works while Wrangler is logged out β it refuses `--temporary` when an
+account is connected. It returns a `workers.dev` URL plus a claim URL; the
+deployment is removed after 60 minutes unless claimed. For this project, prefer
+the normal deploy above.
+
+## Checking a deploy
+
+Open the URL Wrangler printed on a phone as well as a desktop β the pond is
+mobile-first. Workers assets can take a few seconds to propagate; a 404
+immediately after a deploy usually means: wait and reload.
+
+## Re-shooting the loading card
+
+The card's backdrop is a still of the opening pond, so it is stale whenever the
+pond's look changes:
+
+```sh
+npm i --no-save sharp # only this script needs it
+npm run dev # note the URL it prints
+node scripts/loader-shot.mjs <dev-url> public/pond-loader-portrait.jpg 430 932 2 480 50
+node scripts/loader-shot.mjs <dev-url> public/pond-loader-landscape.jpg 1440 900 1 880 50
+```
+
+The script pauses the world, hides the flock and the particles, and writes a
+progressive JPEG. Raw PNGs stay in `temp/loader/`. Rebuild and redeploy after.
+
+## Notes
+
+- `.wrangler/` is Wrangler's local cache (git-ignored); deleting it is safe.
+- `wrangler deploy` also accepts a single `.html` file, but this project wants
+ the folder so the hashed JS and the card images go up together.
diff --git a/docs/port-notes.md b/docs/port-notes.md
new file mode 100644
index 0000000..b2dd3d0
--- /dev/null
+++ b/docs/port-notes.md
@@ -0,0 +1,620 @@
+# Port notes
+
+Working notes for quack, the web port of celphase's duck-pond. The line-by-line
+study of the Rust project lives in `duck-pond/exploration.md`, beside the
+vendored clone (both kept out of this repo). This file is about *this* project:
+what was ported, what changed, and the conventions the code relies on.
+
+[`behavior-divergence.md`](behavior-divergence.md) is the balance sheet: what
+came across from the Rust model, what has **not** been ported yet, what quack
+invented, and where the behaviour differs because quack has flight and winter
+and the Rust has neither.
+
+## What came from where
+
+| in quack | source | lives in |
+| --- | --- | --- |
+| Activity model, time budget, families, swimming speeds | duck-pond `scene.rs` | `src/pond.ts` |
+| Calling model: intervals, sleeping suppression, hen lottery, social answers, brood recall | duck-pond `scene.rs` | `src/pond.ts`, `src/config.ts` (`CALLS`) |
+| Duck rig: body, wings, bill, eyes, poses | our own bird world's `bird.js` | `src/duck.ts` |
+| Flight: the glide, the climb-off beat and the landing flare, and the flap phase under them | our own bird world's `bird.js` air states | `src/duck.ts` |
+| Season-keyframe idea (re-paced: 7 minutes a season) | our own bird world's `seasons.js` | `src/seasons.ts` |
+| Bread, begging, the sight cone, flying to a far crust, the curious visitor, cursor gaze, the camera, pacing | new in quack | `src/pond.ts`, `src/main.ts`, `src/camera.ts` |
+| The lead: a hole the freeze never closes, and the rules that keep a duck from being caught by the ice | our own bird world's `js/world.js`, `js/bird.js` | `src/world.ts`, `src/pond.ts` |
+
+The research citations behind the upstream constants live in
+`duck-pond/ATTRIBUTION.md`.
+
+Deliberately not ported for v1: the whole audio stack (synthesis, propagation,
+spatialization). The calling model itself is now ported as behaviour: the
+intervals, lottery, one-hop answer chain and brood recall run in `src/pond.ts`,
+tuned by `CALLS`. A call is a silent head/bill gesture, and every call also
+leaves a `CallEvent` on `Pond.onCall` for the audio core that will follow; a
+lost duckling's "peep peep" and a content duckling's "pip-pip" are separate
+call kinds now, not a random chirp.
+
+## The flock
+
+Twelve mallards, built in `src/pond.ts`:
+
+- Hens: **Mabel** (brood: Pip, Dot, Bean, Fig), **Clover**, **Hazel**
+ (brood: Nib, Tuft, Sprout).
+- Drakes: **Rufus** (Clover's mate; trails her), **Bramble** (lone).
+- Ducklings follow their mother; drakes follow their mate.
+
+Activities: `swim | dabble | rest | preen | stray | feed | beg | fly`, each
+running sub-actions (`idle`, `look`, `sleep`, `stretch`, `preen`, `peck`,
+`drink`, `dabble`, `upend`, `chirp`, `beg`). `feed`, `beg` and `fly` are ours;
+the rest carry over duck-pond's time budget (~40 % feeding, ~30 % resting,
+~15 % preening). A duck on the wing is flown by the pond along a straight line
+and posed by the rig; only an unencumbered duck ever flies β a hen with a brood,
+or a duckling, keeps to the water.
+
+Calls are scheduled beside the activities, not inside them: `CALLS.interval`
+sets the next one by occasion (lost young, lost duckling, feeding, a drake's
+mate out of reach, dabbling, everything else), a resting duck skips 80β90 % of
+them, and a hen's series or decrescendo can set one hop of answers going.
+Ducklings peep when lost and pip-pip otherwise; a hen gathering her brood or
+answering lost young gives the `kwek-kwek` brood call and cues stray ducklings
+back. The audio synth is still absent β see the note above and
+`behavior-divergence.md` Β§2.1.
+
+## Pond geometry
+
+| piece | value |
+| --- | --- |
+| pond ellipse (`WORLD.pond`: cx, cz, rx, rz) | 0, β22, 46, 64 |
+| beach strip (`WORLD.bank`) | x β€ 26, z 41.5β52.5 |
+| thrower's stand (`WORLD.thrower`, what the flock reacts to) | (0, 47) |
+| viewer's stand (`WORLD.viewStand`, the camera's eye) | (0, 48.3); the water in front of it is kept clear of reeds |
+| depth bands (`WORLD.bands`, world z) | 22β38 / 0β22 / β38β4 |
+| chase region for bread (`WORLD.chase`) | x Β±120, z β120β¦43.5, and any water a duck can be in β open, shallow or frozen |
+| walkable shore (`WORLD.shore`) | x Β±130 on the waterline, 3 m (edge) to 21 m inland |
+
+The near land is a **bank**, not a meadow that happens to be near the water: south
+of `shorelineZ` the ground climbs clear of the water (0.7 m at the water's edge to
+about 3 m, 90 m inland) and stays clear, so the camera always stands on dry ground
+and the water's edge is a clean line rather than a patch of land afloat in the
+shallows. That climb is steep β most of it inside 2.5 m β so the mesh has to
+resolve it: at 6.94 m steps the corners straddle the bank's foot, the interpolated
+surface floats above the ground, and the *drawn* water's edge lands 2.7 m out into
+the water, stranding the reeds and any duck the field puts in the shallows. At
+3.5 m steps the drawn edge lands within 0.1 m of the field's. `WORLD.shore` +
+`shorePoint` / `shorePointAt` / `shoreNormal` / `onShore` describe the walkable
+stretch of that bank, ready for later interaction.
+
+## Tuned constants
+
+Every dial lives in `src/config.ts`, one export per scope: `APP`, `INPUT`,
+`LOADER`, `HINTS`, `CAMERA`, `SEASONS`, `WORLD`, `FLOCK`, `CALLS`, `SHORE`,
+`WINTER`, `LAP`, `FLIGHT`, `BREAD`, `SIGHT`, `CURIOUS`, `SPACE`, `DUCK`,
+`EFFECTS`. A module imports only the groups it spends, and each field carries the reasoning
+that used to sit beside the code. Two things deliberately stay where they were
+written: the season colour keys in `src/seasons.ts` and the species data in
+`src/duck.ts` (read as whole pieces of art), and the rig's pose curves in
+`src/duck.ts` (a shape, not a number).
+
+The table gives the tuned value; `config.ts` is the source of truth. The last
+column is the module that spends it.
+
+| knob | value | spent in |
+| --- | --- | --- |
+| `SEASONS.seconds` | 420 (7 min; a year = 28 min) | `src/seasons.ts` |
+| `APP.openSeason` | the year opens in summer | `src/main.ts` |
+| winter ice | season key `ice` 1: the pond locks solid, but for the lead | `src/seasons.ts` |
+| `WORLD.lead` | centre β3.5, 29; r0 8.5, r1 11.5 m; rim wobbled Β±1.85 m | `src/world.ts` |
+| `WORLD.ice.walk` | 0.5 β how thick the lid must be to carry a duck | `src/world.ts` |
+| `WORLD.ice.still` | 0.3 β how frozen a spot must be for the swell under it to be pinned: the lid is perfectly still, and only the open pool still answers the clock (the water shader repeats it) | `src/world.ts` |
+| `WORLD.water` | wade 0.05 / swim 0.3 / deep 0.8 m: the shallows are walked, deeper water paddled, and 0.8 m is enough to upend | `src/pond.ts` |
+| winter goals | inside the lead (within 0.9 Γ r0); the odd swim bout (`WINTER.loaf` 0.045) bound for the lid's rim instead | `src/pond.ts` |
+| a visit ashore | gated on a swim bout starting within `SHORE.gate` 26 m of the thrower: `SHORE.visit` 0.35, `SHORE.broodVisit` 0.5 for a hen with ducklings, both halved once the pond is iced over (`SHORE.winterVisit`) | `src/pond.ts` |
+| a spell ashore | `SHORE.stay` 35β90 s on the bank, `SHORE.iceStay` 15β45 s on the lid; the water pulls at each bout end β `SHORE.returnToWater` 0.75, `SHORE.returnFromIce` 0.85 | `src/pond.ts` |
+| the lap of the lake | `LAP.chance` 0.14 of the bout ends of an in-frame duck in the water β a wandering round drawn fresh through the bird's position each time: radius `LAP.radius` 4.5β9.5 m (`LAP.winterRadius` 3β5.5 m in the lead), a random way round, and a two-harmonic wobble (`LAP.wobble` 0.15, `LAP.wobble2` 0.1) bowing the path in and out by up to 25% of the radius; `LAP.turns` 0.55β1.5 turns a bout, `LAP.out` 0.3 of them swinging out of the picture; a duck out of the picture is sent back at `FLOCK.returnToView` 0.8, its pastime cut to `FLOCK.outViewBout` 50 s | `src/pond.ts` |
+| the flight | `FLIGHT.chance` 0.18 of the bout ends of an unencumbered, in-frame duck (Γ `WINTER.bouts` 0.55 in the freeze), plus any far piece of bread (below); a low pass `FLIGHT.reach` 16β34 m, `FLIGHT.peak` 0.8β1.45 m Γ body scale up, `FLIGHT.time` 4.2β8.5 s, the landing inside `FLIGHT.far` 40 m of the thrower; `FLIGHT.cool` 20β45 s after each landing | `src/pond.ts` |
+| hull speed, adult | `FLOCK.hullSpeed` 0.71 m/s | `src/pond.ts` |
+| hull speed / burst, duckling | `FLOCK.ducklingHullSpeed` 0.43 / `FLOCK.ducklingBurst` 1.26 m/s | `src/pond.ts` |
+| brood pace (hen with ducklings) | `FLOCK.broodPace` 0.3 m/s | `src/pond.ts` |
+| cruise speeds (hen / drake / duckling) | 0.45 / 0.5 / 0.33 m/s (per kind, in the rig) | `src/duck.ts` |
+| lost duckling | `FLOCK.lost` β stops beyond 4 m from mother | `src/pond.ts` |
+| stray mean interval | `FLOCK.strayEvery` 900 s | `src/pond.ts` |
+| calls | `CALLS.first` 0.5β8 s before the first; then `CALLS.interval.lostYoung` 1.5β3, `.lost` 0.2β1, `.ducklingFeed` 1.5β5, `.duckling` 4β15, `.feed` 2.5β6, `.drakeAway` 8β20, `.drake` 45β180, `.dabble` 15β60, `.other` 20β90 s, in that precedence; a resting duck skips `CALLS.sleepSkip` 0.9 (adult) / 0.8 (duckling) | `src/pond.ts` |
+| answers | a social call reaches 40 m: mate p = `CALLS.answer.mate` 0.6, others `CALLS.answer.other` 0.3 Γ (1 β dist/40); answer due in `CALLS.answerDelay` 0.8β2.5 s, one hop | `src/pond.ts` |
+| brood recall | a lost duckling cuts its mother's timer to `CALLS.motherDelay` 0.5β2.5 s; her brood call runs `CALLS.broodNotes.lost` 6β10 notes (gathering: 3β6) and cues stray ducklings | `src/pond.ts` |
+| `INPUT.tapSlop` | 12 px | `src/main.ts` |
+| cursor gaze limits | `DUCK.look.yawMax` 1.55, `DUCK.look.pitchMax` 0.72 rad | `src/duck.ts` |
+| gaze shaping | `DUCK.look.sweep` 0.9, `DUCK.look.tilt` 1.1 | `src/pond.ts` |
+| bread flight time | `BREAD.time`: clamp(0.45 + dist * 0.04, 0.6, 1.9) s | `src/pond.ts` |
+| bites per piece | `BREAD.bites`: 2 or 3 (50/50) | `src/pond.ts` |
+| bread lifetime | `BREAD.lifetime` 100 s, then it sinks | `src/pond.ts` |
+| begging time | `BREAD.begTime` 25β40 s at the landing spot | `src/pond.ts` |
+| seeing the bread | a duck head-up with the landing spot within `SIGHT.range` 55 m and inside `SIGHT.half` 1.15 rad of its bill starts for the splash at 25β70% of the piece's flight; everyone else only hears the splash and comes late | `src/pond.ts` |
+| flying for bread | a free duck with a piece `FLIGHT.food.near` 15 m or more away, rising to `FLIGHT.food.max` 0.55 at `FLIGHT.food.far` 42 m; it comes down `FLIGHT.food.gap` 1.4β2.6 m off the piece on open water, one bird to a piece | `src/pond.ts` |
+| the curious duck | `CURIOUS.chance` 0.08 of bout ends for a duck in the water within `CURIOUS.gate` 46 m of the stand with no bread down; one at a time, `CURIOUS.every` 150β420 s apart, `CURIOUS.time` 25β60 s of begging after the swim over | `src/pond.ts` |
+| floating bread rings | `BREAD.ring.every` 1.4β4.2 s, `BREAD.ring.scale` 0.2β0.3 (2β3Γ the piece), in open water only β a piece out on the lid lies still | `src/pond.ts` |
+| bread scatter | a tap within `BREAD.scatter.crowded` 0.55 m of floating bread is skewed `BREAD.scatter.near`β`BREAD.scatter.far` 0.24β0.6 m off the cursor, re-rolled until it clears every piece by `BREAD.scatter.gap` 0.24 m | `src/pond.ts` |
+| personal space | hull `SPACE.hullLen` 0.31 m long Γ `SPACE.hullBeam` 0.18 m beam, per unit of body scale; gap `SPACE.familyGap` 0.03 m inside a family, `SPACE.strangerGap` 0.10 m between strangers | `src/pond.ts` |
+| the waddle | one hinge per leg at the hip; a step covers `2Β·DUCK.legs.lengthΒ·sin(DUCK.legs.swing)` β 0.14 m Γ body scale over `DUCK.legs.stance` 0.62 of the cycle, so the cadence falls out of the speed; swing sole lifts `DUCK.legs.lift` 0.022, and the body rides ~0.015 m Γ body scale through a cycle | `src/duck.ts` |
+| the sleeping sit | a duck asleep on solid ground folds its hips to `DUCK.sit.tuck` 1.55 rad and settles `DUCK.sit.drop` 0.04 rig units Γ body scale, until the belly is 7β9 mm Γ body scale *into* the shore or lid; eases at 2.6/s | `src/duck.ts` |
+| breathing | `DUCK.breath.amount` 1.4% at `DUCK.breath.rate` 3.2 rad/s, on the torso mesh alone β head, bill, wings, tail and legs keep their size | `src/duck.ts` |
+| avoidance field | a soft nudge from `SPACE.reach` 1.3Γ the hull distance out, up to `SPACE.push` 0.28 m/s | `src/pond.ts` |
+| camera | first person: the eye planted at `WORLD.viewStand`, `CAMERA.eyeHeight` 2 m above the ground; `CAMERA.yawLimit` Β±0.85, pitch `CAMERA.pitchMin`β¦`CAMERA.pitchMax` β1.0β¦0.45, resting at `CAMERA.pitchStart` β0.06 | `src/camera.ts` |
+| camera zoom | `CAMERA.fov`: 10β78Β°, opens at 52Β°; wheel and pinch ask for equal *ratios* per pixel, and the ease runs in log space. The floor holds a slice of horizon rather than a number of degrees: 10Β° vertical on a 16:9 screen (`CAMERA.fov.refAspect`), opening up on narrower ones (`fovFloor(aspect)`) so a phone's portrait frame always shows the same width of pond at full lean-in | `src/camera.ts` |
+| shore bank floor | `WORLD.bankFloor` 0.7 m at the edge β 3.0 m at 90 m inland | `src/world.ts` |
+| walkable shore | `WORLD.shore`: Β±130 m along the waterline; edge 3 m, depth 18 m | `src/world.ts` |
+| terrain mesh | `WORLD.segments` 432 (3.5 m) over `WORLD.size` 1500 m; the slope is read off the grid, not re-sampled | `src/world.ts` |
+| ripples | `EFFECTS.ripple`: lift 0.035, life 1.3β1.8 s, growth 0.4 + 2.4 t, alpha 0.55, 24 rings | `src/effects.ts` |
+| particle pools | `EFFECTS.capacity`: 180 feathers, 320 droplets, 220 leaves, 300 snowflakes; `EFFECTS.weather` holds the shedding rates | `src/effects.ts` |
+| tone mapping | ACES, `APP.exposure` 0.98 | `src/main.ts` |
+| default seed | `APP.seed` 20261018 (`?seed=N`) | `src/main.ts` |
+
+## Conventions
+
+- Units are meters and seconds; y-up; a duck faces +z before posing.
+- **Head pitch sign**: positive X tips a +z-facing part (the bill) down β for an
+ unyawed bill. In the sleep pose the head is yawed 2.85 rad (~163Β°), so the
+ sign flips and a *negative* pitch is what lowers the bill onto the back.
+- **Sleep pose**: the head is drawn right around over a shoulder with the bill
+ trailed back along the flank. Tangency of the jaw's underside with the
+ torso/wing shell sits near β0.18 rad, but flush is the wrong answer β the tip
+ disappears into the body volume from most angles, the player's included. The
+ code holds the bill at **+0.06 rad**: still lying on the feathers, top edge
+ clear of the body line, so the tip reads from either flank. Checked on hen,
+ drake and duckling, close up and from the stand.
+- **Sleeping is sitting**: a duck that dozes off on solid ground folds its legs
+ up under itself and settles onto its belly; dozing upright on straight legs
+ read as standing with the head tucked, which is a horse, not a duck. The
+ trigger is `action === 'sleep'` while on land or the lid and still; the hips
+ fold to `DUCK.sit.tuck` (1.55 rad, the swimming tuck) and the standing height
+ drops by `DUCK.sit.drop` (0.04 rig units Γ body scale), a little *more* than
+ it takes to bring the belly down to the surface β the body presses a feather's
+ depth into the shore or ice instead of touching it tangentially. Tangency
+ reads as hovering, and the ice would draw the contact as a hard line; sunk in,
+ the belly and the stern are cut by the ground plane and the bird sits in it.
+ The idle bob fades out with `sit` so the settled body does not breathe itself
+ clear of the ground, and the whole settle eases at 2.6/s, so lying down and
+ getting up are movements. Sleeping afloat is untouched: a loafing duck already
+ has its legs at 1.55 and rides the swell. (Measured at drake scale 1.46: the
+ body drops 5.8 cm, the belly's lowest point crosses the ice by ~7 mm, the
+ folded feet ride 13 cm up inside the belly and never break its silhouette.)
+- **Breathing is the body's alone**: the idle breath is a scale on the torso
+ mesh, not on the root. Scaling the whole rig made the head, bill and feet
+ pulse along with the chest, and quietly changed the legs' length every breath
+ β so a planted sole crept a millimetre or two while the duck stood still.
+ Now the chest fills and empties and everything else holds its size; the neck
+ is short enough that the 1.4% seam stays buried in the plumage. Measured at
+ drake scale 1.456: torso world scale runs 1.436β1.477, while head, bill,
+ wings, tail and feet all stay at 1.456.
+- **Wing stretch**: both wings come up and wave, one a little higher than the
+ other, and both *stay* up. A floating duck's shoulders sit barely above the
+ water, so a wing swinging down out of the stretch dips straight into it and the
+ pair reads as one broken diagonal; the asymmetric part is therefore added on
+ both sides rather than being a mirror of the flap.
+- **Cursor gaze**: every duck pans and tilts for the pointer across the whole
+ viewport; the easing keeps the middle gentle while the corners reach the full
+ gaze limits. Driven by `pond.setCursor(x, y)` with NDC coordinates.
+- **Tap vs drag**: no time limit β press as long as you like, release to throw.
+ Only a drift past `INPUT.tapSlop` turns a press into a camera drag. Right-button
+ presses and second fingers are ignored. A duck that never sees the throw reacts
+ only once the bread lands; one that does (below) is off while it is still in
+ the air.
+- **Bread**: leaves the hand just below the camera (1.2 m along the throw
+ direction, at least 1 m above the water), arcs to the landing spot, then
+ splashes or kicks up dust. Landing spots are clamped into the chase region so
+ the flock can always reach them, on open water, in the shallows or out on the
+ ice. A piece left floating nudges the surface now
+ and then β a small ring, a couple of times the size of the piece β so it stays
+ findable without shouting like the splash it landed with.
+- **Seeing the throw**: a duck with its head up has the whole pond in front of it,
+ but no reason to look up β unless something arcs over the water. A piece whose
+ landing spot lies within `SIGHT.range` (55 m) and inside `SIGHT.half`
+ (1.15 rad) of the bird's bill is *seen*: that duck sets off for the splash at
+ 25β70 % of the piece's flight (`sees`, `maybeFlyToFood`), so it is already
+ moving β and sometimes already eating β by the time the ring spreads. The rest
+ only hear the splash, the near ones first (`T/12` per metre), so the flock
+ comes in waves, and the birds facing the other way or asleep always come late.
+ A duck on the wing keeps its invitation until it is down, so a flight for one
+ piece is never cut short by another. Measured (20 min, a throw every 110 s):
+ 22 ducks set off for a piece before it touched down, against 101 that came
+ after the splash.
+- **Rings ride the swell**: every ripple β splash, wake, nibble, bread nudge β
+ is pinned to `waterHeightAt(x, z, time)` for its whole life, never to the
+ height it was spawned at. The swell runs Β±0.3 m here, so a ring left at a
+ fixed height hangs in the air beside its duck the moment the surface drops
+ away from it; a wake spawned at the duck's own waterline would otherwise start
+ 6 cm *under* the surface. `Effects.update` stores the world and clock so
+ `ripple()` and the pool can re-seat every ring each frame.
+- **Repeated taps**: a landing spot within `BREAD.scatter.crowded` (0.55 m) of
+ bread already there is skewed off the cursor β a random bearing,
+ `BREAD.scatter.near`β`BREAD.scatter.far` (0.24β0.6 m) out β and re-rolled
+ until it clears every piece by `BREAD.scatter.gap` (0.24 m, a piece being a
+ 0.1 m pebble). A few taps on one spot then make a little scatter of crusts to
+ pick at, close together but never intersecting. The skew stands only if the
+ ducks can still reach the spot.
+- **Winter, and the lead**: the cold comes in from every edge, but it never
+ closes one patch of water β `WORLD.lead` (centre β3.5, 29; rim 8.5 to
+ 11.5 m out), set a little left of the middle of the gaze the camera opens on.
+ `iceAt(x, z)` is how frozen a spot is: 0 inside the lead, 1 far from it, ramped
+ across the rim, and the rim is wobbled by `leadWobble(angle)` in integer
+ harmonics so it closes on itself without a seam. The water shader repeats both
+ formulas from the same constants (passed as uniforms), so the sheet that is
+ drawn and the sheet the rules know about agree to the metre. Its swell is
+ pinned as soon as the sheet starts to skin over β `WORLD.ice.still` 0.3, well
+ short of the `WORLD.ice.walk` 0.5 that lets a duck stand on it β and the
+ fragment shader repeats the same pinning, so the whole lid is perfectly
+ still: on screen, and in `waterHeightAt`, which is what a duck standing on
+ it, and a ring that lands on it, both ride. Only the open pool still answers
+ the clock. Measured at full ice: all 78 sampled lid points had zero swell
+ amplitude across 24 s of clock, and the ice read 0 changed pixels in a
+ straight-down render two seconds later, while the pool moved 45 % of its
+ pixels in the same frame (and open water in summer 44 %, the control). Through
+ the winter the lid reaches further in, so the flock is pushed off the
+ shallows, gathers in the lead, and begins to stand about on the ice.
+- **What a duck may be in**: `solidAt` β ice at `WORLD.ice.walk` (0.5) or thicker β
+ joins the media a duck may occupy. It swims open water, wades the shallows
+ (0.05β0.3 m: too shallow to paddle, shallow enough to walk through), and walks
+ the lid; those are the only rules that changed, and steering is the same in
+ all three. `Pond.stranded()` is the net under all of it: a duck standing where
+ nothing allows it any more β the freeze closed around it, the thaw took the lid
+ from under it, a shove left it high and dry β is walked out to the nearest spot
+ that is allowed, because steering cannot free a duck that has nowhere to steer,
+ and one that is not freed stands still for good and a hen waiting on her brood
+ waits for ever. Measured through a freeze (30 s of sim once the pond is locked):
+ no duck-frame had a duck standing where the rules forbade it, and none was left
+ stuck β five of the twelve paddling in the lead and seven about on the lid, and
+ a crust thrown on the lid is walked to and eaten.
+- **Ice that reads as ice**: the sheet takes the lead's rim as a slush edge that
+ rides the freezing front, then a grained, patched lid β snow lying on it in
+ patches and the ice scoured bare between them a shade darker, so that even at
+ the grazing angle the camera opens on the sheet is a surface rather than a
+ mist. It turns properly solid just *past* `WORLD.ice.walk`, so nothing is ever
+ seen paddling on something that already reads as ice, and the open water
+ deepens and cools as the season turns, which is what makes the lead read as the one hole in
+ the lid. (borb's sheet was a single colour-and-alpha mix; the grain, the slush
+ and the patches are ours.)
+- **Bread on ice, and in the shallows**: a crust may land on the lid or in the
+ shallows, and the flock walks out to it β nothing is ever re-landed on water
+ they cannot reach. A piece on the lid does not bob and does not ring (it is not
+ floating); it kicks up dust when it lands, like one on the bank.
+- **Ashore is a visit, not a move**: the bank and the winter lid are places to
+ pass the time, never somewhere to go *to*. A duck leaves the water only on a
+ whim of the swim model β a swim bout starting within `SHORE.gate` (26 m of the
+ thrower) turns into a visit with `SHORE.visit` 0.35 (`SHORE.broodVisit` 0.5
+ for a hen with ducklings; `SHORE.winterVisit` halves both, because a spell
+ ashore then means a march over the frozen lid) β and once it is out, no bout
+ targets another spot out there: a pastime is all the footing model offers, and
+ each bout end walks the duck back to the water, `SHORE.returnToWater` 0.75 on
+ the bank and `SHORE.returnFromIce` 0.85 on the lid. The odd bout takes another
+ pastime instead, so a visit is one to three bouts β not instant, and not a
+ migration.
+ Measured in summer (45 min): four adult visits for the flock, 112β226 s each,
+ ~12 % of duck-time out of the water, and most of that one hen's brood on the
+ bank; in winter (20 min Γ 3 seeds): 7β12 % of duck-time on the lid, over half
+ of it the walk across to the bank, the rest loafing in 15β45 s bouts. No duck
+ ever stood anywhere the rules forbade, and none was left out for long.
+- **One year, one cast**: quack is a single year in a timeless place. The same
+ twelve birds are here in January as in June, and the ducklings never grow up;
+ the seasons turn around them. This is a deliberate simplification, not a
+ migration model, and it is the reason the winter cast can stay fixed.
+- **The picture keeps the flock**: a duck that ends a bout outside the camera's
+ wedge is sent back to its band's water (`FLOCK.returnToView` 0.8), and a
+ pastime already running out there is cut to `FLOCK.outViewBout` 50 s. `dabble`
+ is offered only in water inside the wedge, which was the leak that let a
+ dabbler drift over the line and then hold position out there for a whole bout;
+ a duck already outside keeps its ground (and dabbles where it stands) until
+ the bout ends.
+- **The lap of the lake**: a duck that would settle to the nearest pastime
+ sometimes goes off around the water instead β `LAP.chance` 0.14 of the bout
+ ends of an in-frame duck, 0.55 of that in the freeze (`WINTER.bouts`). The
+ round is drawn fresh every time, *through the duck's own position*, so the
+ whole bout is the round and never a swim out to some far-off circle: a random
+ radius (`LAP.radius` 4.5β9.5 m, `LAP.winterRadius` 3β5.5 m in the lead), a
+ random way round, and two radial wobbles (`LAP.wobble` 0.15, `LAP.wobble2`
+ 0.1, incommensurate with the circle and with each other) that bow the path in
+ and out by up to a quarter of the radius, so no two laps read the same and
+ none closes into a stiff circle or ellipse. The pond walks the mark along the
+ arc a few metres ahead of the bird (`stepLap`),
+ and a bout swims `LAP.turns` 0.55β1.5 turns of it β part of the round, or all
+ the way about β before the bird gets its day back. A round that is asked
+ for out of the picture (`LAP.out` 0.3) skips the wedge test, so part of it
+ plays off the edge of the frame and back in. Measured (30 min summer): four
+ laps, radii 4.6β8.9 m, three one way round and one the other, 0.7β1.16 turns
+ each, the path holding the round to 0.91β0.96 of its radius, the net turn
+ matching the lap's own direction every time, all four inside the wedge.
+- **The flight**: now and then a duck takes the short way across on the wing
+ instead of swimming it. `FLIGHT.chance` 0.18 of the bout ends of an
+ unencumbered duck that is inside the picture become a low pass over the water,
+ `FLIGHT.reach` 16β34 m long and `FLIGHT.time` 4.2β8.5 s in the air, riding
+ `FLIGHT.peak` 0.8β1.45 m Γ body scale over the surface at its peak; the
+ landing spot is re-rolled until it is clear of the ducks already there and
+ within `FLIGHT.far` (40 m of the thrower), where the flock still reads β a
+ duck out in the far water flies shorter and tends back towards it.
+ A bird that has just come down stays down for `FLIGHT.cool` 20β45 s, so flights
+ never chain into a shuttle. The pond flies the bird (a straight line, held off
+ and on the water, climb β glide β settle), and the rig does the wings, from
+ borb's air poses: a hard beat to get up, a long glide with barely a tip moving,
+ then the flare and backbeat that sets it down, with the bill lifted on the
+ climb and again on the flare and the wingbeat carrying the body up and down.
+ Splashes at both ends β a puff of snow if it sets down on the lid β legs
+ tucked, tail spread. Nothing on the water touches a bird on the wing β it is
+ listed for no hull, no ripple wake, no cursor and no unclaimed bread, and
+ steering leaves it alone β so it passes over the flock and over the ice with no
+ trouble. Measured (30 min summer, no bread): eight flights from the flock's two
+ eligible birds, 17.8β32.9 m, every one landing on open water, none chained; in
+ winter, three short hops over the lead in 25 min.
+- **Flying for bread**: a crust that comes down across the pond is worth the
+ wing rather than the paddle. A duck that has decided to come for a piece
+ measures it, and if the bird is `FLIGHT.food.near` (15 m) or more away the
+ chance rises with the distance to `FLIGHT.food.max` 0.55 at `FLIGHT.food.far`
+ 42 m (`maybeFlyToFood`); it comes down in the water `FLIGHT.food.gap` 1.4β2.6 m
+ off the piece on a random bearing and paddles the last of the way, so it never
+ lands on its own mouthful, and only one bird flies for a piece β the rest
+ swim, which is what keeps a far throw from emptying the pond
+ into the air. Only ducks with nothing to look after fly for it (a hen with a
+ brood stays on the water), and a piece out on the bank is left to the walkers. Measured: with a throw every 110 s, six food flights in 20 min at
+ 25.7β51.4 m, every one onto water and five of six still going for the bread
+ at touchdown.
+- **The curious duck**: now and then it is the duck that notices the visitor,
+ not the other way round. At a bout end, a duck in the water within
+ `CURIOUS.gate` (46 m of the stand), with no bread down, may take it into its
+ head to come over: `CURIOUS.chance` 0.08, and the flock keeps at least
+ `CURIOUS.every` 150β420 s between visits, so it reads as one bird's idea
+ rather than a habit. It crosses at a purpose (`hurry` until it is within 4 m
+ of the shallows in front of the stand), then stands about there for
+ `CURIOUS.time` 25β60 s, head up and the odd imploring bill-open, begging at
+ nothing β until a piece of bread lands anywhere and the flock's usual business
+ resumes. In a freeze the
+ bank is out of reach, so it comes to the nearest open water instead: the rim of
+ the lead. Measured (25 min winter): two such visits, 66β83 s each, each ending
+ 9.3β9.7 m from the stand at the lead's near rim; in summer one bird crossed
+ 43 m to beg 5.8 m from the stand.
+- **Personal space**: every duck carries a hull β an ellipse half a body long
+ (`SPACE.hullLen`, 0.31 m) and a quarter wide (`SPACE.hullBeam`, 0.18 m), in
+ its own units of body scale, so a drake is a larger obstacle than a duckling β
+ plus a little water between hulls: `SPACE.familyGap` (0.03 m) for a mother and
+ her brood, a pair of mates or siblings, `SPACE.strangerGap` (0.10 m) between
+ strangers. It is a nudge first: steering eases every duck off the space its
+ neighbours hold, from `SPACE.reach` 1.3Γ the hull distance out (up to
+ `SPACE.push` 0.28 m/s), which is what keeps ordinary meetings from turning
+ into contact. Anything that does touch is settled after everyone has moved:
+ `Pond.separate()` measures every pair against
+ that hull distance and shares out any overlap, then takes the closing speed out
+ of the pair, so the next frame cannot drive them straight back in. A duck that
+ cannot give ground (a bank at its back) hands its share to the other; a
+ sleeper or an upended duck takes a quarter share and is given no new velocity
+ at all, so nothing can set it adrift or swing it about. Two ducks therefore
+ meet nose to nose further apart than they meet flank to flank, and a scrum on
+ one crust shoulders up like real dabbling ducks instead of intersecting.
+- **Anchored poses**: a duck in `sleep` or `upend` takes no steering input at
+ all β no wander, no drift on the spot, and no slow turn as its mark shifts.
+ Only a neighbour's crowd-avoidance push can move it (a quarter of the parting
+ push above, and never any new velocity). A duck standing on its
+ bill is planted in the mud; if it kept paddling after a mark it would drag the
+ pose off the spot it chose.
+- **The waddle**: a duck on land hinges each leg exactly once, at the hip, and
+ keeps the step on the ground. Each leg is a hip group (`hipL`/`hipR`), an
+ ankle group and a foot mesh, so the sole can be held flat while the shank
+ swings; both sides share one shank and one foot geometry, mirrored by the
+ hinge's own position. Through the stance (`p < DUCK.legs.stance`, 0.62 of a
+ cycle) the hinge angle is the arcsine of a linear ramp β
+ `asin(sin(DUCK.legs.swing)Β·(2p/DUCK.legs.stance β 1))` β which is exactly the
+ angle that holds a foot of length `DUCK.legs.length` (0.15) on the spot it
+ landed on while the body crosses the ground over it: the planted sole never
+ skates, and it lies flat the whole time, because the ankle cancels the hinge.
+ The swing carries the foot back to the front on a smooth ease, sole lifted
+ clear of the ground (`DUCK.legs.lift`, 0.022, the shank telescoping up into
+ the belly) and toe up. A step covers
+ `2Β·DUCK.legs.lengthΒ·sin(DUCK.legs.swing)`, about 0.14 m per unit of body
+ scale, spread over `DUCK.legs.stance` of the cycle, so the cadence falls
+ straight out of the speed β
+ `speedΒ·DUCK.legs.stance/(2Β·DUCK.legs.lengthΒ·sin(DUCK.legs.swing)Β·scale)`,
+ near 1.9 cycles/s at a hen's cruise, quicker for a duckling's shorter legs
+ because the
+ step in metres shrinks with it. The body rides the leg that is on the
+ ground β the one hanging lowest of them β at exactly that leg's vertical
+ reach (`legReach`), so the planted sole holds its height as well as its
+ spot, and the body rides back up out of the lean, which presses the outside
+ foot into the ground by as much again; what is left over is the step's own
+ weight: high over the straight planted leg, low as the feet spread. Over
+ that it leans onto the planted foot (`DUCK.waddle.roll` 0.13 rad) and the
+ stern answers once a step (`DUCK.waddle.wag`). The hips answer the lean in
+ turn, so the legs hang plumb and the body rocks *over* the planted foot
+ rather than swinging the foot through an arc β which is what holds the sole
+ still
+ sideways as well as fore-and-aft. A duck without a step's worth of weight
+ in it β standing, floating, or swimming with the legs tucked β damps
+ `stride` to 0, so the legs ease back under the body and a stop never
+ freezes mid-stride. Measured on the ice with `scripts/gait.mjs` (a hen at
+ body scale 1.46, walking 0.45 m/s): the planted sole holds its spot β no
+ forward travel at all over a stance, and sideways under 2% of the ground the
+ body covers β and its height to within a centimetre, most of that the body's
+ own breathing. The swing sole clears by up to 4.7 cm, the lean runs Β±5Β°, and
+ the body rides 1.4 cm per unit of body scale through a cycle. Cadence came
+ out at 1.32 cycles/s against 1.32 predicted, sweeping 100% of the ground it
+ crossed.
+- **Bottom feeding (`upend`)**: the whole body tips forward about the waterline
+ pivot β the duck's own `root` origin, ~9 cm under the surface, roughly the
+ body's centre, which is where a real upender turns. `DUCK.upend.pitch` is
+ 1.62 rad (a little past vertical) reached over a 0.8 s ramp, held with the feet
+ paddling and the stern wagging, then rocked back in 0.75 s. The `dabble`
+ activity offers it only to a duck that is afloat β never out on the lid β and
+ only where `depthAt > DUCK.upend.depth` (0.8 m), so the bill really is in the
+ mud; ducklings never upend. Rings and droplets fire at both ends of the move.
+ Measured: the head goes ~0.24 m under the surface (visible through the
+ transparent water) and the tail ~0.47 m above it.
+- **Camera**: first person, planted on the shore. Drag turns yaw (right = turn
+ right) and pitch (down = the gaze drops); pinch and wheel change the field of
+ view. The eye never moves, so the pond keeps its place and only the gaze
+ swings across it. Reeds are kept clear of the water directly in front of the
+ stand (`WORLD.viewStand`), so no stem parks itself in the eye.
+ Zooming has no centre of its own β it narrows the frame around wherever the
+ gaze rests β so the gaze rests a touch below level (`CAMERA.pitchStart`,
+ β0.06 rad): that is the line a full lean-in keeps, and it frames the whole
+ flock from the near families to the far band.
+
+## The loading card
+
+The pond is held behind a card for its first moment: the view it opens on, shot
+without a duck in it and thrown out of focus, with "quack ... quack" coming up a
+letter at a time over it. When the world is up, the card fades away and the live
+pond is left behind, framed the same way.
+
+- **The face**: `public/pond-loader-portrait.jpg` and `-landscape.jpg`, shot by
+ `scripts/loader-shot.mjs` from the camera's opening pose (summer, default
+ seed, `app.paused`), with the flock and the particle pools hidden. Progressive
+ JPEG (mozjpeg, 4:2:0, q50) at 480 and 880 px wide β 3.9 and 5.4 kB, so the
+ card has its picture before the module is down.
+- **The wash**: `filter: blur(17px)`, with the image stretched to the element
+ (`100% 100%`) rather than cover-cropped. Under that blur the stretch never
+ shows, and it keeps the far shore on the same line of the screen as the live
+ camera's at any aspect ratio β card 43.0β43.4 % down, live 44.0 %, measured on
+ a phone and a 4:3 tablet β so the handover has no jump to hide.
+- **The word**: five spans β word, three dots, word β each easing out of a 6 px
+ blur and *fully in* before the next starts (word 0.38 s, dot 0.2 s, 0.05 s
+ between; 1.61 s end to end), so the line reads in five distinct steps rather
+ than as one smear.
+- **The handover**: `hideLoadingSoon()` keeps the card to `LOADER.min` = 1950 ms
+ from navigation β enough for the last word even on a fast load, with a beat on
+ the finished line β then `.done` fades it over `LOADER.fade` 900 ms and removes
+ it `LOADER.removeDelay` 400 ms later. The class also drops `pointer-events`,
+ so the pond can be dragged while the fade is still going.
+ `prefers-reduced-motion` puts the line up at once and fades in 0.3 s.
+
+## The title cards
+
+Five lines the pond cannot show for itself come up low over the water after the
+loading card has gone, one at a time, and leave again: the throw instruction,
+the look instruction, the zoom instruction, the credit to celphase's duck-pond,
+and the source card.
+
+- **One line, one card**: `HINTS.tipHold` 3 s holds for the three tips,
+ `HINTS.creditHold` 7.5 s for the credit and `SOURCE.hold` 6.5 s for the
+ source, `HINTS.fade` 0.6 s fades (the CSS runs 0.55 s), `HINTS.gap` 0.5 s of
+ quiet water between. The first starts 1.9 s after the loading card begins to
+ fade β with a normal boot that is about four seconds after navigation, so the
+ pond is revealed first and the tips come second.
+- **The source card**: `SOURCE.src_url` in `src/config.ts` is the public
+ repository. When it is a non-empty string the card opens that URL in a new
+ tab; when it is empty or null the card instead offers
+ `public/quack-source.zip`, a download of this working tree. The zip is built
+ by `scripts/source-zip.mjs` from `src/`, `public/`, `scripts/`, `docs/`,
+ `audio/` and the root project files, and leaves out `node_modules`, `dist`,
+ `temp`, `duck-pond`, `.wrangler` and every local credential file. The build
+ runs it before Vite, so the copy in `dist/` is current. It is generated, not
+ committed.
+- **Source maps**: `vite.config.ts` sets `build.sourcemap: true`, so every
+ production build ships its `.js.map` beside the bundle. That keeps the live
+ site debuggable and lets anyone compare the deployed code with the source zip
+ module by module.
+- **Device wording**: `(hover: none) and (pointer: coarse)` picks "tap to throw
+ bread", "drag to look around" and "pinch to zoom"; a fine pointer gets
+ "click", "drag" and "scroll to zoom". The wording follows the *primary* input,
+ so a touchscreen laptop still gets mouse words. The controls are described as
+ they are: drag turns the gaze, pinch or wheel changes the field of view.
+- **Hardly there**: an 0.33-alpha panel with a 9 px backdrop blur, a 1 px border
+ and a 13 px radius β legible over bright water without turning into a HUD. The
+ container is `pointer-events: none`; only the links re-enable the pointer, so
+ taps and drags pass through the card to the pond everywhere but the link
+ itself.
+- **Stays for the link**: on hover-capable devices the card does not leave while
+ the pointer is on its link, so the credit or source can be read or followed at
+ leisure. `prefers-reduced-motion` collapses the fades to 0.01 s; the cards
+ still come and go.
+
+## Debugging
+
+- `window.quack = { app, world, seasons, effects, pond, rig, camera, renderer, scene }`
+ is set once the pond is up; `app.paused`, `app.timeScale` and `seasons.hold(i)`
+ are the quick levers. `world.ice`, `world.iceAt(x, z)` and `world.solidAt(x, z)`
+ answer what the freeze is doing where.
+- `app.step(raw = 1/60)` advances the world by exactly `raw` seconds whatever
+ the clock is doing β pause and `timeScale` are ignored β and renders, so a
+ probe can hold `app.paused = true` and walk the sim in fixed slices without
+ the frame rate colouring the answer. `scripts/gait.mjs` uses it to film and
+ measure the waddle; each duck carries its two feet at `d.feet`
+ (`[footL, footR]`, index 0 is the +x side) for a probe to watch, and
+ `d.stride`, `d.walkPhase`, `d.roll` and `d.vel` say what the gait is doing,
+ and `d.sit` how far a sleeping duck has settled onto its belly.
+- Holding winter: `seasons.hold(3)` glides there in about a second. To catch the
+ freeze part way, hold it with `app.timeScale = 1` and poll `world.ice` on a
+ timer, pausing the app as it passes the value you want β 0.4 is a good
+ mid-freeze, lid advancing and the lead still wide. `world.waterHeightAt(0, 45, t)`
+ is the check that the shore has stopped moving: every `t` must give the same
+ number, or the lid at the edge of a frozen pond is still heaving; the same
+ check on screen is two renders of the lid two seconds of clock apart with every
+ mesh but the water hidden β 0 differing pixels, while the pool in the same
+ frame moves.
+- `pond.force(name, activity, left)` pins a duck; `pond.throwBread(x, z, ...)`
+ lands a piece wherever you like; `pond.setCursor(nx, ny)` / `setCursor(null)`
+ drives the gaze (works headless).
+- Forcing a flight: `d.setActivity('fly')` then
+ `pond.beginFlight(d, { x, z })` (the target needs open water) β the pond flies
+ it from where it stands while the rig poses it. `d.flying`, `d.flyH` (height
+ over the water), `d.flyPhase` (0β1 through the pass) and `d.flyPitch` say what
+ it is doing; `d.flight` is the plan itself. Walking `app.step` through a pass
+ at 0.05 s slices gives the glide and the landing for a shot. `d.flyCool` is
+ the seconds left before that bird will take the wing again.
+- Forcing a lap: `pond.startLap(d, true)` (the flag is the wedge test) returns
+ whether it found a round, and `d.lap` holds it (`x`, `z`, `r`, `a`, `dir`,
+ `wob`, `left`). The path is measured from the plan, not the frame: sample
+ `hypot(d.pos.x - d.lap.x, d.pos.z - d.lap.z) / d.lap.r` each second (it holds
+ ~0.9β1.1), and add up the signed heading change around `d.facing` β the net
+ turn must have the sign of `d.lap.dir` and about its size (turns Γ 2Ο).
+- Bread in the air: `pond.sees(d, x, z)` is the sight cone (awake, within
+ `SIGHT.range`, inside `SIGHT.half` of the bill); `d.notice` is the seconds
+ until that duck sets off, so a probe can read the early reaction before the
+ splash. `pond.maybeFlyToFood(d)` makes the fly-or-swim roll for the nearest
+ unclaimed piece; `piece.claimed` says who is coming for it.
+- Curiosity: `pond.curious` is the seconds until the flock may notice the visitor
+ again; `pond.begPoint(d)` is where that duck would come to, and 0 if there is
+ no water it can reach in front of the stand. `pond.force(name, 'beg', left)`
+ poses a visit for a shot.
+- Overlap checks: `pond.contact(a, b, ux, uz)` is the centre distance at which
+ two hulls touch β reach it through a unit vector and compare with the real
+ gap, so a probe can report the worst clearance in a crowd rather than trusting
+ a screenshot. `pond.separate()` can be called by hand to settle a planted
+ crowd before a shot.
+- Shore: `world.shore` is the walkable section; `world.shorePoint(x, across)`
+ returns a point on it (`across` 0 = water's edge, 1 = inland edge),
+ `world.shorePointAt(t)` marches it west to east, `world.onShore(x, z)` tests a
+ spot and `world.shoreNormal(x)` gives the inland direction.
+- Screenshots: `node scripts/shot.mjs <url> <out> [w] [h] [waitMs] ["js"]` β
+ the optional JS string runs in the page first (to pose the camera or season).
+ Output goes under `temp/`, which is git-ignored.
+- The loading card is plain HTML/CSS in `index.html`, up before any module is:
+ `document.getAnimations()` lists its five span animations, so a probe can
+ pause them and seek a frame (`a.currentTime = ms`). `scripts/loader-shot.mjs
+ <url> <out> [w] [h] [dpr] [outWidth] [q]` re-shoots its backdrop from the
+ pond's opening pose and writes a progressive JPEG (needs
+ `npm i --no-save sharp`; the raw PNG lands in `temp/`). Hide the flock
+ (`d.root.visible = false`), the particle pools and the ripples first β the
+ card is the pond with no bird in it.
+- Reading a shot back as data: `node scripts/sample.mjs <png> [x0..1]` prints the
+ colour down that column, sampling every 2.5 % of the height. Colours on the way
+ down a column settle questions a glance cannot ("is that band the bank or the
+ shallow water?"): compare the shot against the same frame with
+ `world.water.visible = false`.
+- Forcing a sleep pose: `m.setActivity('rest')`, `m.activityLeft = 600`, wait
+ ~0.5 s, then `m.startAction('sleep', 600)`, wait ~2.6 s. `m._sleepSide = 1`
+ pins which way the head turns. A duckling needs the pair re-asserted every
+ ~100 ms β family glue replaces the action otherwise.
+- Freezing an action mid-pose: re-assert `d.startAction('upend', 4.5)` and
+ `d.actionTime = d.actionDuration - 1.6` every ~16 ms to hold it in the middle
+ of the move, and pin `pos`/`target`/`facing` in the same tick β `force()`
+ clears the action, and a drifting duck is a moving frame. `app.timeScale = 6`
+ fits a whole upend cycle into the shot window.
+- Camera: the eye is planted, so pose it directly β `rig.look(yaw, pitch, fov)`
+ aims it at once with no easing, `rig.stand(x, z)` puts the stand down
+ somewhere else (it re-reads the ground), then `app.paused = true` freezes the
+ scene for the shot.
+- TypeScript 7 quirk: `instanceof` does not narrow `unknown` values from
+ `import * as THREE`; use explicit casts.
+
+## Open threads
+
+- **Night**: the seasons already carry the sun across the year; a proper
+ day/night cycle within a season is the next step.
+- **Audio**: the calling model is now ported and emits `CallEvent`s on
+ `Pond.onCall`; the next pass is the synth (the Rust's `synth.rs`), propagation
+ and spatialization. The dabbling splash rhythm and bill clack can hang off
+ the same event layer when it is built; the visual `chirp`/`beg` gestures stay
+ as the fallback for muted playback.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..320c5ec
--- /dev/null
+++ b/index.html
@@ -0,0 +1,260 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
+ <meta name="theme-color" content="#0c1620" />
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+ <!-- the drake in the tab: the svg is the master, the pngs are rendered
+ from it by scripts/duck-icon.mjs -->
+ <link rel="icon" type="image/png" sizes="16x16" href="/duck-16.png" />
+ <link rel="icon" type="image/png" sizes="32x32" href="/duck-32.png" />
+ <link rel="icon" type="image/svg+xml" href="/duck.svg" />
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+ <title>Ententeich</title>
+ <!-- the pond's own face, blurred behind the word: the one the card opens
+ on, shot without a duck in it (scripts/loader-shot.mjs) -->
+ <link rel="preload" as="image" href="/pond-loader-portrait.jpg" media="(max-aspect-ratio: 1/1)" />
+ <link rel="preload" as="image" href="/pond-loader-landscape.jpg" media="(min-aspect-ratio: 1/1)" />
+ <style>
+ :root {
+ color-scheme: dark;
+ }
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+ html,
+ body {
+ height: 100%;
+ overflow: hidden;
+ background: #0c1620;
+ overscroll-behavior: none;
+ }
+ #app {
+ position: fixed;
+ inset: 0;
+ }
+ #app canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ touch-action: none;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ }
+ /* the card: the pond it opens on, thrown out of focus behind the word,
+ and gone into the live pond the moment that is ready */
+ #loading {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ background: #0c1620;
+ transition: opacity 0.9s ease;
+ z-index: 2;
+ }
+ #loading.done {
+ opacity: 0;
+ pointer-events: none;
+ }
+ #loading.done .loading__line {
+ opacity: 0;
+ transform: translateY(-5px);
+ }
+ .loading__photo {
+ position: absolute;
+ /* over the edges, so the blur's soft border stays off screen */
+ inset: -7%;
+ background: url('/pond-loader-portrait.jpg') center / 100% 100% no-repeat;
+ /* the wash is stretched to the viewport rather than cover-cropped:
+ under this much blur nobody can tell, and it keeps the far shore
+ on the same line of the screen as the live camera's, at any
+ aspect ratio, so the two meet cleanly when the card goes */
+ filter: blur(17px) saturate(1.04);
+ }
+ @media (min-aspect-ratio: 1/1) {
+ .loading__photo {
+ background-image: url('/pond-loader-landscape.jpg');
+ }
+ }
+ .loading__tint {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(145% 105% at 50% 42%, rgba(8, 20, 30, 0) 28%, rgba(8, 20, 30, 0.4) 100%),
+ linear-gradient(180deg, rgba(8, 20, 30, 0.26), rgba(8, 20, 30, 0.08) 38%, rgba(8, 20, 30, 0.3));
+ }
+ .loading__line {
+ position: relative;
+ display: flex;
+ align-items: baseline;
+ gap: 0.46em;
+ color: #f4f9fd;
+ font: 500 clamp(21px, 5.6vw, 30px) / 1.2 ui-rounded, system-ui, -apple-system, "Segoe UI", sans-serif;
+ letter-spacing: 0.13em;
+ text-shadow: 0 2px 18px rgba(6, 16, 24, 0.55), 0 0 3px rgba(6, 16, 24, 0.4);
+ transition: opacity 0.55s ease, transform 0.9s ease;
+ }
+ /* "quack ... quack", one piece at a time β the word, then the three
+ dots, then the word again. A piece is fully blended in before the
+ next starts: word 0.38 s, dot 0.2 s, 0.05 s between.
+ 0.05-0.43 Β· 0.48-0.68 Β· 0.73-0.93 Β· 0.98-1.18 Β· 1.23-1.61 */
+ .loading__line .word,
+ .loading__dots span {
+ opacity: 0;
+ animation: blend-in 0.38s ease-out forwards;
+ }
+ .loading__line .word:first-child {
+ animation-delay: 0.05s;
+ }
+ .loading__dots {
+ display: inline-flex;
+ gap: 0.07em;
+ }
+ .loading__dots span {
+ animation-duration: 0.2s;
+ }
+ .loading__dots span:nth-child(1) {
+ animation-delay: 0.48s;
+ }
+ .loading__dots span:nth-child(2) {
+ animation-delay: 0.73s;
+ }
+ .loading__dots span:nth-child(3) {
+ animation-delay: 0.98s;
+ }
+ .loading__line .word:last-child {
+ animation-delay: 1.23s;
+ }
+ @keyframes blend-in {
+ from {
+ opacity: 0;
+ filter: blur(6px);
+ }
+ 65% {
+ filter: blur(1px);
+ }
+ to {
+ opacity: 1;
+ filter: blur(0);
+ }
+ }
+ @media (prefers-reduced-motion: reduce) {
+ #loading {
+ transition-duration: 0.3s;
+ }
+ .loading__line .word,
+ .loading__dots span {
+ animation: none;
+ opacity: 1;
+ }
+ }
+ /* the title cards: one line low over the water, hardly a surface at
+ all β the word is the thing, and the pond stays visible through */
+ #hints {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: calc(18px + env(safe-area-inset-bottom, 0px));
+ z-index: 1;
+ padding: 0 12px;
+ pointer-events: none;
+ text-align: center;
+ }
+ .hint {
+ display: inline-block;
+ max-width: min(92vw, 30rem);
+ margin: 0;
+ padding: 0.58rem 0.95rem 0.62rem;
+ border: 1px solid rgba(232, 246, 255, 0.16);
+ border-radius: 13px;
+ background: rgba(8, 18, 26, 0.33);
+ -webkit-backdrop-filter: blur(9px) saturate(1.06);
+ backdrop-filter: blur(9px) saturate(1.06);
+ color: rgba(242, 249, 253, 0.95);
+ font: 500 clamp(13px, 3.5vw, 15px) / 1.35 ui-rounded, system-ui, -apple-system, "Segoe UI", sans-serif;
+ letter-spacing: 0.022em;
+ text-wrap: pretty;
+ text-shadow: 0 1px 10px rgba(0, 8, 14, 0.4);
+ opacity: 0;
+ transform: translateY(7px);
+ transition: opacity 0.55s ease, transform 0.55s ease;
+ }
+ .hint.on {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ .hint a {
+ color: inherit;
+ text-decoration: underline;
+ text-decoration-color: rgba(242, 249, 253, 0.42);
+ text-decoration-thickness: 1px;
+ text-underline-offset: 0.18em;
+ pointer-events: auto;
+ cursor: pointer;
+ }
+ .hint a:hover {
+ text-decoration-color: currentColor;
+ }
+ @media (prefers-reduced-motion: reduce) {
+ .hint {
+ transition-duration: 0.01s;
+ }
+ }
+ .visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+ #error {
+ position: fixed;
+ inset: auto 12px 12px 12px;
+ display: none;
+ padding: 10px 14px;
+ border-radius: 10px;
+ background: rgba(120, 30, 30, 0.85);
+ color: #ffe8e8;
+ font: 500 13px/1.4 system-ui, sans-serif;
+ z-index: 3;
+ }
+ #error.show {
+ display: block;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="app"></div>
+ <div id="loading" role="status" aria-live="polite">
+ <div class="loading__photo" aria-hidden="true"></div>
+ <div class="loading__tint" aria-hidden="true"></div>
+ <p class="loading__line" aria-hidden="true">
+ <span class="word">quack</span>
+ <span class="loading__dots"><span>.</span><span>.</span><span>.</span></span>
+ <span class="word">quack</span>
+ </p>
+ <p class="visually-hidden">loading the pond</p>
+ </div>
+ <div id="error"></div>
+ <script type="module" src="/src/main.ts"></script>
+ <script>
+ // If the module never boots (network, syntax, WebGL), surface it.
+ setTimeout(() => {
+ if (!window.__quackStarted) {
+ const el = document.getElementById('error');
+ el.textContent = 'the pond did not start β check the console, and make sure WebGL is available';
+ el.classList.add('show');
+ }
+ }, 12000);
+ </script>
+ </body>
+</html>
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a749bab
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1598 @@
+{
+ "name": "quack",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "quack",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "three": "^0.186.1"
+ },
+ "devDependencies": {
+ "fflate": "^0.8.3",
+ "puppeteer": "^25.14.0",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.4"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.154.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
+ "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@puppeteer/browsers": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.5.tgz",
+ "integrity": "sha512-UoCo3Pynbm37aLEEoydSiG+t24ui7sLD40+07CKxzxNy4PpSowJMkvlLIiV67XjP1lMVynhQVnuwlTABqBH8LQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "modern-tar": "^0.8.5",
+ "yargs": "^18.0.0"
+ },
+ "bin": {
+ "browsers": "lib/main-cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "peerDependencies": {
+ "proxy-agent": ">=8.0.1",
+ "yauzl": "^2.10.0 || ^3.4.0"
+ },
+ "peerDependenciesMeta": {
+ "proxy-agent": {
+ "optional": true
+ },
+ "yauzl": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
+ "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
+ "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
+ "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
+ "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
+ "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
+ "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
+ "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
+ "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
+ "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
+ "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
+ "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
+ "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
+ "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
+ "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
+ "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
+ "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/chromium-bidi": {
+ "version": "157.0.8090-0",
+ "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz",
+ "integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "zod": "^4.6.5"
+ },
+ "engines": {
+ "node": ">=20.19.0 <22.0.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "devtools-protocol": "*"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devtools-protocol": {
+ "version": "0.0.1696802",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1696802.tgz",
+ "integrity": "sha512-FO5/SENfvc2K8wRnWQflKcwuoTH4dBkXCWTQ9ebIWtuX8JM5+ZvCotaEmkfh21pO7TvIKGTM3hG4xFiq9WTwmg==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
+ "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz",
+ "integrity": "sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/modern-tar": {
+ "version": "0.8.6",
+ "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.6.tgz",
+ "integrity": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.30",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
+ "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.20",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/puppeteer": {
+ "version": "25.14.0",
+ "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.14.0.tgz",
+ "integrity": "sha512-+s1Iqbl9DsGLOaQdgCQyhtgHZsF9hi4Iu8iuBqxzraxUXoRG1QkYQI0CA1/VsNK1gZWJfs50BL/oOb1L+Yz8Yg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "3.2.5",
+ "chromium-bidi": "157.0.8090-0",
+ "devtools-protocol": "0.0.1696802",
+ "lilconfig": "^3.1.3",
+ "puppeteer-core": "25.14.0",
+ "typed-query-selector": "^2.12.3"
+ },
+ "bin": {
+ "puppeteer": "lib/puppeteer/node/cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/puppeteer-core": {
+ "version": "25.14.0",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.14.0.tgz",
+ "integrity": "sha512-LQitnes3tBeVbdZ7V/4iwJPLZGgkl9uBVK0W7YYMh68j/3Y9eNuvj4yl+Fdo7zN0SuajOMrhd7aPj3DifK8k6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "3.2.5",
+ "chromium-bidi": "157.0.8090-0",
+ "devtools-protocol": "0.0.1696802",
+ "typed-query-selector": "^2.12.3",
+ "webdriver-bidi-protocol": "1.0.1",
+ "ws": "^8.22.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
+ "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.154.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.14",
+ "@rolldown/binding-android-arm64": "1.2.14",
+ "@rolldown/binding-darwin-arm64": "1.2.14",
+ "@rolldown/binding-darwin-x64": "1.2.14",
+ "@rolldown/binding-freebsd-x64": "1.2.14",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.14",
+ "@rolldown/binding-linux-arm64-musl": "1.2.14",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.14",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.14",
+ "@rolldown/binding-linux-x64-gnu": "1.2.14",
+ "@rolldown/binding-linux-x64-musl": "1.2.14",
+ "@rolldown/binding-openharmony-arm64": "1.2.14",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.14",
+ "@rolldown/binding-win32-x64-msvc": "1.2.14"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
+ "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.186.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
+ "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typed-query-selector": {
+ "version": "2.12.3",
+ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
+ "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
+ "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.29",
+ "rolldown": "~1.2.12",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/webdriver-bidi-protocol": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz",
+ "integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
+ "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^8.2.1",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0447754
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "quack",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "duck pond",
+ "license": "MIT",
+ "scripts": {
+ "dev": "node scripts/source-zip.mjs && vite",
+ "build": "node scripts/source-zip.mjs && vite build",
+ "preview": "vite preview",
+ "source": "node scripts/source-zip.mjs"
+ },
+ "dependencies": {
+ "three": "^0.186.1"
+ },
+ "devDependencies": {
+ "fflate": "^0.8.3",
+ "puppeteer": "^25.14.0",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.4"
+ },
+ "allowScripts": {
+ "[email protected]": true
+ }
+}
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..301b4a8
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/duck-16.png b/public/duck-16.png
new file mode 100644
index 0000000..d403462
Binary files /dev/null and b/public/duck-16.png differ
diff --git a/public/duck-32.png b/public/duck-32.png
new file mode 100644
index 0000000..f5937c5
Binary files /dev/null and b/public/duck-32.png differ
diff --git a/public/duck.svg b/public/duck.svg
new file mode 100644
index 0000000..2b89c69
--- /dev/null
+++ b/public/duck.svg
@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="duck">
+ <defs>
+ <clipPath id="tile">
+ <rect width="64" height="64" rx="16" />
+ </clipPath>
+ </defs>
+ <g clip-path="url(#tile)">
+ <!-- the pond at night, as the tab's ground -->
+ <rect width="64" height="64" fill="#0c1620" />
+ <!-- the drake in profile: head up and to the right, body off the corner.
+ the neck is drawn along its own axis, so the ring and the breast fall
+ where a mallard wears them, and the back runs along the top -->
+ <g transform="translate(28 33) rotate(123)">
+ <!-- the green neck -->
+ <rect x="-10" y="-11" width="30" height="22" fill="#276b3e" />
+ <!-- the white ring -->
+ <rect x="14" y="-11" width="6" height="22" fill="#e2ddcc" />
+ <!-- the chestnut breast, on the bird's front -->
+ <rect x="20" y="-14" width="60" height="22" fill="#6e4530" />
+ <!-- the grey-brown back along the top contour -->
+ <rect x="20" y="8" width="60" height="6" fill="#b4b0a0" />
+ </g>
+ <!-- the head -->
+ <ellipse cx="33" cy="23" rx="16" ry="15" fill="#276b3e" />
+ <!-- the bill, broad and blunt -->
+ <path d="M46 16 L57.5 16.5 A4.5 4.5 0 0 1 57.5 25.5 L46 26 Z" fill="#c9b13c" />
+ <!-- the eye, with a catchlight in it -->
+ <circle cx="39" cy="19" r="2.7" fill="#101010" />
+ <circle cx="39.9" cy="18.1" r="0.95" fill="#ffffff" />
+ </g>
+</svg>
diff --git a/public/pond-loader-landscape.jpg b/public/pond-loader-landscape.jpg
new file mode 100644
index 0000000..2445264
Binary files /dev/null and b/public/pond-loader-landscape.jpg differ
diff --git a/public/pond-loader-portrait.jpg b/public/pond-loader-portrait.jpg
new file mode 100644
index 0000000..537502c
Binary files /dev/null and b/public/pond-loader-portrait.jpg differ
diff --git a/scripts/duck-icon.mjs b/scripts/duck-icon.mjs
new file mode 100644
index 0000000..2f919bf
--- /dev/null
+++ b/scripts/duck-icon.mjs
@@ -0,0 +1,42 @@
+// The duck in the tab. public/duck.svg is the master; the PNGs are rendered
+// from it so the two can never drift apart. Usage:
+// node scripts/duck-icon.mjs
+// Writes public/duck-32.png (the fallback for browsers that will not take an
+// SVG favicon) and public/apple-touch-icon.png (iOS pins the home-screen
+// icon from this one, and it does not like transparency).
+import puppeteer from 'puppeteer';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const svg = readFileSync(resolve('public/duck.svg'), 'utf8');
+
+const targets = [
+ { size: 16, out: 'public/duck-16.png' },
+ { size: 32, out: 'public/duck-32.png' },
+ { size: 180, out: 'public/apple-touch-icon.png' },
+];
+
+const browser = await puppeteer.launch({
+ headless: true,
+ args: ['--enable-unsafe-swiftshader', '--hide-scrollbars'],
+});
+
+try {
+ for (const { size, out } of targets) {
+ const page = await browser.newPage();
+ await page.setViewport({ width: size, height: size, deviceScaleFactor: 1 });
+ await page.setContent(
+ '<!doctype html><meta charset="utf-8">' +
+ '<style>html,body{margin:0;padding:0;background:transparent}' +
+ `svg{display:block;width:${size}px;height:${size}px}</style>` +
+ svg,
+ { waitUntil: 'load' },
+ );
+ const png = await page.screenshot({ type: 'png', omitBackground: true });
+ writeFileSync(resolve(out), png);
+ console.log(`${out} ${size}x${size} ${(png.length / 1024).toFixed(1)} kB`);
+ await page.close();
+ }
+} finally {
+ await browser.close();
+}
diff --git a/scripts/gait.mjs b/scripts/gait.mjs
new file mode 100644
index 0000000..e852ea9
--- /dev/null
+++ b/scripts/gait.mjs
@@ -0,0 +1,591 @@
+// The gait probe: boot the pond headless, put one duck into a clean walk on
+// the winter ice lid, and shoot it side-on as a filmstrip β then measure the
+// step cycle from the foot positions it recorded. Usage:
+// node scripts/gait.mjs [duckIndexOrName] [frames] [outDir]
+// duckIndexOrName index into quack.pond.ducks, or a duck's name
+// (default: an adult already walking, else the first
+// leaderless adult)
+// frames filmstrip frames (default 24; more than 30 clamps)
+// outDir where the PNGs and report.txt land (default temp/gait)
+//
+// The sim is stepped by hand β app.paused = true, app.step(1/120) in a loop β
+// so the wall clock and the headless frame rate never enter the measurement.
+// Only the last step of each frame is rendered at full size; the rest run
+// tiny, with the shadow pass off, which keeps the capture to a couple of
+// minutes on swiftshader.
+//
+// The dev server is the one on port 5180 (as our other probes use); point
+// QUACK_URL at another if it is running elsewhere.
+import puppeteer from 'puppeteer';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+const url = process.env.QUACK_URL || 'http://localhost:5180/?seed=20261018';
+const sel = process.argv[2] || '';
+const frames = Math.min(Number(process.argv[3]) || 24, 30);
+const outDir = process.argv[4] || 'temp/gait';
+
+const STEP = 1 / 120; // the sim slice app.step is called with
+const PER_FRAME = 5; // slices per filmstrip frame: 1/24 s of sim each
+const WARM_SECONDS = 1.6; // settle before the camera is pinned
+const PLANT_TOL = 0.006; // m over the ground level that still counts as planted
+const CAM_OUT = 2.4; // m the camera stands off the walk line
+const CAM_EYE = 0.55; // m the eye rides over the lid
+
+// The rig's step geometry, straight out of src/duck.ts (kept in step by hand:
+// a change there wants a change here).
+const LEG_LEN = 0.15; // hip to sole, rig units
+const LEG_SWING = 0.5; // radians the hinge swings either side of centre
+const LEG_STANCE = 0.62; // share of a cycle a foot spends planted
+const CADENCE_CAP = 2.6; // the rig's own ceiling on steps per second
+
+const consoleErrors = [];
+const pageErrors = [];
+let exitCode = 0;
+
+// ---------------------------------------------------------------- statistics
+
+function median(xs) {
+ if (!xs.length) return NaN;
+ const s = [...xs].sort((a, b) => a - b);
+ const m = s.length >> 1;
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
+}
+
+function mean(xs) {
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
+}
+
+const mm = (m) => `${(m * 1000).toFixed(1)} mm`;
+const f2 = (v) => (Number.isFinite(v) ? v.toFixed(2) : 'β');
+const deg = (rad) => (rad * 180) / Math.PI;
+
+/** Runs of consecutive frames where `flags[f]` is true, as [first, last] pairs. */
+function runs(flags) {
+ const out = [];
+ let start = -1;
+ for (let f = 0; f <= flags.length; f++) {
+ const on = f < flags.length && flags[f];
+ if (on && start < 0) start = f;
+ else if (!on && start >= 0) {
+ out.push([start, f - 1]);
+ start = -1;
+ }
+ }
+ return out;
+}
+
+/**
+ * How far a foot travels over the ground, against how far the body travels,
+ * between two frames of one interval. `ratio` is the net xz pair (0 pinned,
+ * 1 skating), `path` the walked distance, `fwd` the along-the-walk part and
+ * `lat` the sideways excursion in metres.
+ */
+function ratioOver(a, b, footA, footB) {
+ const dx = b.x - a.x;
+ const dz = b.z - a.z;
+ const body = Math.hypot(dx, dz);
+ if (body < 1e-6) return null;
+ const fx = footB.x - footA.x;
+ const fz = footB.z - footA.z;
+ const ux = dx / body;
+ const uz = dz / body;
+ return {
+ ratio: Math.hypot(fx, fz) / body,
+ fwd: (fx * ux + fz * uz) / body,
+ lat: Math.abs(fx * -uz + fz * ux),
+ };
+}
+
+function pathRatio(frames, a, b, i) {
+ let foot = 0;
+ let body = 0;
+ for (let f = a + 1; f <= b; f++) {
+ const p = frames[f].feet[i];
+ const q = frames[f - 1].feet[i];
+ foot += Math.hypot(p.x - q.x, p.z - q.z);
+ const r = frames[f].root;
+ const s = frames[f - 1].root;
+ body += Math.hypot(r.x - s.x, r.z - s.z);
+ }
+ return body > 1e-6 ? foot / body : null;
+}
+
+// -------------------------------------------------------------------- the tool
+
+const browser = await puppeteer.launch({
+ headless: true,
+ args: ['--enable-unsafe-swiftshader', '--hide-scrollbars'],
+});
+
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 800, height: 600, deviceScaleFactor: 2, isMobile: false, hasTouch: false });
+ page.on('console', (msg) => {
+ const line = `[console.${msg.type()}] ${msg.text()}`;
+ console.log(line);
+ if (msg.type() === 'error') consoleErrors.push(line);
+ });
+ page.on('pageerror', (err) => {
+ const line = `[pageerror] ${err.message}`;
+ console.log(line);
+ pageErrors.push(line);
+ });
+ page.on('requestfailed', (req) => console.log(`[requestfailed] ${req.url()} ${req.failure()?.errorText || ''}`));
+
+ // 1. boot, exactly as scripts/shot.mjs waits for the page
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
+ await page.waitForFunction(() => window.quack && window.quack.app && window.quack.app.started, { timeout: 30000 });
+ await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
+ await page.evaluate(() => document.getElementById('loading')?.remove());
+
+ // 2. winter: the pond freezes to a lid ducks can walk on, and the lid's
+ // swell is gone where the ice is solid, so the ground line is flat.
+ // The year glides to the season over sim time, and the sim is about to
+ // be held, so the glide is burned in one update instead of stepping.
+ const season = await page.evaluate(() => {
+ const q = window.quack;
+ q.seasons.hold(3);
+ q.seasons.update(1.0);
+ q.world.applySeason(q.seasons.state);
+ q.app.paused = true;
+ return { name: q.seasons.label, ice: q.seasons.state.ice, snow: q.seasons.state.snow };
+ });
+
+ // 3. let the freeze settle, then pick the duck, put it on the lid and
+ // point it down a clear line. The settle matters: a moment after the
+ // ice closes, ducks that were paddling steer across the lid, so there
+ // is usually one already walking at pace to take as it stands.
+ const prep = await page.evaluate(({ sel, warm }) => {
+ const q = window.quack;
+ const ducks = q.pond.ducks;
+ const step = 1 / 120;
+ const tiny = () => {
+ q.renderer.shadowMap.enabled = false;
+ q.renderer.setSize(8, 8, false);
+ };
+ const full = () => {
+ q.renderer.setSize(window.innerWidth, window.innerHeight, false);
+ q.renderer.shadowMap.enabled = true;
+ };
+
+ tiny();
+ for (let i = 0; i < 360; i++) q.app.step(step); // 3 s: the flock finds the ice
+
+ let idx = -1;
+ if (sel !== '') {
+ const n = Number(sel);
+ if (Number.isInteger(n) && ducks[n]) idx = n;
+ else idx = ducks.findIndex((d) => d.name.toLowerCase() === sel.toLowerCase());
+ if (idx < 0) {
+ throw new Error(`no duck matches "${sel}" β have ` + ducks.map((d, i) => `${i}:${d.name}`).join(', '));
+ }
+ } else {
+ // one already walking at a real pace: a free adult at cruise, or a
+ // mother at the brood pace; a follower only ever strays, so it is out
+ idx = ducks.findIndex(
+ (d) =>
+ d.state === 'walk' &&
+ d.activity === 'swim' &&
+ !d.leader &&
+ d.kind !== 'duckling' &&
+ Math.hypot(d.vel.x, d.vel.z) > 0.25,
+ );
+ if (idx < 0) {
+ // to be placed: a free adult walks at cruise, where a mother or
+ // a follower only ever strays at 0.2 m/s
+ idx = ducks.findIndex(
+ (d) => !d.leader && d.kind !== 'duckling' && !ducks.some((o) => o.leader === d && o.kind === 'duckling'),
+ );
+ }
+ if (idx < 0) idx = ducks.findIndex((d) => !d.leader && d.kind !== 'duckling');
+ if (idx < 0) idx = 0;
+ }
+ const d = ducks[idx];
+
+ // the flat lid: solid, fully frozen (no swell riding on it) and water
+ const lid = (x, z) => q.world.solidAt(x, z) && q.world.iceAt(x, z) >= 0.999 && q.world.isWater(x, z);
+ const runClear = (x, z, dx, dz, len) => {
+ for (let t = 0; t <= len; t += 0.5) if (!lid(x + dx * t, z + dz * t)) return false;
+ return true;
+ };
+
+ // a duck already walking on the lid with room ahead keeps its ground;
+ // anything else is set down on a chosen line, along x, well clear of
+ // the open lead
+ let start = null;
+ let dir = null;
+ let tier = 'placed on the ice';
+ if (d.state === 'walk' && d.activity === 'swim' && Math.hypot(d.vel.x, d.vel.z) > 0.05 && lid(d.pos.x, d.pos.z)) {
+ for (const s of [1, -1]) {
+ if (runClear(d.pos.x, d.pos.z, s, 0, 6)) {
+ start = { x: d.pos.x, z: d.pos.z };
+ dir = { x: s, z: 0 };
+ tier = 'already walking';
+ break;
+ }
+ }
+ }
+ if (!start) {
+ let best = null;
+ for (let z = 4; z <= 26; z += 2) {
+ for (let x = -24; x <= 24; x += 2) {
+ for (const s of [1, -1]) {
+ if (!runClear(x, z, s, 0, 12)) continue;
+ if (!runClear(x, z + s * 2.4, 0, 0, 0.1)) continue; // the camera needs ice under it too
+ let score = Math.min(Math.hypot(x + 3.5, z - 29), 24);
+ for (const o of ducks) if (o !== d && Math.hypot(o.pos.x - x, o.pos.z - z) < 6) score -= 4;
+ if (!best || score > best.score) best = { x, z, s, score };
+ }
+ }
+ }
+ if (!best) throw new Error('no clear run of ice found for a walk');
+ start = { x: best.x, z: best.z };
+ dir = { x: best.s, z: 0 };
+ }
+
+ // what makes it walk: free adults 'swim' at cruise β including a mother
+ // already on the move, who holds the brood pace; a follower, or a duck
+ // set down with young in tow, keeps to 'stray' (its own target, 0.2 m/s)
+ const hasYoung = ducks.some((o) => o.leader === d && o.kind === 'duckling');
+ const act = d.leader || (hasYoung && tier !== 'already walking') ? 'stray' : 'swim';
+
+ // a stray duckling that loses its mother stops dead, so she is parked
+ // just behind the line where it can still see her
+ if (d.leader && d.kind === 'duckling') {
+ const mom = d.leader;
+ mom.pos.x = start.x - dir.x * 3;
+ mom.pos.z = start.z - dir.z * 3;
+ mom.vel.x = 0;
+ mom.vel.z = 0;
+ mom.target.x = mom.pos.x;
+ mom.target.z = mom.pos.z;
+ mom.setActivity('rest');
+ mom.activityLeft = 300;
+ d.lost = false;
+ }
+
+ // only a placed duck is teleported: one already walking is kept as it
+ // stands, and just sent on down the line we chose
+ if (tier !== 'already walking') {
+ d.pos.x = start.x;
+ d.pos.z = start.z;
+ d.vel.x = 0;
+ d.vel.z = 0;
+ d.roll = 0;
+ d.stride = 1; // skip the blend ramp: the walk starts at full amplitude
+ }
+ d.facing.x = dir.x;
+ d.facing.z = dir.z;
+ d.heading = Math.atan2(dir.x, dir.z);
+ d.quat.set(0, Math.sin(d.heading / 2), 0, Math.cos(d.heading / 2));
+ d.floating = false;
+ d.target.x = d.pos.x + dir.x * 10;
+ d.target.z = d.pos.z + dir.z * 10;
+ d.setActivity(act);
+ d.activityLeft = 300; // no rethinking the activity under the capture
+ d.setState('walk');
+
+ // settle: the damped pose fields (stride, legTuck, roll, the heading
+ // slerp) need a second or so, and stepping is cheap while the render
+ // is the size of a stamp and the shadows are off
+ for (let i = 0; i < Math.round(warm * 120); i++) q.app.step(step);
+ full();
+
+ const p = ducks[idx];
+ const sp = Math.hypot(p.vel.x, p.vel.z);
+ return {
+ idx,
+ name: p.name,
+ kind: p.kind,
+ tier,
+ activity: p.activity,
+ scale: p.scale,
+ state: p.state,
+ speed: sp,
+ start: { x: p.pos.x, z: p.pos.z },
+ dir,
+ warn: sp < 0.15 ? `pace came out at only ${sp.toFixed(2)} m/s` : '',
+ };
+ }, { sel, warm: WARM_SECONDS });
+ if (prep.warn) console.log(`[warn] ${prep.name}: ${prep.warn}`);
+
+ // 4. the camera: side-on, perpendicular to the walk, fixed for the whole
+ // capture. The rig clamps a look()'s yaw to Β±0.85 rad of down-pond, so
+ // the gaze is put on the rig directly β it is the same three numbers
+ // place() reads, just without the guard.
+ const cam = await page.evaluate(({ i, frameCount }) => {
+ const q = window.quack;
+ const d = q.pond.ducks[i];
+ const sp = Math.hypot(d.vel.x, d.vel.z);
+ const fx = sp > 1e-4 ? d.vel.x / sp : d.facing.x;
+ const fz = sp > 1e-4 ? d.vel.z / sp : d.facing.z;
+
+ // aim at the middle of the stretch the capture covers
+ const travel = sp * frameCount * 5 * (1 / 120);
+ const mx = d.pos.x + fx * travel * 0.5;
+ const mz = d.pos.z + fz * travel * 0.5;
+
+ // either side of the walk; the one nearest down-pond keeps the duck
+ // crossing the frame left to right
+ let best = null;
+ for (const s of [1, -1]) {
+ const px = s * fz;
+ const pz = -s * fx;
+ const yaw = Math.atan2(-px, pz);
+ if (!best || Math.abs(yaw) < Math.abs(best.yaw)) best = { px, pz, yaw };
+ }
+ const ex = mx + best.px * 2.4;
+ const ez = mz + best.pz * 2.4;
+
+ const r = q.rig;
+ r.stand(ex, ez, 0.55);
+ const dx = mx - ex;
+ const dz = mz - ez;
+ const targetY = d.root.position.y + 0.02;
+ const yaw = Math.atan2(dx, -dz);
+ const pitch = Math.atan2(targetY - r.eye.y, Math.hypot(dx, dz));
+ r.yaw = r.wantYaw = yaw;
+ r.pitch = r.wantPitch = pitch;
+ r.fov = r.wantFov = 40;
+ r.camera.fov = 40;
+ r.camera.updateProjectionMatrix();
+ return {
+ eye: { x: r.eye.x, y: r.eye.y, z: r.eye.z },
+ mid: { x: mx, y: targetY, z: mz },
+ dist: Math.hypot(dx, dz),
+ yaw,
+ pitch,
+ fov: r.camera.fov,
+ travel,
+ };
+ }, { i: prep.idx, frameCount: frames });
+
+ // 5. the filmstrip: one screenshot per frame, the sim advanced under it
+ mkdirSync(outDir, { recursive: true });
+ const records = [];
+ const files = [];
+ const simPerFrame = PER_FRAME * STEP;
+ for (let f = 0; f < frames; f++) {
+ const rec = await page.evaluate(({ i, k }) => {
+ const q = window.quack;
+ const d = q.pond.ducks[i];
+ const V = d.root.position.constructor;
+ const v = new V();
+ const phaseBefore = d.walkPhase; // the clock as this frame found it
+ // only the last slice is drawn at full size; the rest only need the
+ // world matrices a render leaves behind
+ q.renderer.setSize(8, 8, false);
+ for (let s = 0; s < k - 1; s++) q.app.step(1 / 120);
+ q.renderer.setSize(window.innerWidth, window.innerHeight, false);
+ q.app.step(1 / 120);
+ const p = d.root.position;
+ return {
+ feet: d.feet.map((foot) => {
+ foot.getWorldPosition(v);
+ return { x: v.x, y: v.y, z: v.z };
+ }),
+ root: { x: p.x, y: p.y, z: p.z },
+ pos: { x: d.pos.x, z: d.pos.z },
+ stride: d.stride,
+ roll: d.roll,
+ phase: d.walkPhase,
+ phaseBefore,
+ speed: Math.hypot(d.vel.x, d.vel.z),
+ state: d.state,
+ };
+ }, { i: prep.idx, k: PER_FRAME });
+ rec.t = (f + 1) * simPerFrame;
+ records.push(rec);
+ const file = join(outDir, `f${String(f).padStart(2, '0')}.png`);
+ await page.screenshot({ path: file });
+ files.push(file);
+ }
+
+ // ---------------------------------------------------------- the report
+ const elapsed = records[records.length - 1].t;
+ const scale = prep.scale;
+ const names = ['L (βx)', 'R (+x)'];
+
+ // what the duck actually covered: the root's ground track between the frame
+ // boundaries β the ground the feet have to keep up with
+ let travel = 0;
+ for (let f = 1; f < frames; f++) {
+ travel += Math.hypot(records[f].pos.x - records[f - 1].pos.x, records[f].pos.z - records[f - 1].pos.z);
+ }
+ const span = (frames - 1) * simPerFrame;
+ const speedGround = travel / span;
+
+ // ground level: the median over frames of the lowest of the two feet
+ const ground = median(records.map((r) => Math.min(r.feet[0].y, r.feet[1].y)));
+
+ // planted by height: a foot this close to the ground level is down
+ const planted = names.map((_, i) => records.map((r) => r.feet[i].y < ground + PLANT_TOL));
+
+ // the rig's own clock: which foot is in stance by phase (feet[0] is hipL,
+ // which aimLeg drives from walkPhase itself)
+ const inStancePhase = names.map((_, i) =>
+ records.map((r) => {
+ const c = (r.phase / (Math.PI * 2) + i * 0.5) % 1;
+ return r.stride > 0.5 && c < LEG_STANCE;
+ }),
+ );
+
+ const block = (label, flags, foot) => {
+ const intervals = runs(flags).filter(([a, b]) => b > a);
+ const rs = [];
+ const fwds = [];
+ const lats = [];
+ const paths = [];
+ let samples = 0;
+ for (const [a, b] of intervals) {
+ const r = ratioOver(records[a].root, records[b].root, records[a].feet[foot], records[b].feet[foot]);
+ if (!r) continue;
+ rs.push(r.ratio);
+ fwds.push(r.fwd);
+ lats.push(r.lat);
+ const p = pathRatio(records, a, b, foot);
+ if (p !== null) paths.push(p);
+ samples += b - a + 1;
+ }
+ return { label, intervals: intervals.length, samples, mean: mean(rs), max: rs.length ? Math.max(...rs) : NaN, fwd: mean(fwds), lat: mean(lats), path: mean(paths) };
+ };
+
+ const notPlanted = names.map((_, i) => planted[i].map((v) => !v));
+ const out = [];
+ const line = (s = '') => out.push(s);
+
+ line(`gait probe β ${prep.name} (duck ${prep.idx}, ${prep.kind}), scale ${scale.toFixed(3)}`);
+ line(` ${url} Β· ${season.name} held (ice ${season.ice.toFixed(2)}, snow ${season.snow.toFixed(2)}) Β· seed 20261018`);
+ line(` walk: ${prep.tier}, activity '${prep.activity}', along ${prep.dir.x > 0 ? '+x' : 'βx'} from (${prep.start.x}, ${prep.start.z}), warmed to ${f2(prep.speed)} m/s`);
+ line(` ${frames} frames Γ ${PER_FRAME}Γ1/120 s = ${elapsed.toFixed(3)} s of sim (ground covered ${mm(travel)} at ${f2(speedGround)} m/s; the rig's speed field reads ${f2(mean(records.map((r) => r.speed)))} m/s, ${records.filter((r) => r.state === 'walk').length}/${frames} frames in state 'walk')`);
+ line(` camera: eye (${cam.eye.x.toFixed(2)}, ${cam.eye.y.toFixed(2)}, ${cam.eye.z.toFixed(2)}) ${cam.dist.toFixed(2)} m out, yaw ${deg(cam.yaw).toFixed(1)}Β°, pitch ${deg(cam.pitch).toFixed(1)}Β°, fov ${cam.fov}Β° β fixed`);
+ line();
+ line(`ground level ${mm(ground)} (median of the per-frame lowest foot; a "foot" is the foot mesh origin)`);
+ line(` speed mean ${f2(mean(records.map((r) => r.speed)))} m/s, min ${f2(Math.min(...records.map((r) => r.speed)))} m/s, max ${f2(Math.max(...records.map((r) => r.speed)))} m/s`);
+ line(` stride blend mean ${f2(mean(records.map((r) => r.stride)))} of 1 (walk amplitude)`);
+ line();
+
+ line('STANCE SLIP β threshold planted (foot y < ground + 6 mm)');
+ for (let i = 0; i < 2; i++) {
+ const b = block('', planted[i], i);
+ const v = b.mean < 0.1 ? 'pinned' : b.mean <= 0.25 ? 'slight slide' : 'skating';
+ line(` foot ${names[i]}: ${b.intervals} stance intervals, ${b.samples}/${frames} frames down (${((100 * b.samples) / frames).toFixed(0)}%)`);
+ line(` slip ratio mean ${f2(b.mean)} max ${f2(b.max)} β ${v}`);
+ line(` of it forward ${f2(b.fwd)} sideways ${mm(b.lat)} (walked-path ratio ${f2(b.path)})`);
+ }
+ {
+ const b = block('', notPlanted[0], 0);
+ const b2 = block('', notPlanted[1], 1);
+ line(` moving reference (not planted): L mean ratio ${f2(b.mean)} over ${b.intervals} runs, R ${f2(b2.mean)} over ${b2.intervals}`);
+ }
+ line();
+
+ line('SWING CLEARANCE β over frames the foot is not planted');
+ for (let i = 0; i < 2; i++) {
+ const ys = records.filter((_, f) => notPlanted[i][f]).map((r) => r.feet[i].y - ground);
+ const max = ys.length ? Math.max(...ys) : NaN;
+ const med = median(ys);
+ line(` foot ${names[i]}: max ${mm(max)}, median ${mm(med)}, min ${mm(ys.length ? Math.min(...ys) : NaN)}, ${ys.length}/${frames} frames up β ${max < 0.008 ? 'dragging' : 'clears the ground'}`);
+ }
+ // and how steady the foot is while it counts as down: a sole that rides
+ // up and down through the stance is not pinned however slow it slides
+ for (let i = 0; i < 2; i++) {
+ const ys = records.filter((_, f) => planted[i][f]).map((r) => r.feet[i].y - ground);
+ if (ys.length > 1) {
+ line(` foot ${names[i]} height while planted: ${mm(Math.min(...ys))} to ${mm(Math.max(...ys))} (spread ${mm(Math.max(...ys) - Math.min(...ys))} over ${ys.length} frames)`);
+ }
+ }
+ line();
+
+ line('BY THE RIG CLOCK β same split, but stance/swing taken from walkPhase');
+ for (let i = 0; i < 2; i++) {
+ const b = block('', inStancePhase[i], i);
+ const ys = records.filter((_, f) => !inStancePhase[i][f]).map((r) => r.feet[i].y - ground);
+ line(` foot ${names[i]}: stance ${b.samples}/${frames} frames (${((100 * b.samples) / frames).toFixed(0)}%, stance share is ${(LEG_STANCE * 100).toFixed(0)}%)`);
+ line(` slip over phase-stance mean ${f2(b.mean)} max ${f2(b.max)} forward ${f2(b.fwd)} sideways ${mm(b.lat)}`);
+ line(` clearance over phase-swing max ${mm(ys.length ? Math.max(...ys) : NaN)} median ${mm(median(ys))}`);
+ }
+ // how flat the foot is while it is nominally down
+ for (let i = 0; i < 2; i++) {
+ const ys = records.filter((_, f) => inStancePhase[i][f]).map((r) => r.feet[i].y - ground);
+ line(` foot ${names[i]} height while in phase-stance min ${mm(Math.min(...ys))} max ${mm(Math.max(...ys))} spread ${mm(Math.max(...ys) - Math.min(...ys))}`);
+ }
+ line();
+
+ // cadence: lift-offs counted off the foot's own height record, and the
+ // walkPhase unwrapped over the same sim time
+ line('CADENCE');
+ let cadFromFeet = NaN;
+ let events = 0;
+ const rates = [];
+ for (let i = 0; i < 2; i++) {
+ const ev = [];
+ for (let f = 1; f < frames; f++) if (planted[i][f - 1] && !planted[i][f]) ev.push(records[f].t);
+ let rate = NaN;
+ if (ev.length >= 2) rate = (ev.length - 1) / (ev[ev.length - 1] - ev[0]);
+ else if (ev.length === 1) rate = 1 / elapsed;
+ if (Number.isFinite(rate)) rates.push(rate);
+ line(` lift-offs foot ${names[i]}: ${ev.length} event${ev.length === 1 ? '' : 's'}${ev.length ? ` at t=${ev.map((t) => t.toFixed(2)).join(', ')} s` : ''} β ${f2(rate)} per second`);
+ events += ev.length;
+ }
+ cadFromFeet = rates.length ? mean(rates) : NaN;
+ // the phase the rig advances inside each frame, summed over all of them β
+ // frame to frame would miss the first frame's own slice of the second
+ let cycles = 0;
+ for (let f = 0; f < frames; f++) cycles += (((records[f].phase - records[f].phaseBefore) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
+ cycles /= Math.PI * 2;
+ const cadPhase = cycles / elapsed;
+ const speedMean = mean(records.map((r) => r.speed));
+ const cadExpected = (speedMean * LEG_STANCE) / (2 * LEG_LEN * Math.sin(LEG_SWING) * scale);
+ const reach = 2 * LEG_LEN * Math.sin(LEG_SWING) * scale;
+ line(` from lift-offs ${f2(cadFromFeet)} cycles/s β mean of the feet's rise-event rates (a foot lifts once a cycle)`);
+ line(` both feet ${events} rise events over ${elapsed.toFixed(3)} s = ${f2(events / elapsed)} steps/s (two steps a cycle)`);
+ line(` from walkPhase ${f2(cadPhase)} cycles/s (${cycles.toFixed(2)} cycles unwrapped over ${elapsed.toFixed(3)} s)`);
+ line(` expected ${f2(cadExpected)} cycles/s = speed ${f2(speedMean)} Γ stance ${LEG_STANCE} / (2 Γ ${LEG_LEN} Γ sin(${LEG_SWING}) Γ ${scale.toFixed(3)})`);
+ if (cadExpected > CADENCE_CAP) line(` note the rig caps cadence at ${CADENCE_CAP}/s, so this walk is above what the legs can step`);
+ line(` step reach ${mm(reach)} of ground per stance (2Β·LΒ·sinΒ·scale); body travel per cycle ${mm(cadPhase > 0 ? speedGround / cadPhase : NaN)} (measured)`);
+ {
+ const implied = (cadPhase * reach) / LEG_STANCE;
+ line(` ground covered the measured cadence sweeps ${f2(implied)} m/s of ground under the duck, against the ${f2(speedGround)} m/s it covered β ${(100 * (speedGround > 0 ? implied / speedGround : 0)).toFixed(0)}% (100% = every metre is stepped, less = the feet skate with the body)`);
+ }
+ if (events < 3) line(` note ${events} lift-off${events === 1 ? '' : 's'} is a coarse count over ${elapsed.toFixed(2)} s of sim β walkPhase is the precise figure`);
+ line();
+
+ // bob and roll, off the same records
+ const ys = records.map((r) => r.root.y);
+ const bob = Math.max(...ys) - Math.min(...ys);
+ let bumps = 0;
+ for (let f = 1; f < frames - 1; f++) if (ys[f] >= ys[f - 1] && ys[f] > ys[f + 1]) bumps++;
+ const rolls = records.map((r) => r.roll);
+ const rollPP = Math.max(...rolls) - Math.min(...rolls);
+ const bobVerdict = bob <= 0.018 * scale ? 'step-weight' : bob > 0.025 * scale ? 'hop' : 'in between';
+ line('BOB AND ROLL');
+ line(` root y peak-to-peak ${mm(bob)} (${mm(bob / scale)} at scale 1), ${f2(bumps / elapsed)} bumps/s β ${bobVerdict} (thresholds ${mm(0.018 * scale)} / ${mm(0.025 * scale)} at this scale)`);
+ line(` roll peak-to-peak ${deg(rollPP).toFixed(1)}Β°, mean |roll| ${deg(mean(rolls.map(Math.abs))).toFixed(1)}Β°`);
+ line();
+
+ line(`frames β ${frames} PNGs at ${frames * 2 * PER_FRAME} ms of sim each, ${outDir}`);
+ for (const file of files) line(` ${file}`);
+
+ const report = out.join('\n');
+ console.log(`\n${report}\n`);
+ writeFileSync(join(outDir, 'report.txt'), report + '\n');
+
+ // 6. the run's health: a duck that never walked, an error in the console
+ if (records.filter((r) => r.state === 'walk').length < frames) {
+ console.log(`[warn] ${frames - records.filter((r) => r.state === 'walk').length} frame(s) were not in state 'walk'`);
+ }
+ if (!(ground + PLANT_TOL > 0)) console.log('[warn] ground level is not above zero β the duck may not be on the lid');
+ if (consoleErrors.length || pageErrors.length) {
+ console.log(`[fatal] ${consoleErrors.length} console error(s), ${pageErrors.length} page error(s)`);
+ exitCode = 1;
+ }
+} catch (err) {
+ console.log(`[fatal] ${err && err.stack ? err.stack : err}`);
+ exitCode = 1;
+} finally {
+ await browser.close();
+}
+
+process.exit(exitCode);
diff --git a/scripts/loader-shot.mjs b/scripts/loader-shot.mjs
new file mode 100644
index 0000000..493aee9
--- /dev/null
+++ b/scripts/loader-shot.mjs
@@ -0,0 +1,66 @@
+// The loading card's backdrop: the pond as it opens, with not a duck in it,
+// shot at one device shape and squeezed into a progressive JPEG small enough to
+// be down before the module is β the card blurs it anyway, so it wants to be
+// cheap rather than sharp. Usage:
+// node scripts/loader-shot.mjs [url] [out] [width] [height] [dpr] [outWidth] [quality]
+// The raw PNG is kept under temp/ so the capture can be looked at unblurred.
+// Needs `npm i --no-save sharp` β the only thing in the repo that uses it.
+import puppeteer from 'puppeteer';
+import sharp from 'sharp';
+import { mkdirSync } from 'node:fs';
+import { dirname, resolve, basename } from 'node:path';
+
+const url = process.argv[2] || 'http://localhost:5180/';
+const out = resolve(process.argv[3] || 'public/pond-loader.jpg');
+const width = Number(process.argv[4]) || 430;
+const height = Number(process.argv[5]) || 932;
+const dpr = Number(process.argv[6]) || 2;
+const outWidth = Number(process.argv[7]) || width;
+const quality = Number(process.argv[8]) || 50;
+
+const browser = await puppeteer.launch({
+ headless: true,
+ args: ['--enable-unsafe-swiftshader', '--hide-scrollbars'],
+});
+
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width, height, deviceScaleFactor: dpr, isMobile: dpr > 1, hasTouch: dpr > 1 });
+ page.on('pageerror', (err) => console.log(`[pageerror] ${err.message}`));
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
+ await page.waitForFunction('window.quack && window.quack.app.started', { timeout: 30000 });
+
+ // hold the pond at the pose it opens on, with the flock out of the shot, the
+ // odd feather or droplet out of it too, and the card itself out of the shot,
+ // then wait for the frame it is drawn in
+ await page.evaluate(async () => {
+ const q = window.quack;
+ q.app.paused = true;
+ document.getElementById('loading')?.remove();
+ for (const d of q.pond.ducks) d.root.visible = false;
+ for (const pool of [q.effects.feathers, q.effects.bits, q.effects.leaves, q.effects.snowflakes]) {
+ pool.mesh.visible = false;
+ }
+ for (const r of q.effects.ripples) r.mesh.visible = false;
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+ });
+
+ const raw = resolve('temp/loader', basename(out).replace(/\.jpe?g$/i, '.png'));
+ mkdirSync(dirname(raw), { recursive: true });
+ await page.screenshot({ path: raw, type: 'png' });
+
+ const png = await sharp(raw);
+ const meta = await png.metadata();
+ mkdirSync(dirname(out), { recursive: true });
+ const info = await png
+ .resize({ width: outWidth, kernel: 'lanczos3' })
+ .jpeg({ quality, progressive: true, mozjpeg: true, chromaSubsampling: '4:2:0' })
+ .toFile(out);
+
+ console.log(
+ `${out} ${info.width}x${info.height} ${(info.size / 1024).toFixed(1)} kB ` +
+ `(from ${meta.width}x${meta.height} at ${dpr}x) raw: ${raw}`,
+ );
+} finally {
+ await browser.close();
+}
diff --git a/scripts/sample.mjs b/scripts/sample.mjs
new file mode 100644
index 0000000..80ef72f
--- /dev/null
+++ b/scripts/sample.mjs
@@ -0,0 +1,41 @@
+// Sample the middle column of a PNG, top to bottom, so a shot can be read as
+// data instead of eyeballed. Usage:
+// node scripts/sample.mjs <png> [x0..1]
+import puppeteer from 'puppeteer';
+import { pathToFileURL } from 'node:url';
+import { resolve } from 'node:path';
+
+const file = resolve(process.argv[2] || 'shot.png');
+const fx = Number(process.argv[3] ?? 0.5);
+const url = pathToFileURL(file).href;
+
+const browser = await puppeteer.launch({
+ headless: true,
+ args: ['--allow-file-access-from-files', '--hide-scrollbars'],
+});
+try {
+ const page = await browser.newPage();
+ await page.goto(url);
+ const rows = await page.evaluate((frac) => {
+ const img = document.querySelector('img');
+ img.style.imageRendering = 'pixelated';
+ const c = document.createElement('canvas');
+ c.width = img.naturalWidth;
+ c.height = img.naturalHeight;
+ const g = c.getContext('2d');
+ g.drawImage(img, 0, 0);
+ const x = Math.min(img.naturalWidth - 1, Math.round(frac * (img.naturalWidth - 1)));
+ const out = [];
+ for (let i = 0; i <= 40; i++) {
+ const y = Math.min(img.naturalHeight - 1, Math.round((i / 40) * (img.naturalHeight - 1)));
+ const d = g.getImageData(x, y, 1, 1).data;
+ const hex = [d[0], d[1], d[2]].map((v) => v.toString(16).padStart(2, '0')).join('');
+ out.push(`${String(i * 2.5).padStart(5)}% #${hex}`);
+ }
+ return out;
+ }, fx);
+ console.log(`${file} @ x=${fx}`);
+ console.log(rows.join('\n'));
+} finally {
+ await browser.close();
+}
diff --git a/scripts/shot.mjs b/scripts/shot.mjs
new file mode 100644
index 0000000..6bd509c
--- /dev/null
+++ b/scripts/shot.mjs
@@ -0,0 +1,39 @@
+// Screenshot helper for tuning: opens the pond in headless Chrome at a phone-ish
+// viewport and writes a PNG. Usage:
+// node scripts/shot.mjs [url] [out] [width] [height] [waitMs]
+import puppeteer from 'puppeteer';
+import { mkdirSync } from 'node:fs';
+import { dirname } from 'node:path';
+
+const url = process.argv[2] || 'http://localhost:5173/';
+const out = process.argv[3] || 'shot.png';
+const width = Number(process.argv[4]) || 430;
+const height = Number(process.argv[5]) || 932;
+const waitMs = Number(process.argv[6]) || 5000;
+
+const browser = await puppeteer.launch({
+ headless: true,
+ args: ['--enable-unsafe-swiftshader', '--hide-scrollbars'],
+});
+
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width, height, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
+ page.on('console', (msg) => console.log(`[console.${msg.type()}] ${msg.text()}`));
+ page.on('pageerror', (err) => console.log(`[pageerror] ${err.message}`));
+ page.on('requestfailed', (req) => console.log(`[requestfailed] ${req.url()} ${req.failure()?.errorText || ''}`));
+
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
+ await new Promise((r) => setTimeout(r, waitMs));
+ // optional JS to run before the shot, e.g. to pose the camera or season
+ const js = process.argv[7];
+ if (js) {
+ await page.evaluate(js);
+ await new Promise((r) => setTimeout(r, 1200));
+ }
+ mkdirSync(dirname(out), { recursive: true });
+ await page.screenshot({ path: out });
+ console.log(`saved ${out}`);
+} finally {
+ await browser.close();
+}
diff --git a/scripts/source-zip.mjs b/scripts/source-zip.mjs
new file mode 100644
index 0000000..934e77b
--- /dev/null
+++ b/scripts/source-zip.mjs
@@ -0,0 +1,55 @@
+// Pack the site's own source for the fifth title card. The fallback has to be
+// more than a link to a repository that may not exist yet: this zip is built
+// from the same working tree, so anyone can download the pond, unpack it and
+// run `npm install && npm run build`.
+//
+// It deliberately includes the committed audio wasm and the Rust stage patch;
+// it excludes node_modules, builds, scratch screenshots, the vendored Rust
+// clone and every local credential file.
+import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
+import { basename, dirname, join, relative, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { zipSync } from 'fflate';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const output = join(root, 'public', 'quack-source.zip');
+const skipDirs = new Set(['node_modules', 'dist', 'temp', '.git', '.wrangler', 'duck-pond']);
+const skipFiles = new Set(['quack-source.zip', '.credentials.local.md']);
+const entries = [
+ 'index.html',
+ 'package.json',
+ 'package-lock.json',
+ 'tsconfig.json',
+ 'vite.config.ts',
+ 'README.md',
+ 'LICENSE',
+ '.gitignore',
+ 'src',
+ 'public',
+ 'scripts',
+ 'docs',
+ 'audio',
+];
+const files = {};
+
+/** Add one file or directory, skipping generated output and local-only files. */
+function add(path) {
+ const base = basename(path);
+ if (skipDirs.has(base) || skipFiles.has(base) || base.startsWith('.env')) return;
+ const name = relative(root, path).split(sep).join('/');
+ if (statSync(path).isDirectory()) {
+ for (const child of readdirSync(path)) add(join(path, child));
+ return;
+ }
+ files[name] = readFileSync(path);
+}
+
+for (const entry of entries) {
+ const path = join(root, entry);
+ if (existsSync(path)) add(path);
+}
+
+mkdirSync(join(root, 'public'), { recursive: true });
+const zip = zipSync(files, { level: 9 });
+writeFileSync(output, zip);
+console.log(`source zip ${relative(root, output)} (${(zip.length / 1024).toFixed(0)} KiB, ${Object.keys(files).length} files)`);
diff --git a/src/camera.ts b/src/camera.ts
new file mode 100644
index 0000000..344711b
--- /dev/null
+++ b/src/camera.ts
@@ -0,0 +1,201 @@
+// The camera rig: a pair of eyes planted on the near shore. Dragging turns the
+// head β yaw and pitch, in place, the way a person standing at the water's edge
+// looks around β and pinching opens or closes the view. The viewpoint never
+// moves, so the pond stays where it is and only the gaze swings across it.
+import * as THREE from 'three';
+import { clamp, damp } from './utils';
+import { CAMERA, WORLD } from './config';
+import type { World } from './world';
+
+/** the half-width of horizon a full lean-in keeps, as a tangent */
+const FOV_MIN_HALF_HORIZON = Math.tan((CAMERA.fov.min * Math.PI) / 360) * CAMERA.fov.refAspect;
+
+/** The narrowest field of view a screen of this shape may lean in to, in degrees. */
+function fovFloor(aspect: number): number {
+ if (!(aspect > 0)) return CAMERA.fov.min;
+ // narrower than 16:9, the floor rises to keep the horizon width; wider, it
+ // stays at the plain angle, so no screen ever leans in to a vertical slit
+ return Math.max(CAMERA.fov.min, (2 * Math.atan(FOV_MIN_HALF_HORIZON / aspect) * 180) / Math.PI);
+}
+
+const _target = new THREE.Vector3();
+
+export class CameraRig {
+ camera: THREE.PerspectiveCamera;
+ world: World;
+ /** where the viewer stands, on the bank */
+ readonly eye = new THREE.Vector3();
+ /** the gaze: yaw turns right from down-pond, pitch lifts the chin (radians) */
+ yaw = 0;
+ pitch: number = CAMERA.pitchStart;
+ /** the field of view in degrees β smaller leans in closer */
+ fov: number = CAMERA.fov.start;
+
+ // what the head is easing towards
+ private wantYaw = 0;
+ private wantPitch: number = CAMERA.pitchStart;
+ private wantFov: number = CAMERA.fov.start;
+ // who is touching the screen, and where
+ private fingers = new Map<number, { x: number; y: number }>();
+ private dragId = -1;
+ private lastX = 0;
+ private lastY = 0;
+ private pinchSpan = 0;
+
+ constructor(camera: THREE.PerspectiveCamera, domElement: HTMLElement, world: World) {
+ this.camera = camera;
+ this.world = world;
+
+ this.eye.set(
+ WORLD.viewStand.x,
+ world.surfaceHeightAt(WORLD.viewStand.x, WORLD.viewStand.z) + CAMERA.eyeHeight,
+ WORLD.viewStand.z,
+ );
+ this.camera.fov = this.fov;
+ this.camera.updateProjectionMatrix();
+ this.place();
+ this.listen(domElement);
+ }
+
+ update(dt: number): void {
+ this.yaw = damp(this.yaw, this.wantYaw, CAMERA.turnRate, dt);
+ this.pitch = damp(this.pitch, this.wantPitch, CAMERA.turnRate, dt);
+ // eased in log space as well, or the last degrees of a lean-in would
+ // cross the frame in a single step
+ this.fov = Math.exp(damp(Math.log(this.fov), Math.log(this.wantFov), CAMERA.zoomRate, dt));
+ // a screen that changes shape under us β a rotate, a resized window β
+ // can leave the gaze narrower than its own floor allows
+ const floor = fovFloor(this.camera.aspect);
+ if (this.fov < floor) {
+ this.fov = floor;
+ this.wantFov = Math.max(this.wantFov, floor);
+ }
+ if (Math.abs(this.camera.fov - this.fov) > 1e-3) {
+ this.camera.fov = this.fov;
+ this.camera.updateProjectionMatrix();
+ }
+ this.place();
+ }
+
+ /**
+ * Aim the head at once β for screenshots and the console, with no easing.
+ * `yaw` turns right from down-pond, `pitch` lifts the chin, `fov` is degrees.
+ */
+ look(yaw: number, pitch: number, fov = this.fov): this {
+ this.wantYaw = this.yaw = clamp(yaw, -CAMERA.yawLimit, CAMERA.yawLimit);
+ this.wantPitch = this.pitch = clamp(pitch, CAMERA.pitchMin, CAMERA.pitchMax);
+ this.wantFov = this.fov = clamp(fov, fovFloor(this.camera.aspect), CAMERA.fov.max);
+ this.camera.fov = this.fov;
+ this.camera.updateProjectionMatrix();
+ this.place();
+ return this;
+ }
+
+ /** Put the eye down somewhere else β for close-ups. Reads the ground again. */
+ stand(x: number, z: number, height = CAMERA.eyeHeight): this {
+ this.eye.set(x, this.world.surfaceHeightAt(x, z) + height, z);
+ this.place();
+ return this;
+ }
+
+ /** Sit the camera on the eye and turn it to the gaze. */
+ private place(): void {
+ const c = this.camera;
+ c.position.copy(this.eye);
+ const flat = Math.cos(this.pitch);
+ _target.set(
+ this.eye.x + Math.sin(this.yaw) * flat,
+ this.eye.y + Math.sin(this.pitch),
+ this.eye.z - Math.cos(this.yaw) * flat,
+ );
+ c.lookAt(_target);
+ }
+
+ private turn(dx: number, dy: number): void {
+ // the view follows the finger: drag right and the head turns right,
+ // drag down and the gaze drops to the water
+ this.wantYaw = clamp(this.wantYaw + dx * CAMERA.turnPerPx, -CAMERA.yawLimit, CAMERA.yawLimit);
+ this.wantPitch = clamp(this.wantPitch - dy * CAMERA.turnPerPx, CAMERA.pitchMin, CAMERA.pitchMax);
+ }
+
+ /**
+ * Zoom by a step in log space: a pinch or a wheel notch is worth the same
+ * fraction of the view whether it is wide open or leaning on a duck, so the
+ * step never feels slow at one end of the range and wild at the other.
+ */
+ private zoom(logStep: number): void {
+ this.wantFov = clamp(this.wantFov * Math.exp(logStep), fovFloor(this.camera.aspect), CAMERA.fov.max);
+ }
+
+ private listen(dom: HTMLElement): void {
+ dom.addEventListener('pointerdown', (e) => {
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
+ this.fingers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ if (this.fingers.size === 1) {
+ this.dragId = e.pointerId;
+ this.lastX = e.clientX;
+ this.lastY = e.clientY;
+ } else {
+ // a second finger means a pinch, not a turn
+ this.dragId = -1;
+ this.pinchSpan = this.span();
+ }
+ try {
+ dom.setPointerCapture(e.pointerId);
+ } catch {
+ // a synthetic or already-released pointer: nothing to capture
+ }
+ });
+
+ dom.addEventListener('pointermove', (e) => {
+ const finger = this.fingers.get(e.pointerId);
+ if (!finger) return;
+ finger.x = e.clientX;
+ finger.y = e.clientY;
+
+ if (this.fingers.size >= 2) {
+ const span = this.span();
+ if (this.pinchSpan > 0) this.zoom((this.pinchSpan - span) * CAMERA.pinchPerPx);
+ this.pinchSpan = span;
+ return;
+ }
+ if (e.pointerId !== this.dragId) return;
+ this.turn(e.clientX - this.lastX, e.clientY - this.lastY);
+ this.lastX = e.clientX;
+ this.lastY = e.clientY;
+ });
+
+ const release = (e: PointerEvent) => {
+ this.fingers.delete(e.pointerId);
+ this.pinchSpan = 0;
+ if (this.fingers.size === 0) {
+ this.dragId = -1;
+ return;
+ }
+ if (this.fingers.size === 1) {
+ // one finger left over after a pinch: let it carry on turning
+ const [id, finger] = [...this.fingers.entries()][0];
+ this.dragId = id;
+ this.lastX = finger.x;
+ this.lastY = finger.y;
+ }
+ };
+ dom.addEventListener('pointerup', release);
+ dom.addEventListener('pointercancel', release);
+
+ dom.addEventListener(
+ 'wheel',
+ (e) => {
+ e.preventDefault();
+ this.zoom(e.deltaY * CAMERA.wheelPerPx);
+ },
+ { passive: false },
+ );
+ }
+
+ private span(): number {
+ const fingers = [...this.fingers.values()];
+ if (fingers.length < 2) return 0;
+ return Math.hypot(fingers[0].x - fingers[1].x, fingers[0].y - fingers[1].y);
+ }
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..634e434
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,593 @@
+// Every knob in quack, grouped by the scope that spends it.
+//
+// A group is its own export, so a module imports only what it needs and the
+// numbers stay out of the code that reads them:
+//
+// import { CAMERA, WORLD } from './config';
+//
+// The shape each group takes follows the notes in docs/port-notes.md: layout
+// lives in WORLD, the flock's day and pace in FLOCK, its voice in CALLS, a lap
+// in LAP, a flight in FLIGHT, the bread and the flock's answer to it in BREAD,
+// and so on. Values that had a name in the port (`ICE_STILL`, `HULL_SPEED`,
+// `SHORE_GATE`) keep a close one here, lower-cased under its group.
+//
+// What is *not* here: the seasonal colour keys and species proportions, which
+// are read as whole pieces of art where they are written; the water shader's
+// wave shapes, which are a matched pair with `waterHeightAt`; and the multi-
+// number pose curves in duck.ts, which are a rig, not a dial. Their tuned
+// extremes all point back to the groups below.
+
+// ------------------------------------------------------------------ app
+
+/** Boot and debug plumbing: what the page starts with before there is a pond. */
+export const APP = {
+ /** the pond when the URL carries no `?seed=` */
+ seed: 20261018,
+ /** the year opens in summer: 0 spring, 1 summer, 2 autumn, 3 winter */
+ openSeason: 1,
+ /** ACES exposure, before the season keys settle in */
+ exposure: 0.98,
+ /** device pixel ratio is capped here: phones report 3+ */
+ maxPixelRatio: 2,
+ /** how long the browser gets to paint the loading card before boot blocks it */
+ bootDelay: 60,
+ /** the longest wall-clock slice one frame may hand the sim, in seconds */
+ maxStep: 0.05,
+ /** the clear colour the scene opens under, before the first season applies */
+ clear: 0xc3dcee,
+ /** initial fog before the season keys settle in (replaced during boot) */
+ fog: { near: 160, far: 720 },
+ /** T holds the year here while it is toggled */
+ slowMotion: 0.25,
+} as const;
+
+/** The one invisible gesture rule on the canvas. */
+export const INPUT = {
+ /** px a press may drift and still count as a tap rather than a drag */
+ tapSlop: 12,
+} as const;
+
+/** The opening card. The word's CSS animation lives in index.html; these are
+ * the waits around it, in ms. */
+export const LOADER = {
+ /** hold the pond back this long once the world is ready, for the word */
+ min: 1950,
+ /** the card's fade into the live pond (keep in step with `.done`) */
+ fade: 900,
+ /** after the fade, the empty element is removed */
+ removeDelay: 400,
+} as const;
+
+/** The title cards that explain the pond after the loader has gone, in ms. */
+export const HINTS = {
+ /** quiet water between the loader's fade and the first card */
+ afterLoader: 1000,
+ /** how long a controls card stays up */
+ tipHold: 3000,
+ /** how long the credit card stays up */
+ creditHold: 7500,
+ /** fade out (keep in step with the `.hint` transition) */
+ fade: 600,
+ /** quiet water between two cards */
+ gap: 500,
+} as const;
+
+/** The fifth title card: a public source link, or a download of this site's own source. */
+export const SOURCE = {
+ /**
+ * Public repository URL. Leave empty (or set to null) while the repo is
+ * private: the card then offers `quack-source.zip`, built from the working
+ * tree by `scripts/source-zip.mjs`.
+ */
+ src_url: '' as string | null,
+ /** how long the source card stays up, in ms */
+ hold: 6500,
+} as const;
+
+// --------------------------------------------------------------- camera
+
+/**
+ * The planted eye on the near shore. The viewpoint never moves: dragging turns
+ * the head in place, and pinching/zooming narrows the frame around the gaze.
+ */
+export const CAMERA = {
+ /** how high the eye rides above the ground it stands on */
+ eyeHeight: 2.0,
+ /** clipping planes for the whole scene */
+ near: 0.15,
+ far: 6000,
+ /** how far the head may turn, each way, from straight down the pond */
+ yawLimit: 0.85,
+ /** how far the head may tip: down at the water, up at the sky */
+ pitchMin: -1.0,
+ pitchMax: 0.45,
+ /** where the gaze rests at boot: a touch below level */
+ pitchStart: -0.06,
+ fov: {
+ /** the field of view in degrees: the widest view and the closest lean */
+ min: 10,
+ max: 78,
+ /** the view it opens on */
+ start: 52,
+ /** the reference screen shape the narrow floor is held to */
+ refAspect: 16 / 9,
+ },
+ /** radians a drag of one pixel turns the head */
+ turnPerPx: 0.0028,
+ /** zoom a wheel pixel asks for, as log(fov) */
+ wheelPerPx: 0.0025,
+ /** zoom a stretched pinch pixel asks for, as log(fov) */
+ pinchPerPx: 0.0022,
+ /** how quickly the head catches up with the finger */
+ turnRate: 10,
+ zoomRate: 10,
+} as const;
+
+// -------------------------------------------------------------- seasons
+
+/** One slow year: the timeline's length and how gently it turns. */
+export const SEASONS = {
+ /** seconds spent in one season; a full year is four of them */
+ seconds: 420,
+ /** the tail of each season blends into the next, the rest holds still */
+ blend: 0.4,
+ /** a requested season key morphs in about this long, near or far */
+ jump: 0.8,
+} as const;
+
+// ---------------------------------------------------------------- world
+
+/**
+ * The pond's water-plan and the ground around it, in world x/z. The pond is a
+ * wide ellipse in front of the shore; ducks live in the middle of it and keep
+ * to the beach strip when they come out.
+ */
+export const WORLD = {
+ /** the terrain and water meshes: a square around the pond, and its vertices */
+ size: 1500,
+ segments: 432,
+ /** the surface every height is measured from */
+ waterLevel: 0,
+ /** fog the world falls back to before a season sets its own */
+ fog: { near: 210, far: 900 },
+ pond: { cx: 0, cz: -22, rx: 46, rz: 64 },
+ /** the beach strip: x half-width and the z band ducks walk on */
+ bank: { x: 26, z0: 41.5, z1: 52.5 },
+ /** the spot just off the bank the thrower stands at (the camera side) */
+ thrower: { x: 0, z: 47 },
+ /** where the viewer stands on the shore, right at the water's edge */
+ viewStand: { x: 0, z: 48.3 },
+ /**
+ * Preferred stretches of open water, as world z: the near shore sits at
+ * high z, so `near` hugs it and `far` reaches the deep middle. A soft
+ * preference β a pick with no room in its window falls back to any water
+ * deep enough.
+ */
+ bands: {
+ near: { lo: 22, hi: 38 },
+ mid: { lo: 0, hi: 22 },
+ far: { lo: -38, hi: 4 },
+ },
+ /** bread can be chased anywhere in this stretch of open water, plus the bank */
+ chase: { x: 120, zMin: -120, zMax: 43.5 },
+ /**
+ * The waterline in front of x: a wobble that fades in beyond the duck pond,
+ * so the middle stretch runs straight and the flock has a reliable edge.
+ */
+ shoreline: { z: 46, wobble: 10, noise: 22, straightFrom: 38, straightTo: 95 },
+ /** the walkable bank: along the waterline, and inland past its edge */
+ shore: { halfSpan: 130, edge: 3, depth: 18 },
+ /** the beach's rise: at the waterline, over the meadow, and its noise */
+ bankFloor: { edge: 0.7, rise: 2.3, span: 90, noise: 0.6 },
+ /** depths that decide what a duck can do: wade, swim, or dive for a dabble */
+ water: { wade: 0.05, swim: 0.3, deep: 0.8 },
+ /**
+ * Ice. `walk` carries a duck's weight; `still` pins the swell (the water
+ * shader repeats it β change both together); `season` is where the year
+ * counts as winter; `open` is the most ice a point may carry and still be
+ * picked as open water for the flock.
+ */
+ ice: { walk: 0.5, still: 0.3, season: 0.3, open: 0.4 },
+ /**
+ * Winter's lead: the one patch of water the cold never closes, a little
+ * left of the middle of the opening view.
+ */
+ lead: { x: -3.5, z: 29, r0: 8.5, r1: 11.5 },
+ /** the meadow: the blooms the world has always had, and its seed bank */
+ flowers: { field: 700, bank: 500 },
+} as const;
+
+// ---------------------------------------------------------------- flock
+
+/** The flock's pace, its day, and the little social rules that shape it. */
+export const FLOCK = {
+ /**
+ * Paddling ducks can't outswim the waves they make: hull speed is 0.71 m/s
+ * for an adult and 0.43 m/s for a 9-day-old duckling, which bursts across
+ * the surface at 1.26 m/s to catch up.
+ */
+ hullSpeed: 0.71,
+ ducklingHullSpeed: 0.43,
+ ducklingBurst: 1.26,
+ /** a hen leading ducklings goes at a pace they can paddle */
+ broodPace: 0.3,
+ /** a duckling further than this from its mother calls for her */
+ lost: 4.0,
+ /** mean time between a foraging duckling's wanders away from its mother */
+ strayEvery: 900.0,
+ /** how long a swim bout holds its mark, s */
+ swimBout: 180,
+ /** how close counts as arrived on a swimming mark, m */
+ arrive: 0.7,
+ /** extra time on the clock after a beg's own spell, s */
+ begPad: 60,
+ /** chance neighbours copy what a neighbour is doing, keeping the flock in
+ * loose sympathy rather than lockstep */
+ mirrorChance: 0.35,
+ /** chance a swim bout sends a duck to a different depth band */
+ bandChange: 0.3,
+ /** chance a bout that ends out of the camera's picture sends a duck back in */
+ returnToView: 0.8,
+ /** once out of the picture, a pastime may still run this long before home */
+ outViewBout: 50,
+ /** in a freeze, half the bids to dabble become loafing */
+ winterDabbleRest: 0.5,
+ /** bout lengths by what the duck is doing, s */
+ bout: {
+ long: { min: 60, max: 240 },
+ preen: { min: 30, max: 120 },
+ other: { min: 30, max: 180 },
+ /** after a meal, before settling back into the day */
+ afterFeed: { min: 60, max: 180 },
+ },
+} as const;
+
+// ---------------------------------------------------------------- calls
+
+/**
+ * The calling model: when a duck calls, who answers, and how quickly a lost
+ * duckling or a brood call brings the next one forward. Ported from
+ * duck-pond's `scene.rs`; the synth and propagation are still absent, so a
+ * call is a silent head/bill gesture and a `CallEvent` for the audio core.
+ */
+export const CALLS = {
+ /** seconds until a newly built duck's first call */
+ first: { min: 0.5, max: 8 },
+ /** seconds until the next call, by the occasion that sets the pace */
+ interval: {
+ lostYoung: { min: 1.5, max: 3 },
+ lost: { min: 0.2, max: 1 },
+ ducklingFeed: { min: 1.5, max: 5 },
+ duckling: { min: 4, max: 15 },
+ feed: { min: 2.5, max: 6 },
+ drakeAway: { min: 8, max: 20 },
+ drake: { min: 45, max: 180 },
+ dabble: { min: 15, max: 60 },
+ other: { min: 20, max: 90 },
+ },
+ /** share of calls a resting duck lets pass: ducklings are a little chattier */
+ sleepSkip: { duckling: 0.8, adult: 0.9 },
+ /** a drake whose mate is this far off calls after her, m */
+ mateAway: 8,
+ /** the answer chain: its reach, a mate's probability, a stranger's base */
+ answer: { range: 40, mate: 0.6, other: 0.3 },
+ /** how soon an answer, or a mother's answer to a lost peep, is due, s */
+ answerDelay: { min: 0.8, max: 2.5 },
+ motherDelay: { min: 0.5, max: 2.5 },
+ /** how soon a new occasion brings the next call forward, s */
+ lostDelay: { min: 0.1, max: 0.5 },
+ gatheringDelay: { min: 0.3, max: 1.2 },
+ noticeDelay: { min: 0.2, max: 1.5 },
+ /** brood-call notes: more when calling lost young than when gathering */
+ broodNotes: {
+ lost: { min: 6, max: 10 },
+ gather: { min: 3, max: 6 },
+ },
+} as const;
+
+// ---------------------------------------------------------------- shore
+
+/**
+ * Neither the bank nor the winter lid is home: a duck that steps out of the
+ * water has come visiting. It settles for a doze or a groom, then the pond
+ * pulls it back β most spells ashore end in a walk to the water, while the odd
+ * one takes another pastime.
+ */
+export const SHORE = {
+ /** how long a visit to grass or ice lasts, s */
+ stay: { min: 35, max: 90 },
+ /** the lid is colder underfoot, so a bird on it is quicker to leave */
+ iceStay: { min: 15, max: 45 },
+ /** chance a swim bout starting near the bank becomes a visit ashore */
+ visit: 0.35,
+ /** a hen with ducklings is readier to take them out */
+ broodVisit: 0.5,
+ /** a spell ashore means a march over the frozen lid, so winter tempts less */
+ winterVisit: 0.5,
+ /** how close to the thrower a duck must be for the bank to tempt it */
+ gate: 26,
+ /** chance a spell on the bank or the lid ends with a swim back */
+ returnToWater: 0.75,
+ returnFromIce: 0.85,
+} as const;
+
+// --------------------------------------------------------------- winter
+
+/** What a freeze changes about the flock's day. */
+export const WINTER = {
+ /** the flock does less of everything on the small open lead */
+ bouts: 0.55,
+ /** share of swim bouts bound for the lid's rim rather than the lead */
+ loaf: 0.045,
+ /** a flight across the lead is a short hop, not a lap of the open lake */
+ flightReach: { min: 5, max: 14 },
+} as const;
+
+// ------------------------------------------------------------------ lap
+
+/**
+ * Now and then a bout that would settle to the nearest pastime becomes a lap
+ * of the lake: a wandering round rather than a line across it. The pond draws
+ * the circle through the bird each time, picks the way round at random, and
+ * bows the path with two wobbles so no two laps read the same.
+ */
+export const LAP = {
+ /** chance a bout that would settle becomes a lap instead */
+ chance: 0.14,
+ /** share of laps that swing out of the camera's picture and back in */
+ out: 0.3,
+ /** how much of the round one bout swims: part of it, or the whole way */
+ turns: { min: 0.55, max: 1.5 },
+ /** the two harmonics that bow the round in and out */
+ wobble: 0.15,
+ wobble2: 0.1,
+ /** radius ranges, m: open water and the winter lead */
+ radius: { min: 4.5, max: 9.5 },
+ winterRadius: { min: 3, max: 5.5 },
+ /** how far along the round the duck aims, m, and the cap on that angle */
+ lookahead: 3.5,
+ lookaheadMax: 1.1,
+ /** attempts to draw a round that stays in water, and samples along it */
+ tries: 40,
+ samples: 24,
+ /** a swimming lap never stalls below this speed, m/s */
+ minSpeed: 0.3,
+} as const;
+
+// --------------------------------------------------------------- flight
+
+/**
+ * A short flight across the pond: up off the water, a long low glide, a
+ * settle. A duck on the wing is over the far water in a few seconds, so a
+ * crust beyond a short paddle is worth flying for β the further off it comes
+ * down, the likelier the bird takes the air.
+ */
+export const FLIGHT = {
+ /** chance a bout that would be a swim becomes a flight (scaled in winter) */
+ chance: 0.18,
+ /** the speed it crosses the water at, m/s */
+ speed: 5.5,
+ /** bounds on how long a flight takes, s */
+ time: { min: 4.2, max: 8.5 },
+ /** how far a flight may reach across the pond, m */
+ reach: { min: 16, max: 34 },
+ /** how far from the thrower the flock's picture still reads */
+ far: 40,
+ /** height it rides over the surface, at scale 1, m */
+ peak: { min: 0.8, max: 1.45 },
+ /** clearance from other ducks when choosing a landing spot, body lengths */
+ clearance: 2.2,
+ /** attempts to find a clear, readable landing spot */
+ tries: 14,
+ /** seconds before a duck will take the wing again after landing */
+ cool: { min: 20, max: 45 },
+ /** flying for bread, as opposed to a lap on the wing */
+ food: {
+ /** nothing closer than this is worth the wing */
+ near: 15,
+ /** the chance rises from nothing here... */
+ far: 42,
+ /** ...to this at most */
+ max: 0.55,
+ /** the bird lands this far short and paddles the last of the way */
+ gap: { min: 1.4, max: 2.6 },
+ },
+} as const;
+
+// ---------------------------------------------------------------- bread
+
+/** Bread, from the hand to the last crumb. */
+export const BREAD = {
+ /** bread left uneaten sinks away after this long, s */
+ lifetime: 100,
+ /** ducks beg for this long once it is gone, s */
+ begTime: { min: 25, max: 40 },
+ /** where it leaves the viewer's hand, in camera-space metres */
+ throw: { along: 1.2, drop: 1.4, floor: 1.0 },
+ /** flight time in seconds as a function of throw distance, m */
+ time: { base: 0.45, perMetre: 0.04, min: 0.6, max: 1.9 },
+ /** arc height in metres over the same distance */
+ arc: { base: 1.0, perMetre: 0.06, min: 1.2, max: 5.5 },
+ /** a piece is two or three bites */
+ bites: { chance: 0.5, many: 3, few: 2 },
+ /** a floating crust nudges the surface a little, now and then */
+ ring: {
+ /** first ring after the splash, s */
+ first: { min: 0.7, max: 2.3 },
+ /** then every few seconds */
+ every: { min: 1.4, max: 4.2 },
+ /** ring scale and strength, relative to a splash */
+ scale: { min: 0.2, max: 0.3 },
+ strength: { min: 0.3, max: 0.45 },
+ },
+ /** the small ring a duck's nibble makes */
+ nibbleRing: { scale: 0.4, strength: 0.4 },
+ /** how long one bite takes, s */
+ biteTime: { min: 0.5, max: 1.2 },
+ /** how long a spent piece takes to shrink away after `lifetime`, s */
+ fadeTime: 4,
+ /** a floating piece rides the swell and bobs a little of its own */
+ float: { lift: 0.03, bob: 0.012, rate: 1.8 },
+ /**
+ * Repeated taps on one spot: a landing point this close to bread already
+ * there counts as the same spot, and the throw is skewed off the cursor by
+ * a random bearing and distance. A few taps then make a scatter of crusts
+ * beside each other instead of a stack of pieces intersecting.
+ */
+ scatter: {
+ /** a landing this close to bread already there counts as the same spot */
+ crowded: 0.55,
+ /** the skew's near and far distance, m */
+ near: 0.24,
+ far: 0.6,
+ /** no two crusts may land closer than this: a piece is a 0.1 m pebble */
+ gap: 0.24,
+ },
+ /**
+ * What a duck makes of bread on its way down. Every duck hears the splash,
+ * the nearest first; a duck that saw the throw is off while it is still in
+ * the air, and the rest start late like everyone else.
+ */
+ notice: {
+ /** jitter around the splash, s */
+ hearJitter: { min: -0.15, max: 1.0 },
+ /** how much later a duck this many metres away hears it, s/m */
+ hearPerMetre: 12,
+ /** the share of the throw time a watching duck needs to set off */
+ seeEarly: { min: 0.25, max: 0.7 },
+ },
+} as const;
+
+// ---------------------------------------------------------------- sight
+
+/**
+ * What a duck can make out. The eyes sit wide on the head, but a bird facing
+ * away sees nothing of what lands behind it, so bread must come down inside
+ * this cone.
+ */
+const SIGHT_HALF = 1.15;
+
+export const SIGHT = {
+ /** how far a duck can make out bread, m */
+ range: 55,
+ /** half-angle of the cone, rad */
+ half: SIGHT_HALF,
+ /** its cosine, derived once for the dot product */
+ cos: Math.cos(SIGHT_HALF),
+} as const;
+
+// -------------------------------------------------------------- curious
+
+/**
+ * Now and then a duck notices the visitor on the bank rather than the other way
+ * round, and paddles over to the water in front of them to stand about and beg
+ * at nothing in particular. One bird goes at most every few minutes.
+ */
+export const CURIOUS = {
+ /** chance per bout, when the flock is near enough */
+ chance: 0.08,
+ /** how long it hangs about after arriving, s */
+ time: { min: 25, max: 60 },
+ /** flock cooldown between curiosity visits, s */
+ every: { min: 150, max: 420 },
+ /** only the flock whose water is this near the stand ever looks up */
+ gate: 46,
+ /** allowance for the paddle over, on top of the mark, s */
+ travelPad: 6,
+} as const;
+
+// ---------------------------------------------------------------- space
+
+/**
+ * Personal space. Every duck is a hull the others have to clear: half a body
+ * long and a quarter wide, times its own body scale, plus a little water
+ * between hulls β a hair's breadth inside a family, more between strangers.
+ * Steering nudges a duck off another's space; the hulls themselves are held
+ * apart, so a scrum on one crust can shoulder but never intersect.
+ */
+export const SPACE = {
+ hullLen: 0.31,
+ hullBeam: 0.18,
+ familyGap: 0.03,
+ strangerGap: 0.1,
+ /** how far outside the hulls the soft nudging starts, and how hard it pushes */
+ reach: 1.3,
+ push: 0.28,
+ /** settle passes each frame, and how much a pinned duck (sleep/upend) yields */
+ passes: 2,
+ pinnedWeight: 0.25,
+} as const;
+
+// ----------------------------------------------------------------- duck
+
+/** The rig's dials: gaze, tipping up, the waddle, sitting, and breathing. */
+export const DUCK = {
+ /** the cursor gaze: how far a head swings and how eagerly it answers */
+ look: { yawMax: 1.55, pitchMax: 0.72, sweep: 0.9, tilt: 1.1 },
+ /** how far a bottom-feeding duck tips over its waterline pivot, and the
+ * depth it needs under it, m */
+ upend: { pitch: 1.62, depth: 0.8 },
+ /** one hinge per leg at the hip, and the solved step it walks */
+ legs: {
+ /** hip to sole, rig units */
+ length: 0.15,
+ /** the ankle rides this far forward of the shank */
+ ankle: 0.027,
+ /** half the foot's thickness: sole to ankle */
+ foot: 0.007,
+ /** the hips sit this far either side of the spine */
+ hip: 0.055,
+ /** radians the hinge swings either side of centre */
+ swing: 0.5,
+ /** share of a step cycle a foot spends planted */
+ stance: 0.62,
+ /** sole clearance at mid-swing */
+ lift: 0.022,
+ },
+ /** the body's lean onto the planted foot, and the stern's answer */
+ waddle: { roll: 0.13, wag: 0.035 },
+ /** asleep on solid ground the hips fold under and the body settles in */
+ sit: { tuck: 1.55, drop: 0.04 },
+ /** the body's slow swell, independent of head and wings */
+ breath: { rate: 3.2, amount: 0.014 },
+ /** the idle float on the swell */
+ bob: { rate: 1.7, amount: 0.012 },
+} as const;
+
+// --------------------------------------------------------------- effects
+
+/** Ripples, splashes and the year's weather. */
+export const EFFECTS = {
+ ripple: {
+ /** how far a ring floats above the water, where its edge reads */
+ lift: 0.035,
+ /** lifetime range, s */
+ life: { min: 1.3, max: 1.8 },
+ /** ring scale over its life, as `from + t * by` */
+ grow: { from: 0.4, by: 2.4 },
+ /** how strongly a ring at full strength reads */
+ alpha: 0.55,
+ /** how many rings the pond keeps in flight */
+ count: 24,
+ },
+ /** pools of weather and water particles */
+ capacity: { feather: 180, bit: 320, leaf: 220, snow: 300 },
+ weather: {
+ /** leaves shed per unit of the crowns' thinning, and the debt cap */
+ leafShed: 900,
+ leafDebt: 24,
+ /** snow only falls above this, and fills a shoal of this many flakes */
+ snowFrom: 0.12,
+ snowCount: 260,
+ /** how many flakes may be added in one frame */
+ snowAdd: 6,
+ /** how far from the camera a crown must be for a shed leaf */
+ crownReach: 90,
+ /** the shoal around the camera: its inner radius and spread */
+ flakeNear: 6,
+ flakeRadius: 42,
+ /** reclaim a flake below the camera by this much, or this far out */
+ flakeDrop: 16,
+ flakeRecycle: 52,
+ },
+} as const;
diff --git a/src/duck.ts b/src/duck.ts
new file mode 100644
index 0000000..793d550
--- /dev/null
+++ b/src/duck.ts
@@ -0,0 +1,1462 @@
+// Ducks: the pond's residents, and the rig that draws them β a cut-down port
+// of the bird rig from our own bird world. The same quaternion-only pivots
+// and damped pose fields carry a swimming duck, a loafing one and one
+// waddling ashore; flight, trees and the hawk are gone. What they DO with
+// their time lives in pond.ts (the duck-pond activity model); this file is
+// rig, pose and species.
+import * as THREE from 'three';
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
+import { clamp, lerp, damp, dampK, smoothstep, TAU, valueNoise, Rng, angleDelta } from './utils';
+import { CALLS, DUCK } from './config';
+import type { World } from './world';
+import type { Effects } from './effects';
+
+export type DuckKind = 'hen' | 'drake' | 'duckling';
+export type Activity = 'swim' | 'dabble' | 'rest' | 'preen' | 'stray' | 'feed' | 'beg' | 'fly';
+export type Band = 'near' | 'mid' | 'far';
+
+/** The calls a duck can make; the synth is still to come, so these are labels. */
+export type CallKind = 'quack' | 'series' | 'decrescendo' | 'chatter' | 'rhaeb' | 'peeps' | 'contact' | 'brood';
+
+/**
+ * One call, at the moment it starts. The silent head/bill gesture is the
+ * visual stand-in for the missing synth; the event carries what the audio
+ * core will need to make the same call later.
+ */
+export interface CallEvent {
+ kind: CallKind;
+ /** the label the Rust visualizer shows, e.g. "quack quack" */
+ label: string;
+ /** notes in the call, or 0 for a run whose count the synth decides */
+ notes: number;
+ /** natural call length, s */
+ duration: number;
+ /** this call may set others answering (a hen's series or decrescendo) */
+ social: boolean;
+ /** this is an answer, so it cannot start another chain */
+ answering: boolean;
+}
+
+/**
+ * A lap of the lake the duck is swimming: a wandering round of open water the
+ * pond draws fresh each time β a circle of a random size through the bird,
+ * swum whichever way round comes up, bowed in and out as it goes, so no two
+ * laps read the same. The pond walks the mark along the round; the bird only
+ * follows its bill.
+ */
+export interface Lap {
+ /** the middle of the round */
+ x: number;
+ z: number;
+ r: number;
+ /** the angle it has reached, and the way round it is going (1 or β1) */
+ a: number;
+ dir: number;
+ /** phase of the wander that keeps the round from being a true circle */
+ wob: number;
+ /** radians of the round still to swim */
+ left: number;
+}
+
+const _q1 = new THREE.Quaternion();
+const _q2 = new THREE.Quaternion();
+const _AXIS_X = new THREE.Vector3(1, 0, 0);
+const _AXIS_Y = new THREE.Vector3(0, 1, 0);
+const _AXIS_Z = new THREE.Vector3(0, 0, 1);
+
+// A duck's pivots are posed by quaternion only: dropping the eulerβquaternion
+// sync three.js keeps on every object saves a decomposition on every write,
+// and nothing reads their euler angles back.
+function detachEulerSync(obj: THREE.Object3D): void {
+ const q = obj.quaternion as unknown as { _onChange?: (cb: () => void) => void };
+ if (q._onChange) q._onChange(() => {});
+}
+
+// ---------------------------------------------------------------- pose trig
+
+// The rig parts are posed by euler triples every frame, and three.js answers
+// each of those with six Math.sin/cos calls. Every half-angle the pose reaches
+// stays well inside Β±1.6 rad, so a small interpolated table answers them an
+// order of magnitude faster; the error is on the order of a microradian.
+const POSE_RANGE = 1.6;
+const POSE_N = 2048;
+const POSE_STEP = POSE_N / (2 * POSE_RANGE);
+const POSE_BIAS = POSE_N / 2;
+const POSE_SIN = new Float64Array(POSE_N + 2);
+const POSE_COS = new Float64Array(POSE_N + 2);
+for (let i = 0; i <= POSE_N + 1; i++) {
+ const a = (i - POSE_BIAS) / POSE_STEP;
+ POSE_SIN[i] = Math.sin(a);
+ POSE_COS[i] = Math.cos(a);
+}
+
+function halfSin(h: number): number {
+ if (h < -POSE_RANGE || h > POSE_RANGE) return Math.sin(h);
+ const u = h * POSE_STEP + POSE_BIAS;
+ const i = u | 0;
+ const f = u - i;
+ return POSE_SIN[i] + (POSE_SIN[i + 1] - POSE_SIN[i]) * f;
+}
+
+function halfCos(h: number): number {
+ if (h < -POSE_RANGE || h > POSE_RANGE) return Math.cos(h);
+ const u = h * POSE_STEP + POSE_BIAS;
+ const i = u | 0;
+ const f = u - i;
+ return POSE_COS[i] + (POSE_COS[i + 1] - POSE_COS[i]) * f;
+}
+
+/**
+ * The XYZ euler β quaternion composition three.js applies, on table trig.
+ * Positive x tips a +z-facing part (bill, tail tip, wing) DOWN.
+ */
+function poseFromEuler(quat: THREE.Quaternion, x: number, y: number, z: number): void {
+ const s1 = halfSin(x * 0.5);
+ const c1 = halfCos(x * 0.5);
+ const s2 = halfSin(y * 0.5);
+ const c2 = halfCos(y * 0.5);
+ const s3 = halfSin(z * 0.5);
+ const c3 = halfCos(z * 0.5);
+ quat.set(
+ s1 * c2 * c3 + c1 * s2 * s3,
+ c1 * s2 * c3 - s1 * c2 * s3,
+ c1 * c2 * s3 + s1 * s2 * c3,
+ c1 * c2 * c3 - s1 * s2 * s3,
+ );
+}
+
+// sin(phase β 1.1) and sin(phase β 2.2) by the angle sum, from the one
+// (sin, cos) pair animate already takes of the wing phase
+const LAG_COS = Math.cos(1.1);
+const LAG_SIN = Math.sin(1.1);
+const LAG2_COS = Math.cos(2.2);
+const LAG2_SIN = Math.sin(2.2);
+
+// ------------------------------------------------------------------ the waddle
+//
+// A duck on land hinges each leg at the hip and nothing else. That is enough
+// to walk properly if the step is solved: while a foot is planted its sole
+// holds the spot it landed on β the hinge angle is the arcsine of a linear
+// ramp, which is exactly what pins a foot of length L to the ground the body
+// travels over β and the body drops by the height that leg's shortening takes
+// out of the hip, so the weight shift reads as a step rather than a bounce.
+// The swing brings the foot back to the front on an ease, sole lifted clear.
+const LEG_SIN = Math.sin(DUCK.legs.swing);
+// A duck asleep on solid ground sits, it does not doze off standing: the hips
+// fold right up under the body and the body settles onto its belly, so the legs
+// vanish into the plumage the way a real bird's do. It settles a feather's
+// depth *into* the shore or ice rather than kissing it tangentially β a body
+// that only touches looks like it is hovering, and the ice would cut the
+// contact as a hard line.
+
+/**
+ * How far the sole hangs below the hip at hinge angle `p` β the leg's
+ * vertical reach, with the ankle's forward offset tilting into it. The step
+ * cycle keeps the *lowest* leg's reach constant, which is what plants a foot.
+ */
+function legReach(p: number): number {
+ return DUCK.legs.length * Math.cos(p) + DUCK.legs.ankle * Math.sin(p) + DUCK.legs.foot;
+}
+
+/** A leg's place in the step cycle: hinge angle, hip lift, sole tilt. */
+interface LegPose {
+ swing: number;
+ lift: number;
+ toe: number;
+}
+
+const _legL: LegPose = { swing: 0, lift: 0, toe: 0 };
+const _legR: LegPose = { swing: 0, lift: 0, toe: 0 };
+
+/**
+ * Fill `out` with the leg's pose at cycle position `p` (0..1) at step
+ * amplitude `amp` (which damps to 0 when the duck stands still).
+ */
+function aimLeg(p: number, amp: number, out: LegPose): void {
+ if (p < DUCK.legs.stance) {
+ // planted: the sole keeps its hold on the ground the body crosses
+ out.swing = amp * Math.asin(LEG_SIN * (2 * (p / DUCK.legs.stance) - 1));
+ out.lift = 0;
+ out.toe = 0;
+ return;
+ }
+ // swinging: forward again on a smooth ease, sole lifted and toe up
+ const u = (p - DUCK.legs.stance) / (1 - DUCK.legs.stance);
+ const ease = u * u * (3 - 2 * u);
+ const bell = Math.sin(Math.PI * u);
+ out.swing = amp * DUCK.legs.swing * (1 - 2 * ease);
+ out.lift = amp * DUCK.legs.lift * bell;
+ out.toe = amp * 0.35 * bell;
+}
+
+// ------------------------------------------------------------ palette paint
+
+type PaintFn = (x: number, y: number, z: number, c: THREE.Color) => void;
+
+function paint(geo: THREE.BufferGeometry, fn: PaintFn): THREE.BufferGeometry {
+ const pos = geo.attributes.position;
+ const colors = new Float32Array(pos.count * 3);
+ const c = new THREE.Color();
+ for (let i = 0; i < pos.count; i++) {
+ fn(pos.getX(i), pos.getY(i), pos.getZ(i), c);
+ colors[i * 3] = c.r;
+ colors[i * 3 + 1] = c.g;
+ colors[i * 3 + 2] = c.b;
+ }
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
+ return geo;
+}
+
+function color(hex: number): THREE.Color {
+ return new THREE.Color(hex);
+}
+
+// ------------------------------------------------------------------ species
+
+export interface DuckSpecies {
+ kind: DuckKind;
+ label: string;
+ scale: number;
+ // body: the waterfowl loaf β long, flat, short-necked
+ bodyWide: number;
+ bodyTall: number;
+ bodyLen: number;
+ bodyLift: number;
+ headSize: number;
+ headPivot: [number, number, number];
+ tailPivot: [number, number];
+ tailLen: number;
+ tailRest: number;
+ wingScale: number;
+ wingPivot: [number, number];
+ // the flat spatulate bill
+ beakLength: number;
+ beakRadius: number;
+ beakTipRadius: number;
+ beakWidth: number;
+ beakFlat: number;
+ eyeOffset: number;
+ flapFreq: number;
+ flapAmp: number;
+ draft: number;
+ pal: Record<string, THREE.Color>;
+}
+
+function mallardBody() {
+ return {
+ scale: 1.45,
+ bodyWide: 1.05,
+ bodyTall: 0.74,
+ bodyLen: 1.85,
+ bodyLift: 0.0,
+ headSize: 1.15,
+ headPivot: [0, 0.095, 0.2] as [number, number, number],
+ tailPivot: [0.026, -0.27] as [number, number],
+ tailLen: 0.7,
+ tailRest: 0.2,
+ wingScale: 0.82,
+ wingPivot: [0.04, 0.03] as [number, number],
+ beakLength: 0.16,
+ beakRadius: 0.036,
+ beakTipRadius: 0.052,
+ beakWidth: 2.2,
+ beakFlat: 0.5,
+ eyeOffset: 0.07,
+ flapFreq: 4.9,
+ flapAmp: 1.1,
+ draft: 0.045,
+ };
+}
+
+/** The drake: the mallard from our bird world, exactly as it swam there. */
+export const DRAKE: DuckSpecies = {
+ ...mallardBody(),
+ kind: 'drake',
+ label: 'mallard drake',
+ pal: {
+ backColor: color(0xb4b0a0),
+ bellyColor: color(0xe2ddcc),
+ wingColor: color(0xa8a497),
+ underColor: color(0x8f8b7d),
+ tipColor: color(0xf4f2ea),
+ tailColor: color(0x2b2b26),
+ streakColor: color(0x8f8a78),
+ cheekColor: color(0x8f6b50),
+ breastColor: color(0x6e4530),
+ headColor: color(0x276b3e),
+ speculumColor: color(0x3a58aa),
+ beakColor: color(0xc9b13c),
+ eyeColor: color(0x101010),
+ footColor: color(0xc98a3a),
+ },
+};
+
+/** The hen: the same loaf in mottled brown, with the mallard's blue mirror. */
+export const HEN: DuckSpecies = {
+ ...mallardBody(),
+ kind: 'hen',
+ label: 'mallard hen',
+ pal: {
+ backColor: color(0x6f5638),
+ bellyColor: color(0xcdbb96),
+ wingColor: color(0x816846),
+ underColor: color(0xa89474),
+ tipColor: color(0xe4d7b8),
+ tailColor: color(0x4a3a28),
+ streakColor: color(0x4e3d28),
+ cheekColor: color(0xcbb68e),
+ breastColor: color(0xc3af8c),
+ headColor: color(0x96784f),
+ eyeStripeColor: color(0x5c4a30),
+ speculumColor: color(0x3b5aa0),
+ beakColor: color(0xcf7f38),
+ eyeColor: color(0x101010),
+ footColor: color(0xc9762f),
+ },
+};
+
+/** The duckling: a rounder, fluffier loaf with a dark bill and eye-stripe. */
+export const DUCKLING: DuckSpecies = {
+ kind: 'duckling',
+ label: 'duckling',
+ scale: 0.5,
+ bodyWide: 1.06,
+ bodyTall: 0.9,
+ bodyLen: 1.35,
+ bodyLift: 0.015,
+ headSize: 1.35,
+ headPivot: [0, 0.1, 0.16],
+ tailPivot: [0.03, -0.24],
+ tailLen: 0.5,
+ tailRest: 0.16,
+ wingScale: 0.6,
+ wingPivot: [0.03, 0.02],
+ beakLength: 0.085,
+ beakRadius: 0.02,
+ beakTipRadius: 0.028,
+ beakWidth: 1.5,
+ beakFlat: 0.62,
+ eyeOffset: 0.075,
+ flapFreq: 6.0,
+ flapAmp: 1.0,
+ draft: 0.032,
+ pal: {
+ backColor: color(0x9d7a42),
+ bellyColor: color(0xe9d9a4),
+ wingColor: color(0xb08d50),
+ underColor: color(0xd8c384),
+ tipColor: color(0xf2e6b8),
+ tailColor: color(0x8a6c3c),
+ streakColor: color(0x6e5426),
+ cheekColor: color(0xe6d49c),
+ headColor: color(0xdcc37f),
+ eyeStripeColor: color(0x513c1c),
+ beakColor: color(0x2f2b26),
+ eyeColor: color(0x101010),
+ footColor: color(0xb5742c),
+ },
+};
+
+export const SPECIES: Record<DuckKind, DuckSpecies> = {
+ drake: DRAKE,
+ hen: HEN,
+ duckling: DUCKLING,
+};
+
+// ------------------------------------------------------------------ geometry
+
+type Pal = Record<string, THREE.Color>;
+type ShapeParams = Partial<DuckSpecies>;
+
+function makeTorsoGeometry(pal: Pal, s: ShapeParams): THREE.BufferGeometry {
+ const body = new THREE.SphereGeometry(0.17, 16, 12);
+ body.scale(s.bodyWide || 0.92, s.bodyTall || 0.86, s.bodyLen || 1.5);
+ body.translate(0, s.bodyLift === undefined ? 0.015 : s.bodyLift, 0);
+ return paint(body, (x, y, z, c) => {
+ const t = clamp((y + 0.15) / 0.3, 0, 1);
+ c.copy(pal.bellyColor).lerp(pal.backColor, Math.pow(t, 0.7));
+ const streak = valueNoise(x * 24, z * 24 + y * 6, 4);
+ if (streak > 0.56) c.lerp(pal.streakColor, (streak - 0.56) * 1.3 * t);
+ // the ducks wear their own colour on the breast
+ const hl = 0.17 * (s.bodyLen || 1.5);
+ if (pal.breastColor) {
+ const front = clamp((z - hl * 0.15) / (hl * 0.4), 0, 1);
+ const low = clamp((0.07 - y) / 0.09, 0, 1);
+ c.lerp(pal.breastColor, front * low * 0.9);
+ } else if (z > 0.1 && y < 0.02) {
+ c.lerp(pal.bellyColor, 0.4);
+ }
+ });
+}
+
+function makeTailGeometry(pal: Pal, s: ShapeParams): THREE.BufferGeometry {
+ const len = s.tailLen || 1;
+ const tail = new THREE.BoxGeometry(0.16, 0.026, 0.3 * len);
+ const p = tail.attributes.position;
+ for (let i = 0; i < p.count; i++) {
+ const t = clamp((p.getZ(i) + 0.15 * len) / (0.3 * len), 0, 1);
+ p.setX(i, p.getX(i) * lerp(0.42, 1.12, t));
+ p.setY(i, p.getY(i) * lerp(1.15, 0.75, t));
+ }
+ tail.computeVertexNormals();
+ return paint(tail, (x, y, z, c) => {
+ c.copy(pal.tailColor);
+ const t = clamp((-z + 0.15 * len) / (0.3 * len), 0, 1);
+ if (t > 0.45) c.lerp(pal.tipColor, (t - 0.45) * 1.3);
+ if (y > 0) c.lerp(pal.backColor, 0.35);
+ });
+}
+
+/** The white of the eye: warm rather than paper, so it sits in a dull day. */
+const EYE_WHITE = color(0xf3eddc);
+
+/**
+ * Skull and upper bill bake into one mesh; the lower bill comes back on its
+ * own so a duck can open it β the silent gesture that stands in for a call.
+ */
+function makeHeadParts(species: DuckSpecies): { head: THREE.BufferGeometry; jaw: THREE.BufferGeometry } {
+ const pal = species.pal;
+ const hs = species.headSize || 1;
+ const parts: THREE.BufferGeometry[] = [];
+
+ const head = new THREE.SphereGeometry(0.105 * hs, 14, 10);
+ head.scale(1, 0.96, 1.04);
+ head.translate(0, 0.03 * hs, 0.05 * hs);
+ parts.push(
+ paint(head, (x, y, z, c) => {
+ if (pal.headColor) {
+ c.copy(pal.headColor);
+ if (pal.cheekColor && z > 0.02 && y > -0.01 && y < 0.04) c.lerp(pal.cheekColor, 0.3);
+ if (pal.eyeStripeColor && z > 0 && y > 0.012 * hs && y < 0.075 * hs) {
+ c.lerp(pal.eyeStripeColor, 0.75);
+ }
+ } else {
+ const t = clamp((y + 0.04) / 0.2, 0, 1);
+ c.copy(pal.bellyColor).lerp(pal.backColor, t * 0.9);
+ if (z > 0.02 && y > -0.01 && y < 0.04) c.lerp(pal.cheekColor, 0.55);
+ }
+ }),
+ );
+
+ // the upper bill: half the slab, riding a touch high
+ const upper = new THREE.CylinderGeometry(species.beakTipRadius, species.beakRadius, species.beakLength, 10);
+ upper.scale(species.beakWidth, 1, species.beakFlat * 0.52);
+ upper.rotateX(Math.PI / 2);
+ upper.translate(0, 0.022 * hs + 0.004 * hs, (0.125 + species.beakLength / 2) * hs);
+ parts.push(paint(upper, (x, y, z, c) => c.copy(pal.beakColor)));
+
+ for (const sx of [-1, 1]) {
+ const eyeR = 0.022 * hs;
+ const ex = sx * species.eyeOffset * hs;
+ const ey = 0.052 * hs;
+ const ez = 0.128 * hs;
+ // the white is the eye itself; the pupil is a smaller ball set into it,
+ // its crown just proud of the sclera so a black disc reads inside the
+ // white from the side and from the camera's high shore. Both pupils
+ // look straight down the bill, not out to their own sides.
+ const eye = new THREE.SphereGeometry(eyeR, 12, 8);
+ eye.translate(ex, ey, ez);
+ parts.push(paint(eye, (x, y, z, c) => c.copy(EYE_WHITE)));
+ const pupilR = eyeR * 0.84;
+ const gx = 0;
+ const gy = 0.06;
+ const gz = 1;
+ const gl = Math.hypot(gx, gy, gz);
+ const off = eyeR - pupilR + eyeR * 0.25;
+ const pupil = new THREE.SphereGeometry(pupilR, 10, 8);
+ pupil.translate(ex + (gx / gl) * off, ey + (gy / gl) * off, ez + (gz / gl) * off);
+ parts.push(paint(pupil, (x, y, z, c) => c.copy(pal.eyeColor)));
+ }
+
+ // the lower bill: the other half-slab, pivoted at the base of the bill
+ const jaw = new THREE.CylinderGeometry(species.beakTipRadius * 0.88, species.beakRadius * 0.92, species.beakLength * 0.98, 10);
+ jaw.scale(species.beakWidth * 0.9, 1, species.beakFlat * 0.42);
+ jaw.rotateX(Math.PI / 2);
+ jaw.translate(0, -0.002 * hs, species.beakLength / 2);
+ paint(jaw, (x, y, z, c) => c.copy(pal.beakColor).multiplyScalar(0.93));
+
+ return { head: mergeGeometries(parts, false), jaw };
+}
+
+function shapeFrom(points: number[][]): THREE.Shape {
+ const shape = new THREE.Shape();
+ shape.moveTo(points[0][0], points[0][1]);
+ for (let i = 1; i < points.length; i++) shape.lineTo(points[i][0], points[i][1]);
+ shape.closePath();
+ return shape;
+}
+
+function makeWingGeometries(pal: Pal, s: ShapeParams): { inner: THREE.BufferGeometry; tip: THREE.BufferGeometry } {
+ const ws = s.wingScale || 1;
+ const innerShape = shapeFrom([
+ [0.0, 0.105],
+ [0.2, 0.088],
+ [0.4, 0.055],
+ [0.385, -0.085],
+ [0.2, -0.16],
+ [0.02, -0.175],
+ ]);
+ const tipShape = shapeFrom([
+ [-0.015, 0.06],
+ [0.14, 0.035],
+ [0.31, -0.02],
+ [0.27, -0.085],
+ [0.1, -0.125],
+ [-0.015, -0.1],
+ ]);
+ const depth = 0.014;
+
+ const inner = new THREE.ExtrudeGeometry(innerShape, { depth, bevelEnabled: false, curveSegments: 1 });
+ inner.scale(ws, 1, 1);
+ inner.rotateX(Math.PI / 2);
+ paint(inner, (x, y, z, c) => {
+ const span = clamp(x / 0.4, 0, 1);
+ const under = y < -depth * 0.5;
+ c.copy(under ? pal.underColor : pal.wingColor);
+ c.lerp(pal.tipColor, Math.max(0, span - 0.5) * 0.6);
+ // the speculum rides the middle of the wing's leading edge
+ if (!under && pal.speculumColor && x > 0.15 && x < 0.3) c.lerp(pal.speculumColor, 0.7);
+ if (!under && z > 0.02) c.lerp(pal.backColor, 0.4);
+ });
+
+ const tip = new THREE.ExtrudeGeometry(tipShape, { depth: depth * 0.8, bevelEnabled: false, curveSegments: 1 });
+ tip.scale(ws, 1, 1);
+ tip.rotateX(Math.PI / 2);
+ paint(tip, (x, y, z, c) => {
+ const span = clamp((x + 0.015) / 0.325, 0, 1);
+ const under = y < -depth * 0.4;
+ c.copy(under ? pal.underColor : pal.wingColor);
+ c.lerp(pal.tipColor, 0.3 + span * 0.65);
+ if (!under && z > 0.02) c.lerp(pal.backColor, 0.35);
+ });
+
+ return { inner, tip };
+}
+
+/**
+ * One leg, hinged at the hip: a shank and a webbed foot. Both sides share
+ * these geometries; the hinge groups are mirrored by their own position.
+ */
+function makeLegParts(pal: Pal): { shank: THREE.BufferGeometry; foot: THREE.BufferGeometry } {
+ const shank = new THREE.CylinderGeometry(0.015, 0.012, DUCK.legs.length, 5);
+ shank.translate(0, -DUCK.legs.length / 2, 0);
+ const foot = new THREE.BoxGeometry(0.06, 0.014, 0.08);
+ foot.translate(0, 0, 0.005);
+ return {
+ shank: paint(shank, (x, y, z, c) => c.copy(pal.footColor)),
+ foot: paint(foot, (x, y, z, c) => c.copy(pal.footColor)),
+ };
+}
+
+interface VariantParts {
+ torso: THREE.BufferGeometry;
+ head: THREE.BufferGeometry;
+ jaw: THREE.BufferGeometry;
+ tail: THREE.BufferGeometry;
+ inner: THREE.BufferGeometry;
+ tip: THREE.BufferGeometry;
+ legs: { shank: THREE.BufferGeometry; foot: THREE.BufferGeometry };
+}
+
+const VARIANT_CACHE = new Map<DuckKind, VariantParts>();
+let DUCK_MATERIAL: THREE.MeshStandardMaterial | null = null;
+
+function variantParts(species: DuckSpecies): VariantParts {
+ let parts = VARIANT_CACHE.get(species.kind);
+ if (!parts) {
+ const wings = makeWingGeometries(species.pal, species);
+ const headParts = makeHeadParts(species);
+ parts = {
+ torso: makeTorsoGeometry(species.pal, species),
+ head: headParts.head,
+ jaw: headParts.jaw,
+ tail: makeTailGeometry(species.pal, species),
+ inner: wings.inner,
+ tip: wings.tip,
+ legs: makeLegParts(species.pal),
+ };
+ VARIANT_CACHE.set(species.kind, parts);
+ }
+ return parts;
+}
+
+function duckMaterial(): THREE.MeshStandardMaterial {
+ if (!DUCK_MATERIAL) {
+ DUCK_MATERIAL = new THREE.MeshStandardMaterial({
+ vertexColors: true,
+ roughness: 0.78,
+ metalness: 0.03,
+ side: THREE.DoubleSide,
+ });
+ }
+ return DUCK_MATERIAL;
+}
+
+// --------------------------------------------------------------- the duck
+
+/** The damped fields of the folded-wing pose. */
+interface WingPose {
+ amp: number;
+ freq: number;
+ base: number;
+ sweep: number;
+ tip: number;
+ fold: number;
+ asym: number;
+ wrap: number;
+ phase: number;
+}
+
+export interface DuckDef {
+ name: string;
+ kind: DuckKind;
+ /** Hen: (f0, f2) of her quack; drake: (rhaeb, -); duckling: (peep top, -). Kept for a later pass at voices. */
+ voice: [number, number];
+ pos: { x: number; z: number };
+}
+
+export class Duck {
+ name: string;
+ kind: DuckKind;
+ voice: [number, number];
+ species: DuckSpecies;
+ world: World;
+ effects: Effects;
+ rng: Rng;
+ scale: number;
+ root: THREE.Group;
+
+ // sim state (read and written by pond.ts β this is the Duck record of
+ // duck-pond's scene.rs, in world x/z instead of the listener's frame)
+ pos: { x: number; z: number };
+ vel: { x: number; z: number };
+ facing: { x: number; z: number };
+ target: { x: number; z: number };
+ cruise: number;
+ activity: Activity = 'rest';
+ activityLeft: number;
+ leader: Duck | null = null;
+ rank = 0;
+ offset = { x: 0, z: 0 };
+ offsetLeft = 0;
+ band: Band = 'mid';
+ notice: number | null = null;
+ eating = 0;
+ lost = false;
+ /** countdown to the next call, s */
+ callTimer = 0;
+ /** the call being made, if any; held for its natural length */
+ call: CallEvent | null = null;
+ callLeft = 0;
+ /** this call is an answer, and may not set anyone else off */
+ answering = false;
+ /** a hen about to call her brood together, before a swim bout */
+ gathering = false;
+ begLeft = 0;
+ ate = false;
+ lastBite: { x: number; z: number } | null = null;
+ preFeed: { activity: Activity } | null = null;
+ /** where the food it is swimming to lies, for the head to lean on */
+ headAim: { x: number; z: number } | null = null;
+ /** how close the bill, not the chest, needs the centre to the food */
+ feedStop = 0;
+ /** the raw pan and tilt the pointer asks of this duck, if any */
+ headLook: { yaw: number; pitch: number } | null = null;
+ floating = true;
+ posY = 0;
+ /** on the wing: the pond flies it, the rig spreads the wings and pitches it */
+ flying = false;
+ /** the lap of the lake it is swimming, if it is on one */
+ lap: Lap | null = null;
+ /** seconds before it will consider flying again: a bird just down stays down */
+ flyCool = 0;
+ /** the pond's flight plan, from the moment it leaves the water */
+ flight: { fromX: number; fromZ: number; toX: number; toZ: number; t: number; dur: number; peak: number } | null = null;
+ /** height over the water, the body's attitude (positive tips the bill
+ * down) and how far through the flight it is β set by the pond each frame */
+ flyH = 0;
+ flyPitch = 0;
+ flyPhase = 0;
+
+ // rig
+ torso: THREE.Mesh;
+ headPivot: THREE.Group;
+ headMesh: THREE.Mesh;
+ jawPivot: THREE.Group;
+ jawMesh: THREE.Mesh;
+ tailPivot: THREE.Group;
+ tailMesh: THREE.Mesh;
+ wingR: THREE.Group;
+ wingL: THREE.Group;
+ tipR: THREE.Group;
+ tipL: THREE.Group;
+ hipL: THREE.Group;
+ hipR: THREE.Group;
+ ankleL: THREE.Group;
+ ankleR: THREE.Group;
+ footL: THREE.Mesh;
+ footR: THREE.Mesh;
+ /** both feet, for probes; 0 is the βx (left) foot, the one `cycleL` drives */
+ feet: THREE.Object3D[];
+ meshes: THREE.Mesh[];
+
+ // pose fields
+ state: 'swim' | 'loaf' | 'walk';
+ action: string | null = null;
+ actionTime = 0;
+ actionDuration = 0;
+ wing: WingPose = { amp: 0, freq: 1, base: 0, sweep: 0, tip: 0, fold: 0, asym: 0, wrap: 0, phase: 0 };
+ wingTarget: WingPose = { amp: 0, freq: 1, base: 0, sweep: 0, tip: 0, fold: 0, asym: 0, wrap: 0, phase: 0 };
+ headYaw = 0;
+ headPitch = 0;
+ headYawTarget = 0;
+ headPitchTarget = 0;
+ jawOpen = 0;
+ jawOpenTarget = 0.02;
+ tailPitch = 0;
+ tailPitchTarget = 0;
+ tailPose: number | null = null;
+ tailYaw = 0;
+ tailSpread = 1;
+ tailSpreadTarget = 1;
+ legTuck = 1.3;
+ legTuckTarget = 1.3;
+ /** how far into a bottom-feeding tip-up the body is, 0..1 */
+ upend = 0;
+ upendTarget = 0;
+ peckPhase = 0;
+ roll = 0;
+ walkPhase = 0;
+ /** how deep into the step cycle the legs are, 0..1: 0 standing, 1 walking */
+ stride = 0;
+ /** how far a sleeping duck has settled onto its belly, 0..1 */
+ sit = 0;
+ posOffsetY = 0;
+ breath = 1;
+ heading: number;
+ quat = new THREE.Quaternion();
+ wanderSeed: number;
+ wakeTimer = 0;
+ gestureTimer = 0;
+ onPeck: ((d: Duck) => void) | null = null;
+ /** emitted when a call starts, for effects and the future audio core */
+ onCall: ((d: Duck, call: CallEvent) => void) | null = null;
+ private _peckHit = false;
+ private _upendPhase = 0;
+ private _preenSide = 1;
+ private _sleepSide = 1;
+ private _gestureLeft = 0;
+ private _fxTimer = 0;
+ private _v = new THREE.Vector3();
+
+ constructor(def: DuckDef, world: World, effects: Effects, rng: Rng) {
+ this.name = def.name;
+ this.kind = def.kind;
+ this.voice = def.voice;
+ this.species = SPECIES[def.kind];
+ this.world = world;
+ this.effects = effects;
+ this.rng = new Rng((rng.next() * 0xffffffff) >>> 0);
+ this.scale = this.species.scale * rng.range(0.97, 1.03);
+
+ this.pos = { x: def.pos.x, z: def.pos.z };
+ this.vel = { x: 0, z: 0 };
+ this.facing = { x: 0, z: -1 };
+ this.target = { x: def.pos.x, z: def.pos.z };
+ this.cruise = def.kind === 'hen' ? 0.45 : def.kind === 'drake' ? 0.5 : 0.33;
+ this.activityLeft = rng.range(0, 4);
+ this.callTimer = this.rng.range(CALLS.first.min, CALLS.first.max);
+ this.wanderSeed = rng.range(0, 999);
+ this.heading = rng.range(0, TAU);
+ this.quat.setFromAxisAngle(_AXIS_Y, this.heading);
+
+ this.state = 'loaf';
+ this.root = new THREE.Group();
+ this.root.name = `duck-${def.name}`;
+ this.root.userData.duck = this;
+
+ const parts = variantParts(this.species);
+ const mat = duckMaterial();
+
+ this.torso = new THREE.Mesh(parts.torso, mat);
+ this.torso.castShadow = true;
+ this.root.add(this.torso);
+
+ const hp = this.species.headPivot;
+ this.headPivot = new THREE.Group();
+ this.headPivot.position.set(hp[0], hp[1], hp[2]);
+ this.headMesh = new THREE.Mesh(parts.head, mat);
+ this.headMesh.castShadow = true;
+ this.headPivot.add(this.headMesh);
+
+ this.jawPivot = new THREE.Group();
+ this.jawPivot.position.set(0, 0.02 * (this.species.headSize || 1), 0.125 * (this.species.headSize || 1));
+ this.jawMesh = new THREE.Mesh(parts.jaw, mat);
+ this.jawMesh.castShadow = true;
+ this.jawPivot.add(this.jawMesh);
+ this.headPivot.add(this.jawPivot);
+ this.root.add(this.headPivot);
+
+ const tp = this.species.tailPivot;
+ this.tailPivot = new THREE.Group();
+ this.tailPivot.position.set(0, tp[0], tp[1]);
+ this.tailMesh = new THREE.Mesh(parts.tail, mat);
+ this.tailMesh.castShadow = true;
+ this.tailPivot.add(this.tailMesh);
+ this.root.add(this.tailPivot);
+
+ const ws = this.species.wingScale || 1;
+ const wp = this.species.wingPivot;
+ const makeWing = (side: 1 | -1) => {
+ const shoulder = new THREE.Group();
+ shoulder.position.set(0.072 * side, wp[0], wp[1]);
+ shoulder.scale.set(side, 1, 1);
+ const hand = new THREE.Group();
+ hand.position.set(0.4 * ws, 0, 0);
+ const inner = new THREE.Mesh(parts.inner, mat);
+ inner.castShadow = true;
+ const handInner = new THREE.Mesh(parts.tip, mat);
+ handInner.castShadow = true;
+ hand.add(handInner);
+ shoulder.add(inner, hand);
+ this.root.add(shoulder);
+ return { shoulder, hand, inner, handInner };
+ };
+ const right = makeWing(1);
+ const left = makeWing(-1);
+ this.wingR = right.shoulder;
+ this.tipR = right.hand;
+ this.wingL = left.shoulder;
+ this.tipL = left.hand;
+
+ const makeLeg = (side: 1 | -1) => {
+ const hip = new THREE.Group();
+ hip.position.set(DUCK.legs.hip * side, 0, 0.005);
+ const shank = new THREE.Mesh(parts.legs.shank, mat);
+ shank.castShadow = true;
+ const ankle = new THREE.Group();
+ ankle.position.set(0, -DUCK.legs.length, DUCK.legs.ankle);
+ const foot = new THREE.Mesh(parts.legs.foot, mat);
+ foot.castShadow = true;
+ ankle.add(foot);
+ hip.add(shank, ankle);
+ this.root.add(hip);
+ return { hip, ankle, foot };
+ };
+ const legR = makeLeg(1);
+ const legL = makeLeg(-1);
+ this.hipR = legR.hip;
+ this.hipL = legL.hip;
+ this.ankleR = legR.ankle;
+ this.ankleL = legL.ankle;
+ this.footR = legR.foot;
+ this.footL = legL.foot;
+ this.feet = [legL.foot, legR.foot];
+
+ this.meshes = [this.torso, this.headMesh, this.jawMesh, this.tailMesh, right.inner, right.handInner, left.inner, left.handInner, this.footR, this.footL];
+ this.root.scale.setScalar(this.scale);
+ this.posY = this.world.waterLevel + this.species.draft * this.scale;
+ this.root.position.set(this.pos.x, this.posY, this.pos.z);
+
+ for (const part of [this.wingR, this.wingL, this.tipR, this.tipL, this.headPivot, this.jawPivot, this.tailPivot, this.hipR, this.hipL, this.ankleR, this.ankleL, this.root]) {
+ detachEulerSync(part);
+ }
+ }
+
+ // ------------------------------------------------------------- behaviour
+
+ /** The pond calls this when the activity model moves the duck on. */
+ setActivity(a: Activity): void {
+ if (this.activity === a) return;
+ this.activity = a;
+ this.gathering = false;
+ this.action = null;
+ this.actionTime = 0;
+ this.tailPose = null;
+ this._nextAction();
+ }
+
+ setState(name: 'swim' | 'loaf' | 'walk'): void {
+ if (name === this.state) return;
+ this.state = name;
+ // an action belongs to a state: nothing a swim was doing carries ashore
+ this.action = null;
+ this.actionTime = 0;
+ this.tailPose = null;
+ this._nextAction();
+ }
+
+ setStateFor(activity: Activity, floating: boolean): void {
+ this.floating = floating;
+ const s = activity === 'fly' ? 'swim'
+ : activity === 'rest' || activity === 'preen'
+ ? (floating ? 'loaf' : 'walk')
+ : (floating ? 'swim' : 'walk');
+ this.setState(s);
+ }
+
+ startAction(name: string | null, duration = 0): void {
+ this.action = name;
+ this.actionTime = duration;
+ this.actionDuration = duration;
+ this._peckHit = false;
+ this._upendPhase = 0;
+ this.tailPose = null;
+ if (name === 'preen') this._preenSide = this.rng.sign();
+ if (name === 'sleep') this._sleepSide = this.rng.sign();
+ }
+
+ /** What this duck does next, given the activity it is on. */
+ private _nextAction():
[Diff truncated. Use the raw view or local clone for the full content.]