From abe6431da35c74993af44258700c0377ccce8803 Mon Sep 17 00:00:00 2001 From: Wayland Date: Sun, 21 Jun 2026 17:23:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(animation):=20full-body=20attack=20pose=20?= =?UTF-8?q?=E2=80=94=20two=20visual=20player=20states=20(FLO-474)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board asked for a player who looks clearly different while attacking (ideally a raised-arms second model). The shipped hero is a single rig-less GLB and no raised-arms variant exists, so a literal two-GLB swap isn't possible today. Instead the attack now reads as a committed full-body chop driven on the visual root via the existing procedural animator: windup winds back and rises, the active frame pitches sharply forward (+0.62 rad, well beyond the <=0.12 rad movement lean) and drops the weight, recovery settles. Result: two unmistakably-distinct silhouettes (normal idle/move <-> attack strike) tied to the melee phase, at zero asset cost — applies to both the forest and human-lands scenes since both feed attackPhase through the controller. A literal raised-arms second model remains a future asset ticket. - proceduralAnimator: attack pitch added to leanX, rise/dip to bobY, by phase - 7 new Vitest cases proving the two distinct states - docs/guide/procedural-animation.md: updated states table + rationale Co-Authored-By: Wayland Co-Authored-By: Claude Opus 4.8 --- docs/guide/procedural-animation.md | 34 ++++-- src/game/animation/proceduralAnimator.test.ts | 102 ++++++++++++++++++ src/game/animation/proceduralAnimator.ts | 38 ++++++- 3 files changed, 165 insertions(+), 9 deletions(-) diff --git a/docs/guide/procedural-animation.md b/docs/guide/procedural-animation.md index 0498b42..1e682a5 100644 --- a/docs/guide/procedural-animation.md +++ b/docs/guide/procedural-animation.md @@ -38,14 +38,32 @@ Engine-agnostic. Two exports: ### Animations -| State | Bob Hz | Bob amp | Lean | Lunge/Topple | -|------------|--------|---------|------|--------------| -| Idle | 1.1 | 2.5 cm | — | — | -| Moving | 2.4 | 5.5 cm | up to 0.12 rad | — | -| Windup | — | — | — | −5 cm pullback | -| Active | — | — | — | +18 cm lunge | -| Recovery | — | — | — | +7 cm partial | -| Dead | — | — | — | topple 0→π/2 (ease-out, ~0.5 s) | +| State | Bob Hz | Bob amp | Lean (pitch) | Lunge/Topple | +|------------|--------|---------|--------------|--------------| +| Idle | 1.1 | 2.5 cm | — | — | +| Moving | 2.4 | 5.5 cm | up to +0.12 rad | — | +| Windup | — | +7 cm rise | −0.28 rad (wind back) | −5 cm pullback | +| Active | — | −5 cm dip | +0.62 rad (forward chop) | +18 cm lunge | +| Recovery | — | — | +0.20 rad (settling) | +7 cm partial | +| Dead | — | — | — | topple 0→π/2 (ease-out, ~0.5 s) | + +#### Two visual states: normal vs. attack (FLO-474) + +The board asked for a player who looks **clearly different while attacking** — +ideally a separate raised-arms model swapped in on the swing. The shipped hero is +a single **rig-less** GLB (`korovany_hero_player-default.glb`) and there is no +raised-arms variant in `public/models`, so a literal two-GLB swap isn't possible +today. Instead the attack reads as a committed **full-body chop** driven on the +visual root: the windup winds back and rises, the active frame pitches sharply +forward (+0.62 rad — well beyond the ≤0.12 rad movement lean) and drops the +weight, and recovery settles back. The result is two unmistakably-distinct +silhouettes (**normal** idle/move ↔ **attack** strike) tied to the melee phase, +at zero asset cost. A literal raised-arms *second model* (pose-baked or rigged +GLB) remains a future asset ticket through Iris → Pygmalion (see +`docs/guide/assets.md`); it would drop in behind the same `attackPhase` contract. + +Attack pitch is **added** to the base lean and the rise/dip to the bob, so the +strike reads even while advancing (chop-on-the-move). ### Death topple and slow-mo diff --git a/src/game/animation/proceduralAnimator.test.ts b/src/game/animation/proceduralAnimator.test.ts index 1fe7c1a..a5c138d 100644 --- a/src/game/animation/proceduralAnimator.test.ts +++ b/src/game/animation/proceduralAnimator.test.ts @@ -178,6 +178,108 @@ describe('stepAnimator — attack lunge', () => { }) }) +describe('stepAnimator — attack pose / two-state read (FLO-474)', () => { + // The board asked for two clearly-distinct visual states: normal and attack. + // With the rig-less hero GLB the strike is a committed full-body chop driven on + // the visual root (pitch via leanX, rise/dip via bobY), layered on the lunge. + const idle = () => + stepAnimator(createAnimatorState(), { + dt: 0.016, + speed: 0, + attackPhase: 'idle', + isDead: false, + }).output + + it('idle stands neutral — no attack pitch, no lunge', () => { + const out = idle() + expect(out.leanX).toBe(0) + expect(out.lungeZ).toBe(0) + }) + + it('windup winds back: leans away (negative pitch) and rises', () => { + const { output } = stepAnimator(createAnimatorState(), { + dt: 0, // freeze the bob so the test reads the pure attack rise + speed: 0, + attackPhase: 'windup', + isDead: false, + }) + expect(output.leanX).toBeLessThan(0) // lean back to telegraph + expect(output.bobY).toBeGreaterThan(0) // come up before the blow + }) + + it('active strike pitches sharply forward — a silhouette clearly unlike idle', () => { + const { output } = stepAnimator(createAnimatorState(), { + dt: 0, + speed: 0, + attackPhase: 'active', + isDead: false, + }) + // A committed forward chop: strongly positive pitch, far beyond the move lean cap. + expect(output.leanX).toBeGreaterThan(0.5) + expect(output.bobY).toBeLessThan(0) // drop weight into the strike + expect(output.lungeZ).toBeGreaterThan(0) + }) + + it('windup and active pitch in opposite directions (wind back, then chop)', () => { + const wind = stepAnimator(createAnimatorState(), { + dt: 0, + speed: 0, + attackPhase: 'windup', + isDead: false, + }).output + const strike = stepAnimator(createAnimatorState(), { + dt: 0, + speed: 0, + attackPhase: 'active', + isDead: false, + }).output + expect(Math.sign(wind.leanX)).toBe(-Math.sign(strike.leanX)) + }) + + it('recovery settles toward neutral — less forward than the strike', () => { + const strike = stepAnimator(createAnimatorState(), { + dt: 0, + speed: 0, + attackPhase: 'active', + isDead: false, + }).output + const recover = stepAnimator(createAnimatorState(), { + dt: 0, + speed: 0, + attackPhase: 'recovery', + isDead: false, + }).output + expect(recover.leanX).toBeGreaterThan(0) + expect(recover.leanX).toBeLessThan(strike.leanX) + }) + + it('attack pitch reads on top of a movement lean (chop while advancing)', () => { + const moving = stepAnimator(createAnimatorState(), { + dt: 0.016, + speed: 4, + attackPhase: 'idle', + isDead: false, + }).output + const movingStrike = stepAnimator(createAnimatorState(), { + dt: 0.016, + speed: 4, + attackPhase: 'active', + isDead: false, + }).output + // Striking mid-advance pitches further forward than plain movement lean. + expect(movingStrike.leanX).toBeGreaterThan(moving.leanX) + }) + + it('CharacterAnimator drives the node into the strike pose', () => { + const animator = new CharacterAnimator(-0.9, 0) + const node: AnimatableNode = { position: { y: -0.9, z: 0 }, rotation: { x: 0, z: 0 } } + animator.node = node + animator.update({ dt: 0, speed: 0, attackPhase: 'active', isDead: false }) + expect(node.rotation.x).toBeGreaterThan(0.5) // forward chop applied to the visual root + expect(node.position.z).toBeGreaterThan(0) // forward lunge applied + }) +}) + describe('stepAnimator — death topple', () => { it('produces no topple when alive', () => { const state = createAnimatorState() diff --git a/src/game/animation/proceduralAnimator.ts b/src/game/animation/proceduralAnimator.ts index 66093d2..1749831 100644 --- a/src/game/animation/proceduralAnimator.ts +++ b/src/game/animation/proceduralAnimator.ts @@ -80,6 +80,23 @@ const WHEELCHAIR_BOB_SCALE = 0.5 /** Attack lunge: max forward displacement at active window midpoint. */ const LUNGE_MAX = 0.18 // metres + +/** + * Attack pose (FLO-474): a committed full-body chop layered on top of the lunge, + * giving two clearly-distinct visual states — **normal** (idle/move) and + * **attack**. With the rig-less hero GLB the only joint we can drive is the + * visual root, so the "raised-arms strike" reads as a whole-body motion: wind + * back and rise on the windup, then pitch sharply forward and drop the weight on + * the active strike. A literal *second model* (a raised-arms pose-baked or rigged + * GLB) is a separate asset ticket; this gives the requested two-state read with + * zero asset cost. Pitches are added to `leanX`, rises/dips to `bobY`. + */ +const ATTACK_WINDUP_PITCH = 0.28 // rad — lean back to telegraph the swing +const ATTACK_WINDUP_RISE = 0.07 // m — come up before the blow +const ATTACK_STRIKE_PITCH = 0.62 // rad — sharp forward chop (distinct silhouette) +const ATTACK_STRIKE_DIP = 0.05 // m — drop weight into the strike +const ATTACK_RECOVER_PITCH = 0.2 // rad — partial forward lean settles back + /** Speed at which topple completes (fraction of π/2 per second). */ const TOPPLE_RATE = 3.0 // reaches π/2 in ~0.52 s @@ -138,7 +155,7 @@ export function stepAnimator( bobAmp *= WHEELCHAIR_BOB_SCALE bobFreq *= 0.85 } - const bobY = bobAmp * Math.sin(2 * Math.PI * bobFreq * time) + let bobY = bobAmp * Math.sin(2 * Math.PI * bobFreq * time) // ── Lean / pose offset ─────────────────────────────────────────────────── let leanX = isMoving ? MOVE_LEAN_MAX * Math.min(speed / 4, 1) : 0 @@ -151,6 +168,25 @@ export function stepAnimator( offsetY = WHEELCHAIR_OFFSET_Y } + // ── Attack pose (FLO-474) ────────────────────────────────────────────────── + // The two-state read: normal pose vs. a committed strike. Wind back + rise on + // the windup, chop forward + dip on the active frame, settle on recovery. The + // pitch is added to the base lean (and the rise to the bob) so the attack pose + // is unmistakably different from the idle/move silhouette while standing still. + let attackPitch = 0 + let attackRise = 0 + if (attackPhase === 'windup') { + attackPitch = -ATTACK_WINDUP_PITCH + attackRise = ATTACK_WINDUP_RISE + } else if (attackPhase === 'active') { + attackPitch = ATTACK_STRIKE_PITCH + attackRise = -ATTACK_STRIKE_DIP + } else if (attackPhase === 'recovery') { + attackPitch = ATTACK_RECOVER_PITCH + } + leanX += attackPitch + bobY += attackRise + // ── Lunge ──────────────────────────────────────────────────────────────── // Windup: small pullback; active: full lunge; recovery: fade out let lungeZ = 0