diff --git a/app/(tabs)/homies/companion-stage/[botId].tsx b/app/(tabs)/homies/companion-stage/[botId].tsx index ebaf215..93fafb6 100644 --- a/app/(tabs)/homies/companion-stage/[botId].tsx +++ b/app/(tabs)/homies/companion-stage/[botId].tsx @@ -53,13 +53,29 @@ const MODE_LABELS: Record = { }; /** - * Detect "show me a trick" intents so Kaori demonstrates with her body - * while she explains. First trick in the library: frontside 360. + * WHICH 360 does this text name? Identification only — no demo-intent gate, so + * it also works on Kaori's own reply ("...watch this backside 360..."). + * Backside spins the other way; plain "360" / "frontside 360" stays frontside. + */ +function detectTrickId(text: string): TrickId | null { + // Flips first — a wildcat/tamedog is a flip, not a 360 ("backflip" contains "back"). + if (/\b(wildcat|back[\s-]?flip)\b/i.test(text)) return 'wildcat'; + if (/\b(tamedog|tame[\s-]?dog|front[\s-]?flip)\b/i.test(text)) return 'tamedog'; + const mentions360 = /\b(360|three[\s-]?sixty|(front|back)side\s*3|(fs|bs)\s*3|back\s*3)\b/i.test( + text, + ); + if (!mentions360) return null; + const isBackside = /\b(backside|bs)\s*(360|three[\s-]?sixty|3)\b|\bback\s*3\b/i.test(text); + return isBackside ? 'backside-360' : 'frontside-360'; +} + +/** + * Detect "show me a trick" intents so Kaori demonstrates with her body while + * she explains. Intent verb + a named trick → that trick. */ function detectTrickDemo(text: string): TrickId | null { - const wantsDemo = /\b(show|demo|demonstrate|do)\b/i.test(text); - const mentions360 = /\b(360|three[\s-]?sixty|frontside\s*3)\b/i.test(text); - return wantsDemo && mentions360 ? 'frontside-360' : null; + const wantsDemo = /\b(show|demo|demonstrate|do|see|watch|hit|throw|bust|try|land)\b/i.test(text); + return wantsDemo ? detectTrickId(text) : null; } /** Split a reply the way Kith chunks speech — one sentence per turn. */ @@ -79,6 +95,13 @@ function actionForSentence(sentence: string): Exclude | null if (/watch|let me show|show you|check (this|it)|like this|here (we|it) go/i.test(sentence)) { return 'full'; } + if ( + /\b(wildcat|tamedog|tame[\s-]?dog|back[\s-]?flip|front[\s-]?flip|flip|invert|somersault)\b/i.test( + sentence, + ) + ) { + return 'full'; + } if (/\b(spin|rotat\w*|360|three[\s-]?sixty)\b/i.test(sentence)) return 'full'; if (/\b(wind|coil|crouch|bend|set[\s-]?up|load)\b/i.test(sentence)) return 'setup'; if (/\b(pop|jump|snap|spring)\b/i.test(sentence)) return 'pop'; @@ -93,19 +116,27 @@ export default function CompanionStageScreen() { const demoState = useRef(createTrickDemoState()); const replySentences = useRef(null); - const { voiceState, voiceReady, getSessionId, bargeIn, reassertPlayback, setMode, beginReply } = - useKithVoice({ - onAssistantSentence: (index) => { - const sentences = replySentences.current; - if (!sentences) return; - const action = actionForSentence(sentences[index] ?? ''); - if (action) startAction(demoState.current, action); - }, - onReplyDone: () => { - demoState.current.session = false; - replySentences.current = null; - }, - }); + const { + voiceState, + voiceReady, + getSessionId, + bargeIn, + stop, + reassertPlayback, + setMode, + beginReply, + } = useKithVoice({ + onAssistantSentence: (index) => { + const sentences = replySentences.current; + if (!sentences) return; + const action = actionForSentence(sentences[index] ?? ''); + if (action) startAction(demoState.current, action); + }, + onReplyDone: () => { + demoState.current.session = false; + replySentences.current = null; + }, + }); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); @@ -167,7 +198,12 @@ export default function CompanionStageScreen() { const wantsDemo = reply && (requestedTrick !== null || /watch this|let me show/i.test(reply)); if (wantsDemo) { - if (requestedTrick) demoState.current.trick = requestedTrick; + // ALWAYS resolve the trick — the user's explicit ask first, else what + // her reply names, else keep the current one. Never fall through to a + // stale/default frontside when the user asked for a backside. + const trick = requestedTrick ?? detectTrickId(reply) ?? demoState.current.trick; + demoState.current.trick = trick; + if (__DEV__) console.log('[stage] demo trick:', trick); demoState.current.session = true; demoState.current.idleT = 0; replySentences.current = splitSentences(reply); @@ -230,6 +266,19 @@ export default function CompanionStageScreen() { toggleVoiceInput(); }, [listening, bargeIn, toggleVoiceInput]); + // Silence Kaori (and stop the mic) the moment the stage loses focus — + // navigating away or hitting back. expo-router keeps this screen mounted so + // the useKithVoice unmount cleanup won't fire; without this her voice keeps + // talking after you leave. Fires exactly once on the focused→blurred edge. + const wasFocused = useRef(isFocused); + useEffect(() => { + if (wasFocused.current && !isFocused) { + stop(); + if (listening) toggleVoiceInput(); + } + wasFocused.current = isFocused; + }, [isFocused, stop, listening, toggleVoiceInput]); + const lastMessages = messages.slice(-2); return ( diff --git a/src/components/companion/KaoriStage.tsx b/src/components/companion/KaoriStage.tsx index 5f22204..8c35b10 100644 --- a/src/components/companion/KaoriStage.tsx +++ b/src/components/companion/KaoriStage.tsx @@ -22,6 +22,7 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { brandColors } from '@/constants/colors'; +import { SnowWorld } from './SnowWorld'; import { createTrickDemoState, driveDemo, @@ -486,6 +487,9 @@ function downgradeMToonMaterials(root: THREE.Object3D, glCtx: WebGL2RenderingCon side: mtoon.side, depthWrite: mtoon.depthWrite, depthTest: mtoon.depthTest, + // Kaori is unlit and always in the foreground — never fog her (the snow + // world adds scene fog; MeshBasicMaterial.fog defaults to true). + fog: false, }); replacement.name = mtoon.name; mtoon.dispose(); @@ -543,21 +547,51 @@ function KaoriModel({ const elapsed = useRef(0); useFrame((_, delta) => { elapsed.current += delta; - if (isDemoActive(demo.current)) { + const demoActive = isDemoActive(demo.current); + if (demoActive) { // The demo session owns the body; face keeps talking (mouth/blink/ // emotes) so she narrates while riding and performing. const active = driveDemo(vrm, demo.current, delta); driveFace(vrm, elapsed.current, voice.current); - vrm.scene.rotation.y = demo.current.rootYaw; - vrm.scene.position.y = demo.current.rootY; + const st = demo.current; + // Compose the whole-body orientation: YAW (stance + spin, about Y) THEN + // PITCH (flip, about her local board long-axis). q = qYaw * qPitch so the + // flip axis rotates WITH her facing. Pure 360 → rootPitch=0 → qPitch= + // identity → q is pure Y yaw, identical to before. + _flipQYaw.setFromAxisAngle(_flipYAxis, st.rootYaw); + _flipQPitch.setFromAxisAngle(_flipPitchAxis, st.rootPitch); + _flipQ.copy(_flipQYaw).multiply(_flipQPitch); + vrm.scene.quaternion.copy(_flipQ); + // Publish the root quaternion so lockBoardToFeet can roll the deck with her + // through a flip inversion (else it stays world-flat while she inverts). + st.rootQuat[0] = _flipQ.x; + st.rootQuat[1] = _flipQ.y; + st.rootQuat[2] = _flipQ.z; + st.rootQuat[3] = _flipQ.w; + // Pivot around the CoM/hip, not the feet: place the scene origin at + // arcCoM − q·comLocal so the hip sits at (0, rootY + COM_LOCAL_Y, 0) for + // every pitch angle (the body orbits the hip). pitch=0 → position.y=rootY. + _flipComOffset.set(0, COM_LOCAL_Y, 0).applyQuaternion(_flipQ); + vrm.scene.position.set( + -_flipComOffset.x, + st.rootY + COM_LOCAL_Y - _flipComOffset.y, + -_flipComOffset.z, + ); if (!active) { - vrm.scene.rotation.y = 0; - vrm.scene.position.y = 0; + vrm.scene.quaternion.identity(); + vrm.scene.position.set(0, 0, 0); } } else { driveCharacter(vrm, elapsed.current, delta, voice.current); } vrm.update(delta); + // With the skeleton fully updated, lock the trick board under the actual + // feet so the bindings stay attached and the board angle follows the legs. + if (demoActive && demo.current.boardOpacity > 0.05) { + lockBoardToFeet(vrm, demo.current); + } else { + demo.current.boardLocked = false; + } }); return ; @@ -593,6 +627,95 @@ function makeBoardGeometry( return geometry; } +// Scratch objects reused every frame so locking the board allocates nothing. +const _footL = new THREE.Vector3(); +const _footR = new THREE.Vector3(); +const _boardX = new THREE.Vector3(); +const _boardY = new THREE.Vector3(); +const _boardZ = new THREE.Vector3(); +const _worldFwd = new THREE.Vector3(0, 0, 1); +const _boardBasis = new THREE.Matrix4(); +const _boardQuat = new THREE.Quaternion(); +// Board "up" derived from her body (for flips) — see lockBoardToFeet. +const _bodyUp = new THREE.Vector3(); +const _rootQ = new THREE.Quaternion(); + +// --- Whole-body flip transform (yaw*pitch quaternion pivoted at the CoM) --- +// Hip/CoM height in the VRM scene-local frame (feet ~y=0; THIGH_LEN+SHIN_LEN ≈ +// 0.84 straight-leg foot→hip). The fixed pivot height for a flip — NOT rootY. +// Tune 0.80–0.90 on device if a flip orbits her waist/chest instead of her hips. +const COM_LOCAL_Y = 0.85; +const _flipQ = new THREE.Quaternion(); +const _flipQYaw = new THREE.Quaternion(); +const _flipQPitch = new THREE.Quaternion(); +const _flipYAxis = new THREE.Vector3(0, 1, 0); +// Flip axis = board's foot-to-foot line in her LOCAL frame (+X). If a flip +// tumbles face-on at the apex instead of head-over-heels, flip this to (-1,0,0). +const _flipPitchAxis = new THREE.Vector3(1, 0, 0); +const _flipComOffset = new THREE.Vector3(); +/** Foot bone ≈ ankle; drop the deck this far below the midpoint so the soles + * sit on top of the board rather than through it. */ +const BOARD_SOLE_DROP = 0.07; + +/** + * Lock the trick board to the rider's actual feet. The board's long axis (+X, + * where the bindings live at ±0.24) is aimed straight down the line between the + * two foot bones and the deck kept facing up, so the bindings stay under the + * soles and the board ANGLE follows the legs — lift the back leg and the tail + * rises because that foot rose. Writes the world transform into demo state for + * TrickBoard to copy. Must run after vrm.update() so the foot bones are posed. + */ +function lockBoardToFeet(vrm: VRM, state: TrickDemoState) { + const humanoid = vrm.humanoid; + const lf = humanoid?.getRawBoneNode('leftFoot'); + const rf = humanoid?.getRawBoneNode('rightFoot'); + if (!lf || !rf) { + state.boardLocked = false; + return; + } + lf.getWorldPosition(_footL); + rf.getWorldPosition(_footR); + + // Board long axis (+X) runs foot-to-foot; build an orthonormal, up-facing + // basis around it. Feet coincident (never really happens) keeps the last + // transform; a near-VERTICAL axis (big stylish leg-lift) rebuilds off + // world-forward so the board STAYS locked instead of unlocking and getting + // flung to the origin. + _boardX.subVectors(_footR, _footL); + if (_boardX.lengthSq() < 1e-6) { + state.boardLocked = false; + return; + } + _boardX.normalize(); + // Board "up" comes from HER body, not the world — so through a flip inversion + // the deck ROLLS with her and stays soles-down instead of lying world-flat + // under an upside-down rider. rootPitch=0 (spins) → body-up == world-up → + // unchanged. (The flip axis IS the foot line, so foot-to-foot never goes + // vertical; the world-fwd fallback is only for the near-degenerate stylish lift.) + _bodyUp.set(0, 1, 0); + if (state.rootPitch) { + _rootQ.set(state.rootQuat[0], state.rootQuat[1], state.rootQuat[2], state.rootQuat[3]); + _bodyUp.applyQuaternion(_rootQ); + } + _boardZ.crossVectors(_boardX, _bodyUp); + if (_boardZ.lengthSq() < 1e-4) { + _boardZ.crossVectors(_boardX, _worldFwd); + } + _boardZ.normalize(); + _boardY.crossVectors(_boardZ, _boardX).normalize(); + _boardBasis.makeBasis(_boardX, _boardY, _boardZ); + _boardQuat.setFromRotationMatrix(_boardBasis); + + state.boardPos[0] = (_footL.x + _footR.x) / 2 - _boardY.x * BOARD_SOLE_DROP; + state.boardPos[1] = (_footL.y + _footR.y) / 2 - _boardY.y * BOARD_SOLE_DROP; + state.boardPos[2] = (_footL.z + _footR.z) / 2 - _boardY.z * BOARD_SOLE_DROP; + state.boardQuat[0] = _boardQuat.x; + state.boardQuat[1] = _boardQuat.y; + state.boardQuat[2] = _boardQuat.z; + state.boardQuat[3] = _boardQuat.w; + state.boardLocked = true; +} + /** Stylized snowboard that appears under Kaori's feet during trick demos. */ function TrickBoard({ demo }: { demo: React.MutableRefObject }) { const groupRef = useRef(null); @@ -612,11 +735,18 @@ function TrickBoard({ demo }: { demo: React.MutableRefObject }) useFrame(() => { const group = groupRef.current; if (!group) return; - const { boardOpacity, rootYaw, boardY } = demo.current; - group.visible = boardOpacity > 0.01; + const { boardOpacity, boardPos, boardQuat } = demo.current; + // Cut off a bit higher than 0 so the board doesn't linger as a faint ghost + // after she's already stood back up (stance return + board vanish together). + group.visible = boardOpacity > 0.05; if (!group.visible) return; - group.position.y = boardY + 0.045; - group.rotation.y = rootYaw; + // Bindings stay glued to the feet — lockBoardToFeet writes boardPos/boardQuat + // each frame after the skeleton is posed. If a frame fails to lock (first + // frame / missing bone / degenerate basis) these hold the LAST good + // transform, so the deck never flings to the world origin under an airborne, + // spinning Kaori (barely visible during fade-in anyway). + group.position.set(boardPos[0], boardPos[1], boardPos[2]); + group.quaternion.set(boardQuat[0], boardQuat[1], boardQuat[2], boardQuat[3]); if (deckRef.current) deckRef.current.opacity = boardOpacity; if (baseRef.current) baseRef.current.opacity = boardOpacity; }); @@ -626,11 +756,23 @@ function TrickBoard({ demo }: { demo: React.MutableRefObject }) {/* Deck — sakura-pink topsheet (matches Kaori's jacket, pops against the dark floor), long axis through the rider's feet */} - + {/* White rails/base peeking out around the deck */} - + {/* Binding hints */} @@ -672,7 +814,7 @@ function StageSet() { {/* Brand-yellow stage ring */} - + ); @@ -805,8 +947,11 @@ export function KaoriStage({ active = true, voiceState, demoState }: KaoriStageP }} style={styles.canvas} > + {/* Seeds scene.background for frame 0; SnowWorld owns it per-frame after. */} + {/* Alpine world that crossfades in as she straps onto the board. */} + diff --git a/src/components/companion/SnowWorld.tsx b/src/components/companion/SnowWorld.tsx new file mode 100644 index 0000000..5c78026 --- /dev/null +++ b/src/components/companion/SnowWorld.tsx @@ -0,0 +1,234 @@ +/** + * SnowWorld — a self-contained alpine scene that crossfades IN as Kaori straps + * onto her board and OUT as she steps off. Rendered inside the KaoriStage Canvas + * alongside StageSet, driven by ONE scalar: the demo's `stance` weight (the same + * 0→1 signal that fades the board in). Zero React re-renders during the fade — + * every frame it reads demo.current.stance and mutates materials / fog / + * background in place. + * + * expo-gl constraints honored: three@0.170, NO drei, PNG-free (everything is + * procedural / vertex-colored), light geometry, alloc-free per-frame. + */ +import { useFrame, useThree } from '@react-three/fiber/native'; +import { type MutableRefObject, useMemo, useRef } from 'react'; +import * as THREE from 'three'; +import { clamp01, easeInOut } from './riderFundamentals'; +import type { TrickDemoState } from './trickAnimations'; + +// Studio (weight 0) vs snow (weight 1) clear color. scene.background is a Color +// (rendered by CLEARING — can't be alpha-faded), so we lerp its RGB in place. +const STUDIO_BG = new THREE.Color('#0b0e17'); // matches the seed tag +const SNOW_BG = new THREE.Color('#bcd3ea'); +const FOG_COLOR = new THREE.Color('#cfe0f2'); +const SNOW_FOG_DENSITY = 0.032; + +const SNOW_BOX = 14; // XZ spread of the flake volume (centered on origin) +const SNOW_TOP = 9; // Y ceiling flakes wrap back to +const SNOW_COUNT = 700; // keep <= ~900 (the Hermes JS wrap loop, not the GPU, is the cost) + +/** Deterministic RNG so remounts look identical (no build-time Math.random). */ +function mulberry32(seed: number) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** SKY: one inverted, vertex-colored icosahedron (unlit, BackSide, never fogged). */ +function makeSkyGeometry(): THREE.BufferGeometry { + const R = 40; + const geo = new THREE.IcosahedronGeometry(R, 2); // ~320 tris + const pos = geo.attributes.position; + const top = new THREE.Color('#2b5c9e'); // deep cool blue overhead + const horizon = new THREE.Color('#dfeaf7'); // pale ice at the horizon + const colors = new Float32Array(pos.count * 3); + const c = new THREE.Color(); + for (let i = 0; i < pos.count; i++) { + const y = pos.getY(i) / R; // -1..1 + const t = THREE.MathUtils.clamp((y + 0.15) / 0.9, 0, 1); + c.copy(horizon).lerp(top, t); + 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; +} + +/** MOUNTAINS: a ring of low-poly vertex-colored cones (slate base → white cap). */ +function makeMountains(): THREE.BufferGeometry[] { + const base = new THREE.Color('#5b708c'); + const cap = new THREE.Color('#f2f7ff'); + const geos: THREE.BufferGeometry[] = []; + const rng = mulberry32(1337); + for (let i = 0; i < 14; i++) { + const h = 6 + rng() * 10; + const r = 3 + rng() * 4; + const g = new THREE.ConeGeometry(r, h, 5 + Math.floor(rng() * 3), 1); + const pos = g.attributes.position; + const cols = new Float32Array(pos.count * 3); + const c = new THREE.Color(); + for (let v = 0; v < pos.count; v++) { + const yy = pos.getY(v) / h + 0.5; // 0 base .. 1 tip + c.copy(base).lerp(cap, THREE.MathUtils.smoothstep(yy, 0.55, 0.9)); + cols[v * 3] = c.r; + cols[v * 3 + 1] = c.g; + cols[v * 3 + 2] = c.b; + } + g.setAttribute('color', new THREE.BufferAttribute(cols, 3)); + const ang = (i / 14) * Math.PI * 2 + rng() * 0.3; + const dist = 20 + rng() * 10; + g.translate(Math.sin(ang) * dist, h / 2 - 0.5, Math.cos(ang) * dist); + geos.push(g); + } + return geos; +} + +function makeSnowPoints(count: number, box: number, top: number) { + const positions = new Float32Array(count * 3); + const vel = new Float32Array(count); + const rng = mulberry32(7); + for (let i = 0; i < count; i++) { + positions[i * 3] = (rng() - 0.5) * box; + positions[i * 3 + 1] = rng() * top; + positions[i * 3 + 2] = (rng() - 0.5) * box; + vel[i] = 0.6 + rng() * 0.7; + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + return { geo, vel }; +} + +export function SnowWorld({ demo }: { demo: MutableRefObject }) { + const { scene } = useThree(); + + const skyGeo = useMemo(makeSkyGeometry, []); + const mountainGeos = useMemo(makeMountains, []); + const snow = useMemo(() => makeSnowPoints(SNOW_COUNT, SNOW_BOX, SNOW_TOP), []); + + const skyMat = useRef(null); + const groundMat = useRef(null); + const mountainMats = useRef([]); + const snowMat = useRef(null); + const snowRef = useRef(null); + const keyLight = useRef(null); + const fillLight = useRef(null); + + // Fog owned here so density is animatable. FogExp2 = cheap per-pixel exp fog. + const fog = useMemo(() => new THREE.FogExp2(FOG_COLOR.getHex(), 0), []); + // Only flip scene.fog (a #define → shader recompile) at the threshold, not every frame. + const fogOn = useRef(false); + const bg = useMemo(() => new THREE.Color(), []); + + useFrame((_, delta) => { + // Same eased weight the board uses (boardOpacity = easeInOut(clamp01(stance))). + const w = easeInOut(clamp01(demo.current.stance)); + + // Background crossfade (mutate the existing Color in place — no GC churn). + bg.copy(STUDIO_BG).lerp(SNOW_BG, w); + if (scene.background instanceof THREE.Color) scene.background.copy(bg); + else scene.background = bg.clone(); + + // Fog fades in; toggle scene.fog only at the threshold. + fog.density = SNOW_FOG_DENSITY * w; + const wantFog = w > 0.01; + if (wantFog !== fogOn.current) { + scene.fog = wantFog ? fog : null; + fogOn.current = wantFog; + } + + // Opacity crossfades (all fade materials are transparent). + if (skyMat.current) skyMat.current.opacity = w; + if (groundMat.current) groundMat.current.opacity = w; + for (const m of mountainMats.current) if (m) m.opacity = w; + if (snowMat.current) snowMat.current.opacity = 0.85 * w; + + // Alpine lights ramp in (studio lights stay; these ADD a cool key + fill). + if (keyLight.current) keyLight.current.intensity = 1.6 * w; + if (fillLight.current) fillLight.current.intensity = 0.5 * w; + + // Animate snow only when visible (studio mode = nearly free). + if (w > 0.02 && snowRef.current) { + const p = snowRef.current.geometry.attributes.position as THREE.BufferAttribute; + const arr = p.array as Float32Array; + for (let i = 0; i < SNOW_COUNT; i++) { + const iy = i * 3 + 1; + arr[iy] -= snow.vel[i] * delta; + arr[i * 3] += Math.sin((arr[iy] + i) * 0.6) * 0.01; // gentle sway + if (arr[iy] < 0) { + arr[iy] = SNOW_TOP; // wrap to ceiling, re-scatter XZ + arr[i * 3] = (Math.random() - 0.5) * SNOW_BOX; + arr[i * 3 + 2] = (Math.random() - 0.5) * SNOW_BOX; + } + } + p.needsUpdate = true; // reuse the Float32Array; never recreate the geometry + } + }); + + return ( + + {/* SKY — inverted vertex-colored sphere, never fogged, drawn first */} + + + + + {/* GROUND — big lit snow plane at y=0.02, just above the studio disc/ring */} + + + + + + {/* MOUNTAINS — distant vertex-colored cones (fog ON so they recede) */} + {mountainGeos.map((g, i) => ( + + { + if (m) mountainMats.current[i] = m as THREE.MeshStandardMaterial; + }} + vertexColors + roughness={1} + metalness={0} + transparent + opacity={0} + /> + + ))} + + {/* SNOW — one THREE.Points, plain white square points (expo-gl safe: NO map) */} + + + + + {/* Alpine lights (fade in via intensity; Kaori is unlit so pays nothing) */} + + + + ); +} diff --git a/src/components/companion/riderFundamentals.ts b/src/components/companion/riderFundamentals.ts index 5b65d16..68ed2c2 100644 --- a/src/components/companion/riderFundamentals.ts +++ b/src/components/companion/riderFundamentals.ts @@ -20,6 +20,7 @@ export type Humanoid = NonNullable; // --- Easing / timeline helpers --- export const clamp01 = (v: number) => Math.min(1, Math.max(0, v)); +export const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); export const easeInOut = (u: number) => u * u * (3 - 2 * u); export const lerp = (a: number, b: number, u: number) => a + (b - a) * u; /** Progress 0→1 of t between phase bounds. */ @@ -59,8 +60,11 @@ export function jumpArc(u: number, height: number): number { * All fields are normalized amounts the appliers translate to bones. */ export interface RiderPose { - /** Rotation progress 0→1 (multiplied by the trick's total spin). */ + /** Yaw rotation progress 0→1 (multiplied by the trick's total spin). */ spin: number; + /** Flip progress 0→1 (multiplied by the trick's total FLIP — a whole-body + * PITCH about the board's long axis, parallel to `spin`). 0 for pure spins. */ + pitch: number; /** Root height above the board line (jump arc). */ height: number; /** 0 straight legs → 1 full squat (hip drop is derived from this). */ @@ -74,10 +78,26 @@ export interface RiderPose { /** Head: lead the spin (yaw) and spot the landing (pitch down). */ headLead: number; headSpot: number; + /** Head tilt/roll toward the leading shoulder (radians) — "head over the shoulder". */ + headRoll: number; + /** Asymmetric leg raise for stylish variants (0 → 1). Regular stance: + * front = left leg (lead foot), back = right leg. Lifting a leg bends its + * knee up so the corresponding end of the board can angle up. */ + backLegLift: number; + frontLegLift: number; + /** Board angle around its long axis (radians; + = tail up / nose down). */ + boardTilt: number; + /** Spin direction: +1 frontside (CCW / +Y), -1 backside (CW / -Y). Flips the + * body wind, head look and arm wrap so a backside READS as backside. The + * actual rotation is carried by the trick's signed totalSpin, and `coil` + * stays frontside-signed either way so the arm load→whip TIMING is preserved + * (applyArms keys windup/whip off the coil sign). */ + dir: number; } export const REST_POSE: RiderPose = { spin: 0, + pitch: 0, height: 0, crouch: STANCE_CROUCH, coil: 0, @@ -85,100 +105,231 @@ export const REST_POSE: RiderPose = { balance: 0, headLead: 0, headSpot: 0, + headRoll: 0, + backLegLift: 0, + frontLegLift: 0, + boardTilt: 0, + dir: 1, }; -/** Relaxed on-board bounce while she talks between moves. */ +/** Head turn to look "downhill" — down the board toward the nose (front foot, + * +X side) like a rider watching where they're going — rather than square + * across the board / off to the side. Applied to headLead (neck yaw). Raise + * toward ~0.9 for more of a look down the board, lower for squarer. */ +export const DOWNHILL_LOOK = 0.6; +/** Small chin-down that pairs with the downhill look (gaze slightly down the + * slope, not level). Applied to headSpot at rest AND at the trick's settle. */ +export const DOWNHILL_CHIN = 0.08; + +/** Relaxed on-board bounce while she talks between moves — gaze downhill, + * head turned down the board toward the nose (not square across / off-camera). */ export function stanceIdlePose(idleT: number): RiderPose { return { ...REST_POSE, crouch: STANCE_CROUCH + Math.sin(idleT * 1.6) * 0.035, coil: Math.sin(idleT * 0.7) * 0.05, + headLead: DOWNHILL_LOOK, + headSpot: DOWNHILL_CHIN, }; } // --- Bone appliers --- -function applyLegs(humanoid: Humanoid, crouch: number, stanceWeight: number) { +function applyLegs( + humanoid: Humanoid, + crouch: number, + stanceWeight: number, + backLegLift = 0, + frontLegLift = 0, +) { const spread = STANCE_SPREAD * stanceWeight; for (const side of ['left', 'right'] as const) { + // Regular stance: left leg leads (front), right leg is back. + const lift = side === 'left' ? frontLegLift : backLegLift; const upper = humanoid.getNormalizedBoneNode(`${side}UpperLeg`); const lower = humanoid.getNormalizedBoneNode(`${side}LowerLeg`); const foot = humanoid.getNormalizedBoneNode(`${side}Foot`); if (upper) { - // Thigh pitches forward (knee travels toward the toe side) - upper.rotation.x = -crouch * THIGH_FLEX; + // Thigh pitches forward (knee travels toward the toe side); a leg lift + // raises the thigh a little so the knee comes up. + upper.rotation.x = -crouch * THIGH_FLEX - lift * 0.5; // Splay OUTWARD: her left leg sits on +X (she faces the camera) upper.rotation.z = side === 'left' ? spread : -spread; } - // Shin folds back under the thigh — the human knee hinge - if (lower) lower.rotation.x = crouch * SHIN_FLEX; - // Keep the sole flat on the board - if (foot) foot.rotation.x = -crouch * (SHIN_FLEX - THIGH_FLEX); + // Shin folds back under the thigh — the human knee hinge; a lift folds it + // more so that foot lifts off the board. + if (lower) lower.rotation.x = crouch * SHIN_FLEX + lift * 1.3; + // Keep the sole flat on the board (relaxed when the foot is lifted) + if (foot) foot.rotation.x = -crouch * (SHIN_FLEX - THIGH_FLEX) + lift * 0.4; } } -function applyTorsoAndHead(humanoid: Humanoid, pose: RiderPose) { +function applyTorsoAndHead(humanoid: Humanoid, pose: RiderPose, w: number) { const hips = humanoid.getNormalizedBoneNode('hips'); const spine = humanoid.getNormalizedBoneNode('spine'); const chest = humanoid.getNormalizedBoneNode('chest'); const neck = humanoid.getNormalizedBoneNode('neck'); + // Every channel scales by the stance weight `w` (neutral = 0 for all of + // them), so the whole upper body eases IN as she steps onto the board and OUT + // as she steps off — no torso/head SNAP at the idle↔demo handoff (that snap, + // e.g. headLead jumping 0→0.6 at full strength while the legs were still + // ramping, was the begin/end "glitch back into home position"). + // The coil-driven WIND flips with the spin direction (pose.dir) so a backside + // winds the opposite way; headLead/headRoll already carry dir from the pose. + const wind = pose.coil * pose.dir; if (hips) { - hips.rotation.y = pose.coil * 0.35; + hips.rotation.y = wind * 0.35 * w; hips.rotation.z = 0; } if (spine) { - spine.rotation.y = pose.coil * 0.5; - spine.rotation.x = pose.crouch * 0.3 + pose.tuck * 0.25; + spine.rotation.y = wind * 0.5 * w; + spine.rotation.x = (pose.crouch * 0.3 + pose.tuck * 0.25) * w; spine.rotation.z = 0; } if (chest) { - chest.rotation.y = pose.coil * 0.45; - chest.rotation.x = pose.crouch * 0.18; + chest.rotation.y = wind * 0.45 * w; + chest.rotation.x = pose.crouch * 0.18 * w; } if (neck) { - neck.rotation.y = pose.coil * 0.4 + pose.headLead; - neck.rotation.x = pose.headSpot - pose.tuck * 0.15; - neck.rotation.z = 0; + neck.rotation.y = (wind * 0.4 + pose.headLead) * w; + neck.rotation.x = (pose.headSpot - pose.tuck * 0.15) * w; + // Tilt the head over the leading shoulder while spinning/spotting. + neck.rotation.z = pose.headRoll * w; } } -function applyArms(humanoid: Humanoid, pose: RiderPose) { +function applyArms(humanoid: Humanoid, pose: RiderPose, w: number) { + // Rest (from T-pose): uz hangs the arms DOWN at the sides; ux ~0 = neutral + // fwd/back; fz = slight elbow bend. Everything below is RELATIVE to the + // chest, which already carries pose.coil * 0.45 of shoulder rotation — so we + // deliberately ADD arm motion on top of that so the arms read as alive + // instead of dead pendulums hanging off spinning shoulders. const rest = { uz: 1.15, ux: 0.06, fz: 0.15 }; + + // --- Tuning magnitudes --- + const SWING = 0.9; // fwd/back pump of a DOWN arm about upper.rotation.x (coil load/throw) + const LIFT_COIL = 0.55; // how far the arms come UP off the sides at full coil + const LIFT_TUCK = 0.45; // arms pulled in during the airborne tuck + const LIFT_BAL = 0.55; // arms thrown wide for landing balance + const LIFT_AIR = 0.6; // draw-in lift carried through the WHOLE air/spin + const CROSS = 0.4; // cross-body wrap on the whip (upper.rotation.y) + const WRAP_AIR = 0.6; // continuous cross-body wrap that travels WITH the spin + const AIR_SWING = 0.5; // fwd swing of the arms into the spin through the air + const ELBOW = 0.75; // elbow flexion added while winding/whipping + const ELBOW_AIR = 0.8; // elbows fold in as the arms wrap around mid-air + const CATCH = 0.25; // arms fling wide/back on the balance catch + + // coil runs the full -0.7 (wound up) -> +0.5 (whip at pop) -> ~0 range. + // windup>0 ONLY while coil is negative (the load phase). whip>0 ONLY while + // coil is positive (the throw at/after the pop). Both UNCHANGED so the good + // frontside wind-up load/throw keeps its shape. const windup = clamp01(-pose.coil / 0.7); - const uzTarget = rest.uz - pose.tuck * 0.45 - pose.balance * 0.55 + windup * 0.1; - const uxTarget = rest.ux + pose.tuck * 0.35 + windup * 0.25; - const fzTarget = rest.fz + pose.tuck * 0.55; + const whip = clamp01(pose.coil / 0.5); + const coilMag = clamp01(Math.abs(pose.coil) / 0.7); + + // The OLD problem: past the pop, coil decays to ~0 so windup=whip=0 and the + // arms went DEAD at the sides for the whole rotation. These two bells give the + // arms a driver for the ENTIRE air, C0-seamless at both boundaries: + // airDrive = pose.tuck -> already sin(π·air): 0 at the pop & land edges. + // spinSwing = sin(π·spin) -> 0 at spin=0 (stance handoff) AND spin=1 (opens + // for the landing), peaks at mid-spin — exactly where the arms hung still. + const airDrive = pose.tuck; + const spinSwing = Math.sin(Math.PI * clamp01(pose.spin)); + + // upper.rotation.z (abduction): LOWER uz => arm rises toward the T. Rise on + // coil, DRAW IN across the whole air (LIFT_AIR via spinSwing), tuck tighter, + // wide on land. Clamp so it never rotates PAST the T into overhead. + const uzTarget = Math.max( + 0.1, + rest.uz - + coilMag * LIFT_COIL - + pose.tuck * LIFT_TUCK - + spinSwing * LIFT_AIR - + pose.balance * LIFT_BAL, + ); + + // upper.rotation.x — fwd/back swing of a DOWN arm (−x = forward). coil loads + // BACK in the wind-up then THROWS FORWARD at the pop (UNCHANGED). On top, + // AIR_SWING keeps the arms swung into the spin across the whole air instead of + // drifting back to neutral once coil fades. + const swingBase = rest.ux + pose.coil * SWING + pose.tuck * 0.35 + spinSwing * AIR_SWING; for (const side of ['left', 'right'] as const) { - const sign = side === 'left' ? -1 : 1; + const sign = side === 'left' ? -1 : 1; // left arm on +X, right on -X const upper = humanoid.getNormalizedBoneNode(`${side}UpperArm`); const lower = humanoid.getNormalizedBoneNode(`${side}LowerArm`); const hand = humanoid.getNormalizedBoneNode(`${side}Hand`); + + // The BACK arm leads the throw and reaches ACROSS the chest, the front arm + // trails — bias the leader a touch more forward. Which arm leads flips with + // the spin direction: frontside (+Y) the back/RIGHT arm leads; backside the + // front/LEFT. This asymmetry reads as a real wrap, not a puppet swing. + const lead = (pose.dir > 0 ? side === 'right' : side === 'left') ? 1 : -0.6; + // On the landing she flings the arms wide and slightly back to catch balance. + const catchSwing = -pose.balance * CATCH; + + // Everything below eases from the REST (arms-down) pose by the stance + // weight `w`, so the arms blend in/out at the idle↔demo handoff instead of + // snapping (neutral = rest for uz/ux/fz/hand, 0 for the wrap/pitch). if (upper) { - upper.rotation.z = sign * uzTarget; - upper.rotation.x = uxTarget; - upper.rotation.y = windup * 0.35; + upper.rotation.z = sign * lerp(rest.uz, uzTarget, w); + // Lead arm reaches a touch farther forward through the air too, so the + // wrap reads like a lead/trail pair, not a symmetric flap. + upper.rotation.x = lerp( + rest.ux, + swingBase + lead * whip * 0.35 + lead * spinSwing * airDrive * 0.15 + catchSwing, + w, + ); + // Cross-body wrap: hands travel AROUND the torso. OLD wrap fired only on + // whip+tuck (dead through the air); the WRAP_AIR term now carries it for + // the whole rotation. Wraps the OPPOSITE way backside (× dir). + upper.rotation.y = + pose.dir * sign * (whip * CROSS + spinSwing * WRAP_AIR + pose.tuck * 0.3) * w; } - if (lower) lower.rotation.z = sign * fzTarget; - if (hand) hand.rotation.x = 0.1; + // Elbows bend as she loads and whips, fold TIGHTER as the arms wrap around + // mid-air (ELBOW_AIR via the spin bell), and pull in on the tuck. + if (lower) { + lower.rotation.z = + sign * + lerp( + rest.fz, + rest.fz + pose.tuck * 0.55 + (windup + whip) * ELBOW * 0.4 + spinSwing * ELBOW_AIR * 0.35, + w, + ); + lower.rotation.x = -(windup * 0.5 + whip * 0.7 + pose.tuck * 0.6 + spinSwing * 0.45) * w; + } + if (hand) hand.rotation.x = lerp(0.1, 0.1 + pose.tuck * 0.2 + spinSwing * 0.15, w); } } /** Apply a full rider pose to the skeleton (legs scaled by stance weight). */ export function applyRiderPose(humanoid: Humanoid, pose: RiderPose, stanceWeight: number) { - applyLegs(humanoid, pose.crouch * stanceWeight, stanceWeight); - applyTorsoAndHead(humanoid, pose); - applyArms(humanoid, pose); + applyLegs( + humanoid, + pose.crouch * stanceWeight, + stanceWeight, + pose.backLegLift * stanceWeight, + pose.frontLegLift * stanceWeight, + ); + applyTorsoAndHead(humanoid, pose, stanceWeight); + applyArms(humanoid, pose, stanceWeight); } -/** Zero out the bones the idle system never touches (legs, arm Y). */ +/** Zero out the bones the idle system never touches (legs, arm Y, neck roll/pitch). */ export function resetRiderBones(humanoid: Humanoid) { applyLegs(humanoid, 0, 0); for (const side of ['left', 'right'] as const) { const upper = humanoid.getNormalizedBoneNode(`${side}UpperArm`); if (upper) upper.rotation.y = 0; } + // The idle system re-drives neck yaw each frame but not roll/pitch — clear + // any leftover head-over-shoulder tilt / spot from a trick. + const neck = humanoid.getNormalizedBoneNode('neck'); + if (neck) { + neck.rotation.x = 0; + neck.rotation.z = 0; + } } // --- Fundamental mini-demos (reused as spoken-phase segments) --- diff --git a/src/components/companion/trickAnimations.ts b/src/components/companion/trickAnimations.ts index 32e1c56..37aa476 100644 --- a/src/components/companion/trickAnimations.ts +++ b/src/components/companion/trickAnimations.ts @@ -17,7 +17,10 @@ import type { VRM } from '@pixiv/three-vrm'; import { applyRiderPose, + clamp, clamp01, + DOWNHILL_CHIN, + DOWNHILL_LOOK, easeInOut, hipDropFor, jumpArc, @@ -27,7 +30,6 @@ import { POP_DEMO_DURATION, phase, popDemoPose, - REST_POSE, type RiderPose, resetRiderBones, STANCE_CROUCH, @@ -37,25 +39,49 @@ import { windUpDemoPose, } from './riderFundamentals'; -export type TrickId = 'frontside-360'; +export type TrickId = + | 'frontside-360' + | 'frontside-360-stylish' + | 'backside-360' + | 'wildcat' + | 'tamedog'; export type DemoAction = 'none' | 'full' | 'setup' | 'pop' | 'land'; export interface TrickTimeline { duration: number; - /** Total root rotation over the trick (radians; + is frontside/CCW). */ + /** Total root YAW over the trick (radians; + is frontside/CCW). */ totalSpin: number; + /** Total root PITCH/flip over the trick (radians, SIGNED: negative = backflip + * /wildcat over the tail, positive = frontflip/tamedog over the nose). Omit + * for pure spins (treated as 0). Yaw is about vertical, pitch about the + * board's long axis — the two compose independently. */ + totalFlip?: number; poseAt: (t: number) => RiderPose; } -// --- Frontside 360 --- +// --- 360 spins (frontside + backside share one core) --- const FS360_SETUP_END = 1.5; const FS360_POP_END = 1.8; const FS360_AIR_END = 3.1; const FS360_LAND_END = 3.7; const FS360_SETTLE_END = 4.2; -function frontside360PoseAt(t: number): RiderPose { +// Head-spotting knobs (see the head block in spin360PoseAt). +const NECK_CAP = 1.3; // anatomical neck-yaw limit (~75°) — forces the hold→whip +const HEAD_HOLD_FRAC = 0.5; // spin fraction she holds the gaze forward before whipping +const HEAD_WHIP_END = 0.9; // spin fraction the head has re-fixated forward by + +/** + * Shared 360 timeline for frontside AND backside so they never drift apart. + * `dir` = +1 frontside (CCW / +Y) or -1 backside (CW / -Y): it flips the body + * wind, head look and arm wrap (carried out via pose.dir in the appliers) so a + * backside READS as backside. The actual rotation is carried by the trick's + * signed totalSpin — NOT by the coil, which stays frontside-signed for BOTH so + * the arm load→whip TIMING (applyArms keys windup/whip off the coil sign) is + * preserved. + */ +function spin360PoseAt(t: number, dir: 1 | -1): RiderPose { const setup = phase(t, 0, FS360_SETUP_END); const pop = phase(t, FS360_SETUP_END, FS360_POP_END); const air = phase(t, FS360_POP_END, FS360_AIR_END); @@ -73,21 +99,209 @@ function frontside360PoseAt(t: number): RiderPose { if (land > 0.5) crouch = lerp(0.72, 0.35, easeInOut((land - 0.5) * 2)); if (settle > 0) crouch = lerp(0.35, STANCE_CROUCH, easeInOut(settle)); - // Coil: wind away during setup, whip through at pop, neutral by landing + // Coil: wind away during setup, whip through at pop. The SHOULDERS lead and + // the hips catch up (coil ratio in applyTorsoAndHead). Frontside-signed for + // BOTH directions — the -0.7→+0.5 progression is the load→whip TIMING; the + // WIND direction is flipped by pose.dir in the appliers, not here. let coil = -0.7 * easeInOut(setup); - if (pop > 0) coil = lerp(-0.7, 0.35, easeInOut(pop)); - if (air > 0) coil = lerp(0.35, 0.1, air); - if (land > 0) coil = lerp(0.1, 0, land); + if (pop > 0) coil = lerp(-0.7, 0.5, easeInOut(pop)); + if (air > 0) coil = lerp(0.5, 0.05, easeInOut(air)); + if (land > 0) coil = lerp(0.05, 0, land); const tuck = air > 0 && land === 0 ? Math.sin(Math.PI * air) : 0; const balance = land > 0 ? Math.sin(Math.PI * clamp01(land + settle * 0.4)) * (1 - settle) : 0; - // Head: lead the spin through the air, spot the landing from ~270° - const spotting = spin > 0.72; - const headLead = air > 0 && !spotting ? 0.45 : 0; - const headSpot = spotting && land < 1 ? 0.3 : 0; + // Head — HEAD-SPOTTING (one continuous curve, no snaps). The BODY sweeps a + // full dir*2π via rootYaw; the neck (its child) COUNTER-rotates to keep her + // GAZE on the landing. World head yaw = dir*2π*spin + neck.y, so the neck + // target to hold world-forward (the downhill riding gaze) is DOWNHILL_LOOK − + // dir*2π*spin, capped at the neck limit. She holds the gaze forward as long as + // she anatomically can, then the body drags her head around FAST (a quick apex + // glance, not the old slow blind lead-the-spin sweep) and she RE-FIXATES + // forward — measured from the END of the revolution — landing looking downhill. + // (Frontside holds a touch longer than backside because she spins toward her + // downhill gaze; that small asymmetry is physical, not a bug.) + const bodyYaw = dir * Math.PI * 2 * spin; + const holdLead = clamp(DOWNHILL_LOOK - bodyYaw, -NECK_CAP, NECK_CAP); + const reFixLead = clamp(DOWNHILL_LOOK - dir * Math.PI * 2 * (spin - 1), -NECK_CAP, NECK_CAP); + const whip = easeInOut(clamp01((spin - HEAD_HOLD_FRAC) / (HEAD_WHIP_END - HEAD_HOLD_FRAC))); + let headLead = lerp(holdLead, reFixLead, whip); + // Chin drops and head rolls over the shoulder AS she whips her eyes around to + // re-spot the snow, then relaxes — peaks mid-whip, zero at both ends. + const spotArc = Math.sin(Math.PI * whip); + let headSpot = lerp(DOWNHILL_CHIN, 0.32, spotArc); + let headRoll = dir * 0.26 * spotArc; + if (settle > 0) { + // End on the exact downhill riding gaze she started from. + const s = easeInOut(settle); + headLead = lerp(headLead, DOWNHILL_LOOK, s); + headSpot = lerp(headSpot, DOWNHILL_CHIN, s); + headRoll = lerp(headRoll, 0, s); + } + + return { + spin, + pitch: 0, // pure yaw spin — no flip + height, + crouch, + coil, + tuck, + balance, + headLead, + headSpot, + headRoll, + backLegLift: 0, + frontLegLift: 0, + boardTilt: 0, + dir, + }; +} - return { spin, height, crouch, coil, tuck, balance, headLead, headSpot }; +/** Frontside 360 — CCW / +Y. */ +function frontside360PoseAt(t: number): RiderPose { + return spin360PoseAt(t, 1); +} + +/** Backside 360 — CW / -Y (mirror). */ +function backside360PoseAt(t: number): RiderPose { + return spin360PoseAt(t, -1); +} + +/** + * A more STYLISH frontside 360: she lifts her back leg as she spins so the + * board angles (tail up), then ~3/4 through she pushes the back leg down and + * lifts the front leg (board angles the other way) to set up a tail-first + * landing, slapping the board down tail-then-nose. Head/shoulder work is + * inherited from the base FS360. (First pass — tune the leg/board amounts and + * the tilt axis/sign on-device.) + */ +function frontside360StylishPoseAt(t: number): RiderPose { + const base = frontside360PoseAt(t); + const air = phase(t, FS360_POP_END, FS360_AIR_END); + const land = phase(t, FS360_AIR_END, FS360_LAND_END); + const airborne = air > 0 && land === 0; + + // First ~3/4 of the spin vs the last quarter, measured off the spin progress. + const early = clamp01(base.spin / 0.72); + const late = clamp01((base.spin - 0.72) / 0.28); + + // Only the LEGS move here — the board is locked to the feet in KaoriStage and + // its angle follows them, so lifting a leg tilts that end of the board. + // First ~3/4: lift the BACK leg (tail rises). Last quarter: drop it and lift + // the FRONT leg (board angles the other way). + let backLegLift = airborne ? 0.9 * easeInOut(early) * (1 - easeInOut(late)) : 0; + let frontLegLift = airborne ? 0.7 * easeInOut(late) : 0; + + if (land > 0) { + // Land tail-first: the front foot stays up (nose up / tail down) at contact, + // then drops so the nose slaps down after it. + frontLegLift = 0.5 * (1 - easeInOut(clamp01(land * 2))); + backLegLift = 0; + } + + // boardTilt is unused now (the board derives its angle from the feet). + return { ...base, backLegLift, frontLegLift, boardTilt: 0 }; +} + +// --- Flips: wildcat (BACKFLIP over the tail) + tamedog (FRONTFLIP over the nose) +// A flip is a whole-body PITCH about the board's long axis, pivoted at the CoM +// (hip) in KaoriStage, coordinated with a bigger jump arc. It reuses the 360 +// phase skeleton but drives pose.PITCH (not pose.spin), so rootYaw stays pure +// STANCE_YAW*ease (totalSpin=0) and applyArms' spin-driven Y cross-body wrap +// stays ~0 (flip arms are SAGITTAL: tuck draw-in + balance fling, no Y whip). +const FLIP_PEAK_HEIGHT = 0.62; // bigger air than the 360's 0.55 +const FLIP_TUCK_PEAK = 0.9; // tighter than the 360 (knees-to-chest) + +/** + * Shared flip core. `dir` = -1 wildcat (backward / over the tail) or +1 tamedog + * (forward / over the nose). The actual rotation SIGN is carried by the trick's + * signed totalFlip in driveDemo; here `dir` only shapes the HEAD spot — the + * biggest read difference (wildcat spots the landing LATE/blind, tamedog EARLY). + */ +function flipPoseAt(t: number, dir: 1 | -1): RiderPose { + const setup = phase(t, 0, FS360_SETUP_END); + const pop = phase(t, FS360_SETUP_END, FS360_POP_END); + const air = phase(t, FS360_POP_END, FS360_AIR_END); + const land = phase(t, FS360_AIR_END, FS360_LAND_END); + const settle = phase(t, FS360_LAND_END, FS360_SETTLE_END); + + // FLIP progress → pose.pitch. Same 0.06 / easeInOut(air) / 0.06 shape as the + // 360's spin (velocity-matched at the pop/land edges) and parks EXACTLY at + // 1.0 == full 2π == identity (no landing snap). Fastest through the apex. + const pitch = pop > 0 ? clamp01(0.06 * pop + 0.88 * easeInOut(air) + 0.06 * land) : 0; + + // Bigger, slightly longer air than a 360. + const height = jumpArc(phase(t, FS360_POP_END - 0.08, FS360_AIR_END + 0.15), FLIP_PEAK_HEIGHT); + + // Crouch: deep vertical LOAD, explode at pop, refold through the air, absorb. + let crouch = lerp(STANCE_CROUCH, 0.7, easeInOut(setup)); + if (pop > 0) crouch = lerp(0.7, 0.08, easeInOut(pop)); + if (air > 0) crouch = lerp(0.08, 0.35, easeInOut(air)); + if (land > 0) crouch = lerp(0.35, 0.72, easeInOut(clamp01(land * 2))); + if (land > 0.5) crouch = lerp(0.72, 0.35, easeInOut((land - 0.5) * 2)); + if (settle > 0) crouch = lerp(0.35, STANCE_CROUCH, easeInOut(settle)); + + // COIL: NO Y-wind for a flip — stay ON-AXIS (a shoulder/hip twist corkscrews + // it off-axis). Just a small vertical load feel in setup, ~0 through the air. + let coil = -0.35 * easeInOut(setup); + if (pop > 0) coil = lerp(-0.35, 0, easeInOut(pop)); + + // TUCK: tighter than a 360 (knees-to-chest), bell across the air. + const tuck = air > 0 && land === 0 ? FLIP_TUCK_PEAK * Math.sin(Math.PI * air) : 0; + const balance = land > 0 ? Math.sin(Math.PI * clamp01(land + settle * 0.4)) * (1 - settle) : 0; + + // HEAD — pitch-based (headSpot = chin up/down), the signature flip read. + // wildcat (dir<0): throw the head BACK/up at pop (headSpot NEGATIVE), blind + // through the inverted apex, re-spot the snow LATE (~p 0.7, swings POSITIVE). + // tamedog (dir>0): throw the head DOWN/forward early (headSpot POSITIVE), and + // because she flips toward her gaze she re-fixates the landing cleanly ~2/3. + const p = pitch; + const headLead = DOWNHILL_LOOK; // no yaw here — just hold the downhill gaze + let headSpot: number; + if (dir < 0) { + const throwBack = Math.sin(Math.PI * clamp01(p / 0.7)); + const reSpot = easeInOut(clamp01((p - 0.7) / 0.3)); + headSpot = lerp(-0.55 * throwBack, 0.45, reSpot); + } else { + const throwDown = easeInOut(clamp01(p / 0.44)); + const reFix = easeInOut(clamp01((p - 0.66) / 0.34)); + headSpot = lerp(0.25 + 0.2 * throwDown, 0.45, reFix); + } + // Subtle head-over-shoulder roll toward the flip direction reads as commitment. + let headRoll = dir * 0.14 * Math.sin(Math.PI * clamp01(p)); + + if (settle > 0) { + const s = easeInOut(settle); + headSpot = lerp(headSpot, DOWNHILL_CHIN, s); + headRoll = lerp(headRoll, 0, s); + } + + return { + spin: 0, // FLIP: no yaw — rootYaw stays STANCE_YAW*ease + pitch, + height, + crouch, + coil, + tuck, + balance, + headLead, + headSpot, + headRoll, + backLegLift: 0, + frontLegLift: 0, + boardTilt: 0, + dir, + }; +} + +/** Wildcat = BACKFLIP (over the tail, backward). */ +function wildcatPoseAt(t: number): RiderPose { + return flipPoseAt(t, -1); +} + +/** Tamedog = FRONTFLIP (over the nose, forward). */ +function tamedogPoseAt(t: number): RiderPose { + return flipPoseAt(t, 1); } export const TRICKS: Record = { @@ -96,6 +310,29 @@ export const TRICKS: Record = { totalSpin: Math.PI * 2, poseAt: frontside360PoseAt, }, + 'frontside-360-stylish': { + duration: FS360_SETTLE_END, + totalSpin: Math.PI * 2, + poseAt: frontside360StylishPoseAt, + }, + 'backside-360': { + duration: FS360_SETTLE_END, + // NEGATIVE = clockwise from above (the mirror of frontside's +2π). + totalSpin: -Math.PI * 2, + poseAt: backside360PoseAt, + }, + wildcat: { + duration: FS360_SETTLE_END, + totalSpin: 0, + totalFlip: -Math.PI * 2, // backflip: backward over the tail (verify sign on device) + poseAt: wildcatPoseAt, + }, + tamedog: { + duration: FS360_SETTLE_END, + totalSpin: 0, + totalFlip: Math.PI * 2, // frontflip: forward over the nose + poseAt: tamedogPoseAt, + }, }; // --- Demo state machine --- @@ -108,15 +345,34 @@ export interface TrickDemoState { actionT: number; /** Time spent in stance since the last action — drives the auto-loop. */ gapT: number; + /** Post-activity grace timer. Board presence is NOT tied to the exact + * `session` flag (Kith flips it false mid-reply on a ~500ms audio-drain + * grace during between-sentence pauses); this holds the board up across any + * gap before/between moves and only lets it retract once she's truly done. */ + holdT: number; /** Smoothed on-board weight (drives board fade + stance width + yaw). */ stance: number; idleT: number; /** Outputs for the frame loop + board renderer. */ rootYaw: number; + /** Whole-body flip angle this frame (radians, signed). 0 for spins. */ + rootPitch: number; + /** The composed whole-body quaternion [x,y,z,w] KaoriStage applied this frame + * (yaw*pitch). Written by KaoriStage; read by lockBoardToFeet so the board + * rolls with her through a flip inversion instead of staying world-flat. */ + rootQuat: [number, number, number, number]; rootY: number; /** Board height — follows the jump but NOT the crouch hip-drop. */ boardY: number; boardOpacity: number; + /** Board angle around its long axis (tail up/down) for stylish variants. */ + boardTilt: number; + /** Board world transform locked to the actual feet (computed in KaoriStage + * from the raw foot bones so the bindings stay under the soles and the board + * angle follows the feet). Plain arrays to keep this module three-free. */ + boardPos: [number, number, number]; + boardQuat: [number, number, number, number]; + boardLocked: boolean; } export const createTrickDemoState = (): TrickDemoState => ({ @@ -125,12 +381,19 @@ export const createTrickDemoState = (): TrickDemoState => ({ action: 'none', actionT: 0, gapT: 0, + holdT: 0, stance: 0, idleT: 0, rootYaw: 0, + rootPitch: 0, + rootQuat: [0, 0, 0, 1], rootY: 0, boardY: 0, boardOpacity: 0, + boardTilt: 0, + boardPos: [0, 0, 0], + boardQuat: [0, 0, 0, 1], + boardLocked: false, }); /** True while the demo system should own the body. */ @@ -139,6 +402,10 @@ export const isDemoActive = (state: TrickDemoState) => /** Breather in stance between auto-looped trick runs. */ const LOOP_GAP_SECONDS = 0.9; +/** How long the board stays up after the last activity (session or action) + * before it retracts. Must exceed LOOP_GAP_SECONDS and the TTS drain grace + * (~0.5s) + any between-sentence pause so the board never drops mid-demo. */ +const BOARD_HOLD_SECONDS = 1.2; function actionDuration(state: TrickDemoState): number { switch (state.action) { @@ -175,6 +442,10 @@ export function startAction(state: TrickDemoState, action: Exclude LOOP_GAP_SECONDS) { state.action = 'full'; state.actionT = 0; state.gapT = 0; + // Alternate ONLY within the requested trick's family — frontside cycles + // clean↔stylish so both show; backside just re-runs (no stylish variant + // yet). Never drag a backside session back to frontside. + if (state.trick === 'frontside-360') state.trick = 'frontside-360-stylish'; + else if (state.trick === 'frontside-360-stylish') state.trick = 'frontside-360'; } } - const pose = state.action === 'none' && !state.session ? REST_POSE : actionPose(state); + // action==='none' → actionPose returns the downhill stance-idle, so the + // blend-out after the session ends stays in the riding stance (no REST snap). + const pose = actionPose(state); const stanceEase = easeInOut(clamp01(state.stance)); const effectiveCrouch = pose.crouch * stanceEase; @@ -215,18 +502,32 @@ export function driveDemo(vrm: VRM, state: TrickDemoState, dt: number): boolean applyRiderPose(humanoid, pose, stanceEase); } - state.rootYaw = STANCE_YAW * stanceEase + TRICKS[state.trick].totalSpin * pose.spin; - // Hips sink with the knee fold so the feet stay planted on the board + const timeline = TRICKS[state.trick]; + state.rootYaw = STANCE_YAW * stanceEase + timeline.totalSpin * pose.spin; + // Flip pitch: signed totalFlip carried by pose.pitch, gated by stanceEase so a + // partial strap-in never half-flips her. 0 for all spins (totalFlip omitted), + // and spins keep pitch=0 / flips keep spin=0, so the two channels never fight. + state.rootPitch = (timeline.totalFlip ?? 0) * pose.pitch * stanceEase; + // Hips sink with the knee fold so the feet stay planted on the board (for a + // flip this is the CoM/hip height on the jump arc that KaoriStage pivots around). state.rootY = pose.height - hipDropFor(effectiveCrouch); state.boardY = pose.height; state.boardOpacity = stanceEase; + state.boardTilt = pose.boardTilt * stanceEase; - if (!state.session && state.stance < 0.005 && state.action === 'none') { + if ( + !state.session && + state.action === 'none' && + state.holdT >= BOARD_HOLD_SECONDS && + state.stance < 0.005 + ) { state.stance = 0; state.rootYaw = 0; + state.rootPitch = 0; // no leftover flip tilt into idle state.rootY = 0; state.boardY = 0; state.boardOpacity = 0; + state.boardTilt = 0; if (humanoid) resetRiderBones(humanoid); return false; } diff --git a/src/hooks/useKithVoice.ts b/src/hooks/useKithVoice.ts index 2a73025..c436ab4 100644 --- a/src/hooks/useKithVoice.ts +++ b/src/hooks/useKithVoice.ts @@ -119,6 +119,16 @@ export function useKithVoice(callbacks?: KithVoiceCallbacks) { playerRef.current?.interrupt(); sessionRef.current?.bargeIn(); }, + /** + * Immediately silence Kaori — stop playback AND tell the backend to stop + * generating. Call this when the stage loses focus (navigating away), since + * expo-router keeps the screen mounted, so the unmount cleanup won't fire. + */ + stop: () => { + playerRef.current?.interrupt(); + sessionRef.current?.bargeIn(); + voiceState.current.mode = 'idle'; + }, /** Restore the playback audio session after the mic releases it. */ reassertPlayback: () => playerRef.current?.reassertSession(), setMode: (mode: CompanionVoiceState['mode']) => {