File size: 13,454 Bytes
183f99a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
// Title-menu duck stage: the simulated twin, sitting on the title screen
// like the vitrine landing's colour module - head aims at the pointer
// (idle glances at the camera when it's quiet), click/tap quacks. No
// physics, no policies: just the shared rig posed and micro-animated.
//
// Why a separate rig instead of showing the game scene: the boot
// dramaturgy keeps the in-game duck hidden until the BIOS readout cues
// the entrance scan-up, so the title screen needs its own duck. Geometry
// is free anyway - duck.js caches the GLB module-wide, so this rig and
// the game's share the same buffers.
//
// The stage is a lazy module singleton: built once on first open, then
// re-attached on every pause. The rAF loop only runs while attached.
import { useEffect, useRef } from "react";
import Box from "@mui/material/Box";
import * as THREE from "three";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
import {
  loadKinematics, buildRig, setJoint, setJawOpen, applyPose,
  SITTING_POSE, groundFullBody, MODEL_DIR,
} from "../game/duck.js";
import { VARIANTS, DEFAULT_VARIANT, materialHookFor, applyVariant } from "../game/variants.js";
import { createWireframeFx } from "../game/fx/fx-wireframe.js";
import { playUrl } from "../game/audio.js";
import { signed } from "../game/signed.js";
import { useGame } from "../store.js";

// Aim calibration, verbatim from the vitrine's duck3d/scene.js: positive
// head_pitch is head-down on the mjlab model, so screen-up subtracts.
const SIGN_YAW = 1;
const SIGN_PITCH = -1;
const SPREAD_YAW = 0.55; // rad of head yaw across half the stage width
const SPREAD_PITCH = 0.32; // rad of head pitch across half the stage height

// Quack envelope: openness eases toward a target instead of replaying a
// fixed curve, so clicking mid-quack never snaps the jaw backwards.
const QUACK_HOLD_S = 0.3;
const QUACK_ATTACK_TAU = 0.04;
const QUACK_RELEASE_TAU = 0.09;

// The vitrine's aim framing, loosened for the menu column: the duck fills
// ~2/3 of the frame height instead of edge to edge. The look height is
// tuned WITH the zoom to put the feet (y=0) ~18% above the frame's bottom
// edge - floating well clear of the shortcut strip.
const CAM_POS = [0.28, 0.2, 0.43];
const CAM_LOOK = [0, 0.122, 0];
const CAM_ZOOM = 0.78;
const FOV_DEG = 32;

// Same voice banks as the game's quack (game.js VOICE_BANK).
const VOICE_BANK = { classic: "duck1", charcoal: "duck2", purple: "duck3", blue: "duck4" };
const CHIRP_TAKES = "abcdefghijkl";

const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
const rand = (a, b) => a + Math.random() * (b - a);

let stagePromise = null;
let liveStage = null;
let stageReady = false;

function getStage() {
  if (!stagePromise) {
    stagePromise = buildStage()
      .then((s) => {
        liveStage = s;
        stageReady = true;
        return s;
      })
      .catch((e) => {
        // Still flip ready: the title screen must not spin forever when
        // WebGL / assets fail - it just shows without the duck.
        stageReady = true;
        throw e;
      });
  }
  return stagePromise;
}

// Title-screen loading gate: the menu holds a spinner until the stage's
// assets are in (kinematics + GLB + rig build), so the content reveal
// and the duck's entrance scan cue together instead of the duck popping
// into an already-settled screen.
export const preloadMenuDuck = () => getStage();
export const isMenuDuckReady = () => stageReady;

async function buildStage() {
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(FOV_DEG, 1, 0.05, 20);
  camera.position.set(
    ...CAM_LOOK.map((l, i) => l + (CAM_POS[i] - l) / CAM_ZOOM),
  );
  camera.lookAt(...CAM_LOOK);

  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  renderer.setClearColor(0x000000, 0);
  renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
  // Same output pipeline the variant palette was calibrated against.
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  const el = renderer.domElement;
  el.style.position = "absolute";
  el.style.inset = "0";
  el.style.width = "100%";
  el.style.height = "100%";
  // Anti-flicker: invisible until the first frame has been presented.
  el.style.opacity = "0";
  el.style.transition = "opacity 0.45s ease";

  const pmrem = new THREE.PMREMGenerator(renderer);
  scene.environment = pmrem.fromScene(new RoomEnvironment()).texture;
  scene.environmentIntensity = 0.25;

  // Vitrine light rig: strong white key, soft fill, warm orange rim.
  scene.add(new THREE.AmbientLight(0xffffff, 0.45));
  const key = new THREE.DirectionalLight(0xffffff, 1.25);
  key.position.set(2, 4, 2);
  scene.add(key);
  const fill = new THREE.DirectionalLight(0xffffff, 0.4);
  fill.position.set(-2, 2, 1.5);
  scene.add(fill);
  const rim = new THREE.DirectionalLight(0xffb366, 0.7);
  rim.position.set(0, 3, -2);
  scene.add(rim);

  const k = await loadKinematics(`${MODEL_DIR}/kinematics.json`);
  let appliedVariant = useGame.getState().variant ?? DEFAULT_VARIANT;
  if (!VARIANTS[appliedVariant]) appliedVariant = DEFAULT_VARIANT;
  const rig = await buildRig(k, {
    materialForMesh: materialHookFor(VARIANTS[appliedVariant]),
  });
  // Face the camera: MJCF forward is +X; the placer yaw turns it to +Z.
  rig.placer.rotation.y = -Math.PI / 2;
  scene.add(rig.placer);
  applyPose(rig, SITTING_POSE);
  groundFullBody(rig);

  // Vitrine-style entrance: the duck materializes with the wireframe
  // scan-up the first time the menu shows. Hidden (clip at floor level)
  // until the stage is attached, then start() cues the scan; once done
  // the fx restores the real materials and is dropped for good - pause
  // reopens show the solid duck immediately.
  //
  // Shader pre-warm: the scan cycles through three material states, each
  // compiling its GPU program on the frame it first renders - the plain
  // PBR originals (restored when the scan finishes), the per-mesh clip
  // clones (unique customProgramCacheKey each, so ~30 separate PBR
  // compiles), and the wireframe ShaderMaterial (only drawn once the
  // scan is running). Left alone, those compiles land mid-animation as
  // driver-timed freezes. The canvas is still detached and invisible
  // here, so render each state once now: solid first, then a mid-scan
  // frame (wires on, clones clipping), then rewind to hidden.
  renderer.render(scene, camera);
  let entranceFx = createWireframeFx();
  entranceFx.init({ THREE, scene, root: rig.placer, camera, renderer, hidden: true });
  entranceFx.setProgress(0.5);
  renderer.render(scene, camera);
  entranceFx.setProgress(0);
  let entranceStarted = false;

  const headPivot = rig.joints.get("head_yaw").body;
  const headWorld = new THREE.Vector3();
  const camDir = new THREE.Vector3();

  // ── Pointer β†’ aim state ───────────────────────────────────────────────
  const ndc = new THREE.Vector2(0, 0);
  let mountEl = null;
  let lastT = 0;
  let lastMouseT = -Infinity;
  let hasFocus = document.hasFocus();
  let mouseInside = true;
  let az = 0, el2 = 0;
  let attention = 0;
  const o1 = rand(0, 6.3), o2 = rand(0, 6.3);
  let quackHoldUntil = -Infinity;
  let quackOpen = 0;

  const onPointerMove = (e) => {
    if (!mountEl) return;
    const r = mountEl.getBoundingClientRect();
    if (!r.width || !r.height) return;
    ndc.x = clamp(((e.clientX - r.left) / r.width) * 2 - 1, -2, 2);
    ndc.y = clamp(-(((e.clientY - r.top) / r.height) * 2 - 1), -2, 2);
    lastMouseT = lastT;
    mouseInside = true;
  };
  const onBlur = () => { hasFocus = false; };
  const onFocus = () => { hasFocus = true; };
  const onMouseLeave = () => { mouseInside = false; };
  const onMouseEnter = () => { mouseInside = true; };
  // Any click on the title screen quacks, not just the stage box: the
  // overlay owns the whole viewport while the menu is up, and the quack
  // never swallows the event (buttons underneath still do their thing).
  const onPointerDown = () => quack();

  function quack() {
    quackHoldUntil = lastT + QUACK_HOLD_S;
    const bank = VOICE_BANK[appliedVariant] ?? "duck1";
    const take = CHIRP_TAKES[(Math.random() * CHIRP_TAKES.length) | 0];
    // The click that lands here is a user gesture, so this also unlocks
    // the shared AudioContext on the very first interaction.
    playUrl(signed(`./assets/voices/${bank}/chirp_${take}.wav`), { gain: 0.7 });
  }

  function step(dt, t) {
    lastT = t;
    headPivot.getWorldPosition(headWorld);
    camDir.copy(camera.position).sub(headWorld);
    const azCam = Math.atan2(camDir.x, camDir.z);
    const elCam = Math.atan2(camDir.y, Math.hypot(camDir.x, camDir.z));

    // Attention 0..1: eases toward 1 while the page is focused, the
    // pointer is around and moved recently; lazy camera glances otherwise.
    const engaged = hasFocus && mouseInside && t - lastMouseT < 3.0;
    attention += ((engaged ? 1 : 0) - attention) * (1 - Math.exp(-dt / 0.3));

    const azAim = azCam + ndc.x * SPREAD_YAW;
    const elAim = elCam * 0.5 + ndc.y * SPREAD_PITCH;
    const azIdle = azCam + 0.45 * Math.sin(0.21 * t + o1) * Math.sin(0.09 * t + o2) * 2;
    const elIdle = elCam * 0.5 + 0.1 * Math.sin(0.4 * t + o2);
    const azT = azIdle + (azAim - azIdle) * attention;
    const elT = elIdle + (elAim - elIdle) * attention;
    const kSmooth = 1 - Math.exp(-dt * (2.5 + 7.5 * attention));
    az += (azT - az) * kSmooth;
    el2 += (elT - el2) * kSmooth;

    const breathe = Math.sin(t * 1.4) * 0.025;
    setJoint(rig, "neck_pitch", SITTING_POSE.neck_pitch + breathe);
    setJoint(rig, "head_yaw", SIGN_YAW * az);
    setJoint(rig, "head_pitch", SITTING_POSE.head_pitch + SIGN_PITCH * el2);
    setJoint(rig, "head_roll", 0.3 * SIGN_YAW * (az - azCam));

    const quackTarget = t < quackHoldUntil ? 1 : 0;
    const tau = quackTarget > quackOpen ? QUACK_ATTACK_TAU : QUACK_RELEASE_TAU;
    quackOpen += (quackTarget - quackOpen) * (1 - Math.exp(-dt / tau));
    setJawOpen(rig, quackOpen);

    if (entranceFx) {
      if (!entranceStarted) {
        entranceFx.start();
        entranceStarted = true;
      }
      entranceFx.update(dt);
      if (entranceFx.isDone()) {
        entranceFx.dispose();
        entranceFx = null;
      }
    }

    // Colourway follows the in-game duck (store.variant), fading live.
    // Deferred while the entrance scan holds its clip-material clones:
    // an applyVariant mid-scan would repaint clones that get thrown away
    // on restore. The sync lands on the first frame after the scan.
    const v = useGame.getState().variant;
    if (!entranceFx && v !== appliedVariant && VARIANTS[v]) {
      appliedVariant = v;
      applyVariant(rig, v);
    }

    renderer.render(scene, camera);
    if (el.style.opacity === "0") el.style.opacity = "1";
  }

  // ── Attach / detach (rAF only runs while a mount hosts the canvas) ────
  let raf = 0;
  let prevNow = 0;
  const loop = (now) => {
    raf = requestAnimationFrame(loop);
    const dt = Math.min((now - prevNow) / 1000, 0.05);
    prevNow = now;
    step(dt, now / 1000);
  };

  function resize() {
    if (!mountEl) return;
    const w = mountEl.clientWidth;
    const h = mountEl.clientHeight;
    if (!w || !h) return;
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    renderer.setSize(w, h);
  }
  const resizeObserver = new ResizeObserver(resize);

  function attach(mount) {
    if (!mount || mountEl === mount) return;
    detach();
    mountEl = mount;
    mount.appendChild(el);
    resizeObserver.observe(mount);
    resize();
    window.addEventListener("pointermove", onPointerMove);
    window.addEventListener("pointerdown", onPointerDown);
    window.addEventListener("blur", onBlur);
    window.addEventListener("focus", onFocus);
    document.addEventListener("mouseleave", onMouseLeave);
    document.addEventListener("mouseenter", onMouseEnter);
    prevNow = performance.now();
    raf = requestAnimationFrame(loop);
  }
  function detach() {
    if (!mountEl) return;
    cancelAnimationFrame(raf);
    resizeObserver.disconnect();
    window.removeEventListener("pointermove", onPointerMove);
    window.removeEventListener("pointerdown", onPointerDown);
    window.removeEventListener("blur", onBlur);
    window.removeEventListener("focus", onFocus);
    document.removeEventListener("mouseleave", onMouseLeave);
    document.removeEventListener("mouseenter", onMouseEnter);
    el.remove();
    mountEl = null;
  }

  const stage = { attach, detach, quack, step };
  // Deterministic hook for automated verification, same spirit as the
  // game's window.rl: rAF pauses in background tabs, so tests drive
  // frames by hand.
  window.__menuDuck = stage;
  return stage;
}

export default function MenuDuck({ sx }) {
  const hostRef = useRef(null);

  useEffect(() => {
    let disposed = false;
    getStage()
      .then((stage) => {
        if (disposed) return;
        if (hostRef.current) stage.attach(hostRef.current);
      })
      .catch((e) => console.warn("menu duck disabled:", e));
    return () => {
      disposed = true;
      liveStage?.detach();
    };
  }, []);

  return (
    <Box
      ref={hostRef}
      aria-hidden
      sx={{
        position: "relative",
        touchAction: "manipulation",
        userSelect: "none",
        ...sx,
      }}
    />
  );
}