Task #22: Slap/ragdoll physics on Timmy

## What was done
- agents.js: spring physics (stiffness=7, damping=0.8, clamp ±0.44 rad) on
  timmy.group.rotation.x/z via slapOffset/slapVelocity integrated per-frame with
  proper dt (capped at 50ms for tab-background safety)
- agents.js: applySlap(hitPoint) — computes XZ impulse direction from hit point
  relative to TIMMY_POS, adds angular velocity, triggers pip startle + crystal flash
- agents.js: _playBoing() — lazy AudioContext, sine oscillator 260→90 Hz with
  exponential gain decay (0.38s)
- agents.js: Pip startle — 3s decay timer, random scatter direction offset, 4x
  spin speed while startled, boosted pip light intensity
- agents.js: Crystal ball hit flash — hitFlashTimer=0.5s, intensity spikes to 10
  and fades; normal crystalLight/cbMat logic when not flashing
- agents.js: getTimmyGroup() export for raycaster target
- interaction.js: registerSlapTarget(group, applyFn) — stores targets
- interaction.js: _onPointerDown capture-phase listener — raycasts against
  timmyGroup recursively, calls applySlap on hit, suppresses OrbitControls drag
  for 220ms via stopImmediatePropagation + controls.enabled toggle
- main.js: imports getTimmyGroup, applySlap, registerSlapTarget; wires
  registerSlapTarget(getTimmyGroup(), applySlap) after initInteraction

## Verification
- Vite build: clean, 14 modules, 0 errors
- /tower HTTP 200
- Testkit: 27/27 PASS
This commit is contained in:
alexpaynex
2026-03-19 03:31:01 +00:00
parent 2956cc07b2
commit 35babd2400
3 changed files with 154 additions and 9 deletions

View File

@@ -14,6 +14,14 @@ function deriveTimmyState() {
let scene = null;
let timmy = null;
let _audioCtx = null;
let _lastFrameTime = 0;
// ── Spring physics constants ───────────────────────────────────────────────────
const SPRING_STIFFNESS = 7.0;
const SPRING_DAMPING = 0.80;
const MAX_TILT_RAD = 0.44; // ~25°
const SLAP_IMPULSE = 0.28;
// ── Face emotion targets per internal state ───────────────────────────────────
// lidScale: 0 = fully closed, 1 = wide open
@@ -363,6 +371,14 @@ function buildTimmy(sc) {
faceParams: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 },
faceTarget: { lidScale: 0.44, pupilScale: 0.90, smileAmount: 0.08 },
_overrideMood: null,
// Spring physics (slap ragdoll)
slapOffset: { x: 0, z: 0 },
slapVelocity: { x: 0, z: 0 },
// Pip startle
pipStartleTimer: 0,
pipStartleDir: { x: 0, z: 0 },
// Crystal ball hit flash
hitFlashTimer: 0,
};
}
@@ -371,6 +387,9 @@ function buildTimmy(sc) {
export function updateAgents(time) {
if (!timmy) return;
const t = time * 0.001;
const dt = _lastFrameTime > 0 ? Math.min((time - _lastFrameTime) * 0.001, 0.05) : 0.016;
_lastFrameTime = time;
const vs = deriveTimmyState();
const pulse = Math.sin(t * 1.8 + timmy.pulsePhase);
const pulse2 = Math.sin(t * 3.5 + timmy.pulsePhase * 1.5);
@@ -409,8 +428,24 @@ export function updateAgents(time) {
timmy.group.position.y = bodyBob;
timmy.head.rotation.z = headTilt;
timmy.crystalLight.intensity = crystalI;
timmy.cbMat.emissiveIntensity = 0.28 + (crystalI / 6) * 0.72;
// ── Spring ragdoll integration ────────────────────────────────────────────
const so = timmy.slapOffset, sv = timmy.slapVelocity;
sv.x += (-SPRING_STIFFNESS * so.x - SPRING_DAMPING * sv.x) * dt;
sv.z += (-SPRING_STIFFNESS * so.z - SPRING_DAMPING * sv.z) * dt;
so.x = Math.max(-MAX_TILT_RAD, Math.min(MAX_TILT_RAD, so.x + sv.x * dt));
so.z = Math.max(-MAX_TILT_RAD, Math.min(MAX_TILT_RAD, so.z + sv.z * dt));
timmy.group.rotation.x = so.x;
timmy.group.rotation.z = so.z;
// ── Crystal ball flash on slap hit ───────────────────────────────────────
if (timmy.hitFlashTimer > 0) {
timmy.hitFlashTimer = Math.max(0, timmy.hitFlashTimer - dt);
timmy.crystalLight.intensity = 10.0 * (timmy.hitFlashTimer / 0.5);
timmy.cbMat.emissiveIntensity = 0.9 * (timmy.hitFlashTimer / 0.5);
} else {
timmy.crystalLight.intensity = crystalI;
timmy.cbMat.emissiveIntensity = 0.28 + (crystalI / 6) * 0.72;
}
timmy.crystalGroup.rotation.y += 0.004 * cbPulseSpeed;
const cbScale = 1 + Math.sin(t * cbPulseSpeed) * 0.022;
timmy.cb.scale.setScalar(cbScale);
@@ -424,13 +459,18 @@ export function updateAgents(time) {
timmy.magicLight.intensity = 1.0 + Math.sin(t * 4.2) * 0.5 + (vs === 'working' ? 0.8 : 0.0);
timmy.magicOrb.position.y = 1.55 + Math.sin(t * 2.8) * 0.04;
const pipX = Math.sin(t * 0.38 + 1.4) * 3.2;
const pipZ = Math.sin(t * 0.65 + 0.8) * 2.2 - 1.8;
const pipY = 1.55 + Math.sin(t * 1.6) * 0.32;
// ── Pip familiar — startle reaction on slap ───────────────────────────────
if (timmy.pipStartleTimer > 0) timmy.pipStartleTimer = Math.max(0, timmy.pipStartleTimer - dt);
const startled = timmy.pipStartleTimer > 0;
const startleRatio = startled ? (timmy.pipStartleTimer / 3.0) : 0;
const pipSpeedMult = startled ? (1 + 3 * startleRatio) : 1;
const pipX = Math.sin(t * 0.38 * pipSpeedMult + 1.4) * 3.2 + timmy.pipStartleDir.x * startleRatio;
const pipZ = Math.sin(t * 0.65 * pipSpeedMult + 0.8) * 2.2 - 1.8 + timmy.pipStartleDir.z * startleRatio;
const pipY = 1.55 + Math.sin(t * 1.6 * (startled ? 3.5 : 1.0)) * (startled ? 0.72 : 0.32);
timmy.pip.position.set(pipX, pipY, pipZ);
timmy.pip.rotation.x += 0.022;
timmy.pip.rotation.y += 0.031;
timmy.pipLight.intensity = 0.55 + Math.sin(t * 2.1) * 0.2;
timmy.pip.rotation.x += 0.022 * (startled ? 4 : 1);
timmy.pip.rotation.y += 0.031 * (startled ? 4 : 1);
timmy.pipLight.intensity = 0.55 + Math.sin(t * 2.1) * 0.2 + (startled ? 0.6 * startleRatio : 0);
// ── Speech bubble fade ───────────────────────────────────────────────────
if (timmy.speechTimer > 0) {
@@ -494,6 +534,61 @@ export function setFaceEmotion(mood) {
timmy._overrideMood = MOOD_ALIASES[mood] ?? 'idle';
}
// ── applySlap — called by interaction.js on hit ───────────────────────────────
export function applySlap(hitPoint) {
if (!timmy) return;
// XZ direction from Timmy origin to hit point — tilt group away from impact
const dx = hitPoint.x - TIMMY_POS.x;
const dz = hitPoint.z - TIMMY_POS.z;
const len = Math.sqrt(dx * dx + dz * dz) || 1;
const nx = dx / len, nz = dz / len;
// Add angular impulse: Z impact → X rotation, X impact → Z rotation
timmy.slapVelocity.x += nz * SLAP_IMPULSE;
timmy.slapVelocity.z -= nx * SLAP_IMPULSE;
// Pip startle: scatter in a random direction
timmy.pipStartleTimer = 3.0;
timmy.pipStartleDir.x = (Math.random() - 0.5) * 5.0;
timmy.pipStartleDir.z = (Math.random() - 0.5) * 5.0;
// Crystal ball flash
timmy.hitFlashTimer = 0.5;
// Synthesised boing SFX
_playBoing();
}
function _playBoing() {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume();
const osc = _audioCtx.createOscillator();
const gain = _audioCtx.createGain();
osc.connect(gain);
gain.connect(_audioCtx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(260, _audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(90, _audioCtx.currentTime + 0.32);
gain.gain.setValueAtTime(0.55, _audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, _audioCtx.currentTime + 0.38);
osc.start(_audioCtx.currentTime);
osc.stop(_audioCtx.currentTime + 0.40);
} catch (_) {
// Autoplay policy or audio not supported — silently skip
}
}
// ── getTimmyGroup — used by interaction.js for raycasting ────────────────────
export function getTimmyGroup() {
return timmy ? timmy.group : null;
}
export function setAgentState(agentId, state) {
if (agentId in agentStates) agentStates[agentId] = state;
}

View File

@@ -1,10 +1,28 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
let controls;
let _canvas;
let _camera = null;
let _timmyGroup = null;
let _applySlap = null;
const _raycaster = new THREE.Raycaster();
const _pointer = new THREE.Vector2();
const _noCtxMenu = e => e.preventDefault();
// ── registerSlapTarget ────────────────────────────────────────────────────────
// Call after initAgents() with Timmy's group and the applySlap function.
// The capture-phase pointerdown listener will hit-test against the group and
// call applySlap(hitPoint) — also suppressing the OrbitControls drag.
export function registerSlapTarget(timmyGroup, applyFn) {
_timmyGroup = timmyGroup;
_applySlap = applyFn;
}
export function initInteraction(camera, renderer) {
_camera = camera;
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
@@ -17,6 +35,32 @@ export function initInteraction(camera, renderer) {
_canvas = renderer.domElement;
_canvas.addEventListener('contextmenu', _noCtxMenu);
// Capture phase so we intercept before OrbitControls' bubble-phase handler.
// If Timmy is hit we call stopImmediatePropagation() to suppress the orbit drag.
_canvas.addEventListener('pointerdown', _onPointerDown, { capture: true });
}
function _onPointerDown(event) {
if (!_timmyGroup || !_applySlap || !_camera) return;
const rect = _canvas.getBoundingClientRect();
_pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
_pointer.y = ((event.clientY - rect.top) / rect.height) * -2 + 1;
_raycaster.setFromCamera(_pointer, _camera);
const hits = _raycaster.intersectObject(_timmyGroup, true);
if (hits.length > 0) {
event.stopImmediatePropagation(); // block OrbitControls drag
_applySlap(hits[0].point);
// Re-enable orbit after a short pause to avoid accidental rotation
if (controls) {
controls.enabled = false;
setTimeout(() => { if (controls) controls.enabled = true; }, 220);
}
}
}
export function updateControls() {
@@ -30,10 +74,14 @@ export function updateControls() {
export function disposeInteraction() {
if (_canvas) {
_canvas.removeEventListener('contextmenu', _noCtxMenu);
_canvas.removeEventListener('pointerdown', _onPointerDown, { capture: true });
_canvas = null;
}
if (controls) {
controls.dispose();
controls = null;
}
_camera = null;
_timmyGroup = null;
_applySlap = null;
}

View File

@@ -2,10 +2,11 @@ import { initWorld, onWindowResize, disposeWorld } from './world.js';
import {
initAgents, updateAgents, getAgentCount,
disposeAgents, getAgentStates, applyAgentStates,
getTimmyGroup, applySlap,
} from './agents.js';
import { initEffects, updateEffects, disposeEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
import { initInteraction, disposeInteraction } from './interaction.js';
import { initInteraction, disposeInteraction, registerSlapTarget } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
import { initPaymentPanel } from './payment.js';
@@ -22,6 +23,7 @@ function buildWorld(firstInit, stateSnapshot) {
if (stateSnapshot) applyAgentStates(stateSnapshot);
initInteraction(camera, renderer);
registerSlapTarget(getTimmyGroup(), applySlap);
if (firstInit) {
initUI();