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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user