Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions docs/guide/procedural-animation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
102 changes: 102 additions & 0 deletions src/game/animation/proceduralAnimator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
38 changes: 37 additions & 1 deletion src/game/animation/proceduralAnimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading