# Handoff Report: Milestone 2 — Character Animation & Visceral Combat Feel Engine

**Date**: 2026-10-01T02:45:00Z  
**Author**: `explorer_m2_1_gen3`  
**Recipient**: `parent` (ID: `77cd448f-be37-473e-809c-59db9f78e386`) / Orchestrator  
**Milestone**: Milestone 2 (Character Animation & Visceral Combat Feel Engine)  
**Primary Focus**: 5-Phase Kinetic State Machine (`animation_engine.js`) & 4 Weapon Archetypes (`weapon_swing_catalog.js`)  

---

## 1. Observation

### 1.1 Existing Codebase & Line Counts
Direct inspection of files via `view_file` and line counting:
- `client/webapp/js/engine/animation_engine.js`: 311 lines (Soft Cap: 350, Hard Cap: 500).
- `client/webapp/js/data/weapon_swing_catalog.js`: 113 lines (Soft Cap: 700, Hard Cap: 1000).
- `client/webapp/js/engine/combat_feel_engine.js`: 198 lines (Soft Cap: 350, Hard Cap: 500).
- `client/webapp/js/engine/weapon_swing_renderer.js`: 166 lines (Soft Cap: 350, Hard Cap: 500).
- `client/webapp/js/engine/entity_renderer.js`: 427 lines (Soft Cap: 350, Hard Cap: 500).
- `client/webapp/js/engine/canvas_renderer.js`: 297 lines (Soft Cap: 350, Hard Cap: 500).
- `client/webapp/js/engine/combat_skills.js`: 472 lines (Soft Cap: 350, Hard Cap: 500 — **CRITICAL**: only 28 lines remaining before 500 Hard Cap!).

### 1.2 Inspection of `animation_engine.js`
In `client/webapp/js/engine/animation_engine.js`:
- Lines 18-25 define animations for `HERO_TRACKS`:
  ```javascript
  animations: {
    idle: { frames: ['idle_0', 'idle_1', 'idle_2', 'idle_3'], fps: 6, loop: true },
    run: { frames: ['run_0', 'run_1', 'run_2', 'run_3', 'run_4', 'run_5', 'run_6', 'run_7'], fps: 12, loop: true, footsteps: [0, 4] },
    attack_slash: { frames: ['attack_slash_0', 'attack_slash_1', 'attack_slash_2', 'attack_slash_3', 'attack_slash_4', 'attack_slash_5'], fps: 15, loop: false, hit_frame: 2 },
    skill_whirlwind: { frames: ['skill_whirlwind_0', 'skill_whirlwind_1', 'skill_whirlwind_2', 'skill_whirlwind_3', 'skill_whirlwind_4', 'skill_whirlwind_5'], fps: 18, loop: true },
    dodge: { frames: ['dodge_0', 'dodge_1', 'dodge_2', 'dodge_3', 'dodge_4', 'dodge_5'], fps: 20, loop: false },
    hurt: { frames: ['hurt_0', 'hurt_1', 'hurt_2'], fps: 12, loop: false }
  }
  ```
- Lines 85-104 define `createAnimationState`:
  ```javascript
  function createAnimationState(manifestKey = 'hero') {
    return {
      manifestKey,
      currentClip: 'idle',
      frameIndex: 0,
      frameTime: 0,
      playbackRate: 1.0,
      actionSpeed: 1.0,
      isActionLocked: false,
      hitTriggered: false,
      kineticPhase: 'idle',
      hitStopTimer: 0,
      isHitStopped: false,
      smoothAngle: 0,
      targetAngle: 0,
      heading8Dir: 'S',
      onHitCallback: null,
      onCompleteCallback: null
    };
  }
  ```
- Lines 149-158 in `updateAnimation`:
  ```javascript
  if (animState.hitStopTimer > 0) {
    animState.hitStopTimer -= dt;
    animState.isHitStopped = true;
    animState.kineticPhase = 'hit_stop';
    if (animState.hitStopTimer <= 0) {
      animState.hitStopTimer = 0;
      animState.isHitStopped = false;
    }
    return;
  }
  animState.isHitStopped = false;
  ```
- Observation: Outside of `animState.hitStopTimer > 0`, `animState.kineticPhase` is NEVER updated! It remains `'idle'` even when running, winding up, striking, or recovering.
- Observation: `HERO_TRACKS` only has `attack_slash`; it lacks explicit weapon archetype attack tracks for `attack_thrust`, `attack_slam`, and `attack_bolt`.
- Observation: `hero_anim_atlas.png` has dimensions $1280 \times 960$ (8 cols $\times$ 5 rows = 40 cells of $160 \times 192$). Currently 33 cells are mapped (idle: 4, run: 8, attack_slash: 6, whirlwind: 6, dodge: 6, hurt: 3).

### 1.3 Inspection of `weapon_swing_catalog.js`
In `client/webapp/js/data/weapon_swing_catalog.js`:
- Lines 10-99 define `WEAPON_SWING_ARCHETYPES` with 4 archetypes:
  1. `SLASH`: id `'SLASH'`, style `'arc'`, windUpTime `0.12`, activeTime `0.08`, recoveryTime `0.15`, hitStopFrames `3` (`0.05s`), sweepAngleDeg `135`, reachRadius `56`, color `'#ef4444'`.
  2. `THRUST`: id `'THRUST'`, style `'cone'`, windUpTime `0.09`, activeTime `0.06`, recoveryTime `0.14`, hitStopFrames `2` (`0.035s`), sweepAngleDeg `35`, reachRadius `84`, color `'#38bdf8'`.
  3. `SLAM`: id `'SLAM'`, style `'radial'`, windUpTime `0.20`, activeTime `0.10`, recoveryTime `0.24`, hitStopFrames `4` (`0.066s`), sweepAngleDeg `360`, reachRadius `76`, color `'#f59e0b'`.
  4. `BOLT`: id `'BOLT'`, style `'beam'`, windUpTime `0.10`, activeTime `0.05`, recoveryTime `0.16`, hitStopFrames `2` (`0.035s`), sweepAngleDeg `20`, reachRadius `180`, color `'#c084fc'`.
- Lines 101-112: `getWeaponSwingArchetype(archetypeId)` exported and exposed on `window.WeaponSwingCatalog`.

### 1.4 Inspection of `weapon_swing_renderer.js` and `combat_feel_engine.js`
- `weapon_swing_renderer.js`: implements `WeaponSwingEffect` with dynamic visual rendering for `'arc'` (crescent blade), `'cone'` (piercing needle), `'radial'` (ground shockwave), and `'beam'` (sky lightning bolt). Also implements `WeaponSwingRenderer` managing a 16-effect pool with `triggerSwing`, `update(dt)`, `render(ctx)`. Exposed as `window.weaponSwingRenderer`.
- `combat_feel_engine.js`: implements `CombatFeelEngine` with `triggerHitStop(frames, durationSec)`, `isHitStopped(dt)`, `spawnDamageNumber(text, x, y, color, isCrit)` backed by a static 64-slot Ring Buffer `DamageNumberSlot`, and `triggerScreenShake(intensity, durationSec)`. Exposed as `window.combatFeelEngine`.
- Observation: Neither `weapon_swing_renderer.js` nor `combat_feel_engine.js` is currently invoked inside `canvas_renderer.js` or `combat_skills.js`!

### 1.5 Inspection of `combat_skills.js`
In `client/webapp/js/engine/combat_skills.js`:
- Line 278: `var dodgeCharges = 3; window.dodgeCharges = dodgeCharges;`
- Observation: `dodgeCharges` is never decremented when `doDodge()` is called, and there is no 3.0s recharge loop.
- Observation: `doDodge()` does not explicitly cancel ongoing skill attacks or reset `player.attackTimer = 0`.
- Observation: `doFire()`, `doThunder()`, `doFrost()`, and `doPrimaryAttack()` all hardcode `AnimationEngine.playAction(player.anim, 'attack_slash')` and do not call `weaponSwingRenderer.triggerSwing()`.
- Observation: `hitMonster()` (lines 127-213) does not call `combatFeelEngine.triggerHitStop()`.

### 1.6 Baseline Test Execution
Executed `pytest -v tests/e2e/test_poe2_ui_animation_vfx_e2e.py`:
- Result: 33 passed, 9 xpassed in 0.38s.
- Relevant tests:
  - `test_f06_kinetic_animation_clips`: PASSED
  - `test_f06_five_phase_machine_baseline`: XPASS (due to `@pytest.mark.xfail(strict=False)`)
  - `test_f07_hit_stop_engine_baseline`: XPASS (due to `@pytest.mark.xfail(strict=False)`)
  - `test_f08_weapon_swing_catalog_baseline`: XPASS (due to `@pytest.mark.xfail(strict=False)`)
  - `test_f09_dodge_charges_budget_baseline`: XPASS (due to `@pytest.mark.xfail(strict=False)`)
  - `test_cross_dodge_cancels_skill_windup`: PASSED
  - `test_cross_hit_stop_during_screen_shake`: PASSED
- Executed `pytest tests/unit/test_martial_character_ecosystem.py`: 5 passed in 0.14s.
- Executed `python tools/lint/check_code_and_doc_hygiene.py --strict`: Passed with 0 Hard Cap violations.

---

## 2. Logic Chain

1. **State Machine Completeness**:
   - The test `test_f06_five_phase_machine_baseline` expects the 5 kinetic phases: Idle (6fps), Velocity-matched Run, Wind-up (anticipation), Impact (hit active), Recovery (follow-through).
   - Currently, `animation_engine.js` sets `animState.kineticPhase = 'hit_stop'` only when hit-stopped, and never updates it during normal animation playback.
   - Therefore, `updateAnimation` must continuously determine `kineticPhase`:
     - If `animState.hitStopTimer > 0`: phase is `hit_stop`.
     - Else if current clip has `hit_frame !== undefined`:
       - If `frameIndex < hit_frame`: phase is `wind_up` (anticipation).
       - If `frameIndex === hit_frame`: phase is `impact` (active strike window).
       - If `frameIndex > hit_frame`: phase is `recovery` (follow-through).
     - Else if `currentClip === 'run'` (or moving with `velocity > 0.05`): phase is `run`.
     - Else if `currentClip === 'dodge'`: phase is `dodge`.
     - Else: phase is `idle`.
   - Adding a canonical `KINETIC_PHASES` object provides an unambiguous contract for UI, AI, and test suites.

2. **Weapon Archetype Integration**:
   - `weapon_swing_catalog.js` already provides complete data for `SLASH`, `THRUST`, `SLAM`, `BOLT`.
   - `weapon_swing_renderer.js` already provides the Canvas 2D renderers for `arc`, `cone`, `radial`, `beam`.
   - However, they are detached from gameplay: `combat_skills.js` calls `attack_slash` for all skills, and never calls `weaponSwingRenderer.triggerSwing()`.
   - In `animation_engine.js`, adding clips for `attack_thrust`, `attack_slam`, and `attack_bolt` in `HERO_TRACKS` allows skills to trigger appropriate character postures. By using `atlas_clip: 'attack_slash'` or sharing grid coordinates, the engine can draw these clips without requiring modifications to the 40-cell sprite atlas.
   - In `combat_skills.js`:
     - Primary Attack / Fire (`Infernal Slash`): uses `SLASH` archetype and triggers `weaponSwingRenderer.triggerSwing('SLASH', ...)`.
     - Frost (`Glacial Spikes / Lance`): uses `THRUST` archetype and triggers `weaponSwingRenderer.triggerSwing('THRUST', ...)`.
     - Thunder (`Lightning Strike`): uses `BOLT` archetype and triggers `weaponSwingRenderer.triggerSwing('BOLT', ...)`.
     - Boss Slam / Heavy Earth: uses `SLAM` archetype and triggers `weaponSwingRenderer.triggerSwing('SLAM', ...)`.

3. **Hit-Stop & Combat Feel Synchronization**:
   - `CombatFeelEngine` specifies `triggerHitStop(frames, durationSec)` with 2–4 frames (33–66ms).
   - In `combat_skills.js` `hitMonster()`, when an attack connects, it should query the archetype's `hitStopFrames` and `hitStopDuration` and invoke `window.triggerHitStop(archetype.hitStopFrames, archetype.hitStopDuration)`.
   - If `player.anim` exists, `player.anim.hitStopTimer = archetype.hitStopDuration;` ensures the hero also micro-freezes on impact.
   - Floating damage numbers should invoke `window.spawnDamageNumber(...)` to utilize the pre-allocated 64-slot Ring Buffer.
   - In `canvas_renderer.js`, calling `combatFeelEngine.update(dt)` / `weaponSwingRenderer.update(dt)` in the update tick and `combatFeelEngine.renderDamageNumbers(ctx)` / `weaponSwingRenderer.render(ctx)` in the render tick seamlessly brings both systems to life.

4. **Dodge Roll Evasion & Animation Cancelling**:
   - In `combat_skills.js`, `dodgeCharges` must decrement when dodging (`dodgeCharges--`) and only allow dodging when `dodgeCharges > 0`.
   - A recharge interval (every 3.0s, restore 1 charge up to 3) satisfies the 3-charge pool requirement.
   - When dodging while `player.animState === 'attack'` or `player.anim.isActionLocked === true`, dodging must cancel the attack (`player.attackTimer = 0; player.anim.isActionLocked = false;`).

5. **Hygiene & Line Cap Compliance**:
   - `combat_skills.js` is at 472 lines. To prevent exceeding 500 lines, the worker must write compact logic and remove redundant inline comments or dead code blocks.
   - `animation_engine.js` is at 311 lines. Adding the 5-phase resolver and archetype clips will add ~25 lines, ending at ~336 lines (well under 350 Soft Cap).
   - `entity_renderer.js` is at 427 lines. Replacing dynamic array operations with a static 32-slot `GhostTrailPool` can be done in ~20 lines without exceeding 450 lines (well under 500 Hard Cap).

---

## 3. Caveats

- **Sprite Atlas Geometry**: `hero_anim_atlas.png` is fixed at $1280 \times 960$ with 40 cells. The worker must not add new unmapped frames that shift `globalIndex` past 39 in `drawEntityFrame`. Aliasing `atlas_clip: 'attack_slash'` or sharing frame sequences is the safest approach.
- **Strict Read-Only Mode**: Explorer is read-only; no code modifications have been made to the working tree.
- **E2E Baseline State**: Currently, tests marked `@pytest.mark.xfail(strict=False)` pass with XPASS. Once the worker implements the features, the orchestrator/QA may optionally promote them to strict tests.

---

## 4. Conclusion & Recommended Worker Implementation Strategy

Milestone 2 is highly structured and largely prepared by existing modules (`weapon_swing_catalog.js`, `weapon_swing_renderer.js`, `combat_feel_engine.js`). The primary work consists of:
1. Upgrading `animation_engine.js` to full 5-phase kinetic tracking and adding the 4 archetype attack clips.
2. Connecting `weapon_swing_renderer.js` and `combat_feel_engine.js` into the `canvas_renderer.js` game loop.
3. Wiring archetype triggers, hit-stop, dodge charges, and wind-up cancellation into `combat_skills.js` (carefully keeping total lines < 500).
4. Converting `player.ghostTrails` in `entity_renderer.js` to a static 32-slot pool.

### Step-by-Step Implementation Roadmap for the Worker:

#### Step 1: `animation_engine.js` (Target: <= 340 lines)
1. Add `KINETIC_PHASES` frozen constant:
   ```javascript
   export const KINETIC_PHASES = Object.freeze({
     IDLE: 'idle',
     RUN: 'run',
     WIND_UP: 'wind_up',
     IMPACT: 'impact',
     HIT_STOP: 'hit_stop',
     RECOVERY: 'recovery',
     DODGE: 'dodge'
   });
   ```
2. Update `HERO_TRACKS.animations`:
   Add `attack_thrust`, `attack_slam`, `attack_bolt` with `hit_frame` defined (e.g., frame 2 or 3) and `atlas_clip: 'attack_slash'` to reuse attack keyframes.
3. In `updateAnimation(animState, velocity, dt, facingDir)`:
   Ensure `animState.kineticPhase` is updated every frame:
   - If `animState.hitStopTimer > 0`: `'hit_stop'`
   - Else if `clip.hit_frame !== undefined`:
     - `frameIndex < clip.hit_frame` $\rightarrow$ `'wind_up'`
     - `frameIndex === clip.hit_frame` $\rightarrow$ `'impact'`
     - `frameIndex > clip.hit_frame` $\rightarrow$ `'recovery'`
   - Else if `currentClip === 'run'`: `'run'`
   - Else if `currentClip === 'dodge'`: `'dodge'`
   - Else: `'idle'`
4. Add `cancelAction(animState)`:
   ```javascript
   function cancelAction(animState) {
     if (!animState) return;
     animState.isActionLocked = false;
     animState.hitTriggered = false;
     animState.onHitCallback = null;
     animState.onCompleteCallback = null;
   }
   ```
5. Export `cancelAction` and `KINETIC_PHASES` on `window.AnimationEngine`.

#### Step 2: `canvas_renderer.js` (Target: <= 310 lines)
In `client/webapp/js/engine/canvas_renderer.js`:
1. In the update section:
   ```javascript
   if (window.combatFeelEngine) window.combatFeelEngine.update(dt);
   if (window.weaponSwingRenderer) window.weaponSwingRenderer.update(dt);
   ```
2. In the render section (after `renderEntities`):
   ```javascript
   if (window.weaponSwingRenderer) window.weaponSwingRenderer.render(ctx);
   if (window.combatFeelEngine) window.combatFeelEngine.renderDamageNumbers(ctx);
   ```

#### Step 3: `combat_skills.js` (Target: <= 485 lines, STRICTLY < 500)
In `client/webapp/js/engine/combat_skills.js`:
1. **Dodge Pool & Cancelling**:
   - Initialize `dodgeCharges = 3; dodgeMaxCharges = 3; dodgeRechargeTimer = 0;`
   - In `doDodge()`:
     - Check `if (dodgeCharges <= 0) return;`
     - Interrupt attack wind-up:
       ```javascript
       if (player.animState === 'attack' || (player.anim && player.anim.isActionLocked)) {
         player.attackTimer = 0;
         if (window.AnimationEngine && player.anim) window.AnimationEngine.cancelAction(player.anim);
       }
       ```
     - Decrement: `dodgeCharges--;`
     - Start recharge tracking if not already recharging.
   - In game update (or ticker): recharge 1 charge every 3.0s until reaching 3.
2. **Skill Archetype Swing Triggers**:
   - `doPrimaryAttack()`: trigger `'SLASH'`, play `'attack_slash'`
   - `doFire()`: trigger `'SLASH'`, play `'attack_slash'`
   - `doFrost()`: trigger `'THRUST'`, play `'attack_thrust'`
   - `doThunder()`: trigger `'BOLT'`, play `'attack_bolt'`
   - `bossHeavySlam()`: trigger `'SLAM'`, play `'attack_slam'`
3. **Hit-Stop on Impact**:
   - In `hitMonster()`:
     ```javascript
     const arch = (window.WeaponSwingCatalog && window.WeaponSwingCatalog.getWeaponSwingArchetype)
       ? window.WeaponSwingCatalog.getWeaponSwingArchetype(elem === 'frost' ? 'THRUST' : (elem === 'thunder' ? 'BOLT' : 'SLASH'))
       : { hitStopFrames: 3, hitStopDuration: 0.05 };
     if (typeof window.triggerHitStop === 'function') {
       window.triggerHitStop(arch.hitStopFrames, arch.hitStopDuration);
     }
     if (player.anim) player.anim.hitStopTimer = arch.hitStopDuration;
     ```
   - Spawn damage numbers using `window.spawnDamageNumber(...)`.

#### Step 4: `entity_renderer.js` (Target: <= 445 lines, STRICTLY < 500)
In `client/webapp/js/engine/entity_renderer.js`:
- Encapsulate `ghostTrails` within a static ring buffer (32 slots) with `active`, `x`, `y`, `facing`, `alpha` properties to eliminate dynamic allocation in hot loops.

---

## 5. Verification Method

To verify the implementation independently, execute the following commands in order:

1. **E2E UI, Animation & Combat Feel Tests**:
   ```powershell
   pytest -v tests/e2e/test_poe2_ui_animation_vfx_e2e.py
   ```
   *Expected Result*: All 42 tests pass (0 failures).

2. **Martial Character Ecosystem Unit Tests**:
   ```powershell
   pytest -v tests/unit/test_martial_character_ecosystem.py
   ```
   *Expected Result*: All 5 tests pass (0 failures).

3. **Strict Code & Document Hygiene Gate**:
   ```powershell
   python tools/lint/check_code_and_doc_hygiene.py --strict
   ```
   *Expected Result*: 0 Hard Cap violations (`client/webapp/js/engine/combat_skills.js` $\le 500$, `animation_engine.js` $\le 500$, `entity_renderer.js` $\le 500$).

4. **Runtime Manual Verification Checklist (Demo mode / Chrome DevTools)**:
   - Hero performs `idle` breathing at 6 fps.
   - Moving in 8 directions syncs footstep cadence with ground speed without foot sliding.
   - Executing Primary / Skills displays distinct swing VFX (Cleave arc, Thrust cone, Lightning bolt).
   - Striking the Target Dummy or monsters produces 2–4 frame micro-freeze (hit-stop) on both attacker and victim.
   - Pressing Space during skill windup immediately cancels the attack into Huyễn Ảnh Bộ with i-frame and ghost trails.
