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

**Subject**: Huyễn Ảnh Bộ Evasion (i-frames, dodge roll, animation cancelling) & Ghost Trail Pool (`entity_renderer.js` / `weapon_swing_renderer.js`)  
**Investigator**: `explorer_m2_3_gen3`  
**Working Directory**: `c:\Projects\FreeExile\.agents\teamwork\explorer_m2_3_gen3`  
**Date**: 2026-10-01  

---

## 1. Observation

### Obs 1: 250ms i-Frame Window & Exact Boundaries
- **Server**: In `server/world/combat_engine.py`:
  - Lines 23-24: `last_evasion_timestamp_ms: int = 0`, `evasion_iframe_duration_ms: int = 250`.
  - Lines 87-98:
    ```python
    elapsed_evasion = current_timestamp_ms - defender.last_evasion_timestamp_ms
    if 0 <= elapsed_evasion <= defender.evasion_iframe_duration_ms:
        return DamageEventResult(..., is_evaded=True, final_damage=0.0, ...)
    ```
- **Client**: In `client/webapp/js/engine/combat_skills.js`:
  - Lines 286-288:
    ```javascript
    player.isIFrame = true;
    player.iFrameTimer = 0.25;
    player.animState = 'dodge';
    ```
- **Decay & UI**: In `client/webapp/js/engine/canvas_renderer.js`:
  - Lines 176-191:
    ```javascript
    if (player.isIFrame) {
      player.iFrameTimer -= dt;
      ...
      if (player.iFrameTimer <= 0) {
        player.isIFrame = false;
        document.getElementById('badge-iframe').classList.add('hidden');
        if (player.animState === 'dodge') player.animState = mag > 0.05 ? 'run' : 'idle';
      }
    }
    ```
- **Monster Evasion Check**: In `client/webapp/js/engine/monster_system.js`:
  - Lines 306-316:
    ```javascript
    if (player.isIFrame === true || (player.iFrameTimer && player.iFrameTimer > 0)) {
      if (typeof sfxEngine !== 'undefined') sfxEngine.playDodgeWhoosh();
      ...
      spawnDamageText('NÉ ĐÒN! (i-frame)', pPos.x, pPos.y - 45, '#38bdf8', false);
      spawnParticles('phantom', player.wx, player.wy, 16);
      return;
    }
    ```
- **Boundary Tests**: `tests/unit/test_dodge_and_evasion_iframe.py` (lines 84-156) and `tests/e2e/test_poe2_ui_animation_vfx_e2e.py` (lines 242-250) confirm:
  - $t+0\text{ ms}$: `is_evaded is True`
  - $t+249\text{ ms}$: `is_evaded is True`
  - $t+250\text{ ms}$: `is_evaded is True`
  - $t+251\text{ ms}$: `is_evaded is False` (damage taken)

### Obs 2: 3-Charge Dodge Pool & Cooldown Recovery
- In `client/webapp/js/ui/skill_bar_controller.js`:
  - Line 48: `{ id: 'dodge', domId: 'skill-dodge', key: 'SPACE', maxCd: 3.0, charges: 3, action: () => window.doDodge?.() }`
  - Lines 140-151: Triggering cooldown decrements `slot.charges--`, syncs `window.dodgeCharges = slot.charges`, updates `#dodge-charges-badge`, and starts `slot.rechargeRemaining = slot.rechargeDuration` (3.0s).
  - Lines 213-228: Upon recharge timeout, `slot.charges` increments by 1 (max 3), updates badge and ready flash.
  - Line 187: When `slot.charges <= 0`, `isSlotOnCooldown('dodge')` returns `true`.
- In `client/webapp/js/engine/combat_skills.js`:
  - Lines 278-282: `var dodgeCharges = 3; window.dodgeCharges = dodgeCharges;`
  - `doDodge()` guards with:
    `if (player.isIFrame || (window.skillBarController && window.skillBarController.isSlotOnCooldown('dodge'))) return;`
  - But if `skillBarController` is missing or when called directly, `dodgeCharges` is not decremented locally.

### Obs 3: Animation Cancelling During Wind-Up Phase
- In `tests/e2e/test_poe2_ui_animation_vfx_e2e.py` lines 272-283:
  ```python
  def test_cross_dodge_cancels_skill_windup(self, combat_setup: Tuple[CombatEngine, CombatActor, CombatActor]) -> None:
      """Player executing dodge roll during skill wind-up interrupts attack and gains i-frame."""
      ...
      hero.is_channeling = True
      engine.trigger_phantom_evasion(hero.actor_id, now_ms)
      hero.is_channeling = False
      ...
      assert res.is_evaded is True and hero.is_channeling is False
  ```
- In `client/webapp/js/engine/animation_engine.js`:
  - Lines 85-104: `createAnimationState` initializes `kineticPhase: 'idle'`, `hitStopTimer: 0`, `isActionLocked: false`.
  - Line 152: Sets `animState.kineticPhase = 'hit_stop'`.
  - Lines 188-195: Tracks `clip.hit_frame` (`attack_slash` has `hit_frame: 2`). But `kineticPhase` is not dynamically assigned to `'wind_up'` (when `frameIndex < hit_frame`) or `'recovery'` (when `frameIndex > hit_frame`).
- In `client/webapp/js/engine/combat_skills.js`:
  - Lines 215-276: `doFire()`, `doThunder()`, `doFrost()`, `doPrimaryAttack()` set `player.animState = 'attack'`, but `doDodge()` does not explicitly clear ongoing attack timers (`player.attackTimer = 0`, `player.isChanneling = false`) or cancel active weapon swings.

### Obs 4: Ghost Trail Afterimages & Allocation Deficiencies
- In `client/webapp/js/engine/canvas_renderer.js`:
  - Lines 178-185:
    ```javascript
    const pPos = worldToIso(player.wx, player.wy);
    player.ghostTrails.push({
      x: pPos.x,
      y: pPos.y,
      facing: player.facing,
      alpha: 0.7,
      weapon: player.currentWeapon
    });
    ```
    This allocates an object and pushes to `player.ghostTrails` *every frame* while `player.isIFrame` is active (~30 heap allocations per dodge at 120 FPS).
- In `client/webapp/js/engine/entity_renderer.js`:
  - Lines 30-43:
    ```javascript
    for (let i = player.ghostTrails.length - 1; i >= 0; i--) {
      const g = player.ghostTrails[i];
      g.alpha -= dt * 2.5;
      if (g.alpha <= 0) { player.ghostTrails.splice(i, 1); continue; }
      ctx.save();
      ctx.globalAlpha = g.alpha * 0.45;
      ctx.translate(g.x, g.y);
      if (g.facing < 0) ctx.scale(-1, 1);
      const heroImg = ...;
      if (heroImg && heroImg.complete) {
        ctx.drawImage(heroImg, -50, -125 + 16, 100, 125);
      }
      ctx.restore();
    }
    ```
    - Mutates array with `splice(i, 1)` inside the hot render path.
    - Draws standard hero sprite with generic alpha, **completely lacking any ethereal / cyan / blue tint** (`#38bdf8`) or glow.
- **Unit Test Assertion String Requirement**:
  - `tests/unit/test_dodge_and_evasion_iframe.py` (lines 58-62):
    ```python
    def test_client_ghost_trail_integration(self):
        self.assertIn("player.ghostTrails.push", self.canvas_renderer_code)
        self.assertIn("player.ghostTrails", self.entity_renderer_code)
    ```
    Any refactoring must retain these exact string signatures to avoid breaking this unit test.

### Obs 5: `weapon_swing_renderer.js` Disconnection
- In `client/webapp/js/engine/weapon_swing_renderer.js` (166 lines):
  - Defines `WeaponSwingEffect` and `WeaponSwingRenderer` with support for:
    - `SLASH`: Dynamic crescent slash blade with sharp white inner cutting edge, red glow (`#ef4444`).
    - `THRUST`: Directional energy cone / piercing lance (`#38bdf8`).
    - `SLAM`: Radial shockwave ground fissure (`#f59e0b`).
    - `BOLT`: Sky-piercing celestial lightning bolt (`#c084fc`).
  - Exports instance `weaponSwingRenderer` and exposes it to `window.weaponSwingRenderer`.
- **Disconnection**:
  - `canvas_renderer.js`: Does NOT call `weaponSwingRenderer.update(dt)` in the update loop, nor `weaponSwingRenderer.render(ctx)` in the render loop.
  - `combat_skills.js`: Does NOT trigger `weaponSwingRenderer.triggerSwing()` on skill or primary attack activations.
  - `entity_renderer.js`: Lines 388-411 still execute an obsolete hardcoded single arc (`ctx.arc(22, -45, 38, arcStart, arcEnd)`).

### Obs 6: Strict Line Length Hygiene Status
- Hygiene run (`python tools/lint/check_code_and_doc_hygiene.py --strict`):
  - `client/webapp/js/engine/combat_skills.js`: **473 lines** (Soft Cap 350, Hard Cap 500). Headroom: **27 lines**.
  - `client/webapp/js/engine/entity_renderer.js`: **428 lines** (Soft Cap 350, Hard Cap 500). Headroom: **72 lines**.
  - `client/webapp/js/engine/weapon_swing_renderer.js`: **166 lines** (Clean, <= 350).
  - `client/webapp/js/engine/animation_engine.js`: **311 lines** (Clean, <= 350).
  - `client/webapp/js/engine/canvas_renderer.js`: **297 lines** (Clean, <= 350).
  - `client/webapp/js/ui/skill_bar_controller.js`: **351 lines** (Clean, <= 500).
  - `client/webapp/js/data/weapon_swing_catalog.js`: **113 lines** (Clean, <= 350).

---

## 2. Logic Chain

1. **250ms i-Frame & 3-Charge Evasion Integrity (Supported by Obs 1, Obs 2)**:
   - The evasion window math ($0.25\text{s} = 250\text{ms}$) is mathematically and functionally sound on both server and client.
   - However, the client's dodge charges currently rely entirely on `skill_bar_controller.js`. If `doDodge()` is triggered before controller hydration or programmatically without `skillBarController`, `window.dodgeCharges` does not decrement. A self-contained fallback within `combat_skills.js` or guaranteed early binding ensures bulletproof offline and test resilience.

2. **Animation Cancelling Mechanism (Supported by Obs 3)**:
   - In PoE2, dodge roll is responsive because pressing Space during an attack's anticipation/wind-up phase cancels the attack before commitment.
   - Currently, `AnimationEngine` lacks runtime tracking of the 5 phases during clip playback. Specifically, `attack_slash` has `hit_frame: 2`. When `frameIndex < 2`, the actor is in `wind_up`. When `frameIndex == 2`, the actor is in `impact`. When `frameIndex > 2`, the actor is in `recovery`.
   - When the user presses Dodge while `animState.kineticPhase === 'wind_up'` (or `player.isChanneling === true`):
     - The engine must immediately reset `player.attackTimer = 0`, unlock `animState.isActionLocked = false`, cancel any active weapon swing effect, set `player.isChanneling = false`, and start `doDodge()`.

3. **Ghost Trail Pool & Ethereal Tinting (Supported by Obs 4, Obs 6)**:
   - To achieve the 120 FPS ProMotion zero-allocation mandate (Feature 19 of `SCOPE.md`) while satisfying `test_dodge_and_evasion_iframe.py`:
     - Spawning must be throttled (e.g. every 25ms rather than every frame at 120 FPS) to produce 8-10 distinct, crisp afterimages instead of an overlapping smear.
     - A 32-slot static `GhostTrailPool` must be created.
     - To prevent `entity_renderer.js` (428 lines) or `combat_skills.js` (473 lines) from breaching the 500-line hard cap, `GhostTrailPool` should be encapsulated in a dedicated module `client/webapp/js/engine/ghost_trail_pool.js` (~110 lines) and referenced in `entity_renderer.js` and `canvas_renderer.js`.
     - In `canvas_renderer.js`, keeping the literal signature `player.ghostTrails.push` delegating to or mirroring the pool satisfies `test_client_ghost_trail_integration`.
     - In `entity_renderer.js`, rendering ghost trails with `ctx.shadowColor = '#38bdf8'`, `ctx.shadowBlur = 12`, `ctx.globalCompositeOperation = 'lighter'` (or cyan tint `rgba(56, 189, 248, 0.75)`) creates the true ethereal afterimage.

4. **Wiring `weapon_swing_renderer.js` (Supported by Obs 5)**:
   - `weapon_swing_renderer.js` is already modular and high-quality, but completely dead in terms of runtime execution.
   - Calling `weaponSwingRenderer.triggerSwing()` on attacks, and adding `weaponSwingRenderer.update(dt)` and `weaponSwingRenderer.render(ctx)` into the main canvas pipeline will immediately bring the 4 weapon archetypes (Slash, Thrust, Slam, Bolt) to life with zero overhead.
   - Adding a `.cancelWindup()` method on `WeaponSwingRenderer` ensures that if a swing is cancelled during `windUpTime`, its visual arc stops immediately.

---

## 3. Caveats

1. **Atlas Frame Rendering vs Standalone Image**:
   - `AnimationEngine` supports sprite sheet atlas rendering (`hero_anim_atlas.png`) if loaded, and falls back to `ASSETS[player.class_type]` if the atlas is still loading. The ghost trail pool must gracefully handle both: capturing the current sprite/atlas UV or falling back to the current class standee image.
2. **Backward Compatibility with Existing Unit Tests**:
   - Multiple tests (`test_dodge_and_evasion_iframe.py`, `test_character_animation_and_skills_vfx.py`, `test_poe2_ui_animation_vfx_e2e.py`) perform opaque-box regex and substring checks on `client_bundle` and individual source files. Code additions must strictly preserve existing identifiers (`player.isIFrame = true;`, `player.iFrameTimer = 0.25;`, `player.animState = 'dodge';`, `player.ghostTrails.push`, `player.ghostTrails`, `dodgeCharges`).
3. **Line Cap Overhead**:
   - `combat_skills.js` is at 473 lines. The worker cannot add more than 25 lines to this file without triggering a hard cap violation in `check_code_and_doc_hygiene.py`. Any new logic must be concise or placed into dedicated modules.

---

## 4. Conclusion & Recommended Worker Implementation Strategy

The worker agent should execute the following 5-step implementation:

### Step 1: Create `client/webapp/js/engine/ghost_trail_pool.js`
- Create a dedicated zero-allocation 32-slot pool class `GhostTrailPool`:
  ```javascript
  class GhostTrailSlot {
    constructor() {
      this.active = false;
      this.x = 0; this.y = 0;
      this.facing = 1;
      this.alpha = 0.75;
      this.decay = 2.8;
      this.tint = '#38bdf8';
      this.glow = '#38bdf8';
    }
  }
  export class GhostTrailPool {
    constructor(capacity = 32) {
      this.capacity = capacity;
      this.slots = Array.from({ length: capacity }, () => new GhostTrailSlot());
      this.head = 0;
      this.spawnTimer = 0;
      this.spawnInterval = 0.025; // 25ms interval = ~10 clean afterimages per 250ms roll
    }
    spawn(x, y, facing, options = {}) { ... }
    update(dt) { ... }
    render(ctx) { ... }
  }
  ```
- Expose to `window.ghostTrailPool` and export for native ES modules.
- Add `<script type="module" src="js/engine/ghost_trail_pool.js"></script>` to `client/webapp/index.html`.

### Step 2: Update `client/webapp/js/engine/canvas_renderer.js`
- Throttled ghost trail spawning during `player.isIFrame`:
  ```javascript
  if (player.isIFrame) {
    player.iFrameTimer -= dt;
    const pPos = worldToIso(player.wx, player.wy);
    if (typeof window.ghostTrailPool !== 'undefined') {
      window.ghostTrailPool.spawn(pPos.x, pPos.y, player.facing, { alpha: 0.75, tint: '#38bdf8' });
    } else {
      player.ghostTrails.push({ x: pPos.x, y: pPos.y, facing: player.facing, alpha: 0.7, weapon: player.currentWeapon });
    }
    if (player.iFrameTimer <= 0) { ... }
  }
  ```
- Wire `weaponSwingRenderer.update(dt)` in update loop.
- Wire `weaponSwingRenderer.render(ctx)` in render loop (after `renderEntities`).

### Step 3: Update `client/webapp/js/engine/entity_renderer.js`
- Delegate ghost trail rendering to `window.ghostTrailPool.render(ctx)` while preserving fallback `player.ghostTrails` loop:
  ```javascript
  if (typeof window.ghostTrailPool !== 'undefined') {
    window.ghostTrailPool.render(ctx);
  } else if (typeof player !== 'undefined' && player.ghostTrails) {
    // Fallback drawing loop
  }
  ```
- Remove or cleanly wrap lines 388-411 in `entity_renderer.js` so it delegates to `window.weaponSwingRenderer`.

### Step 4: Enhance `animation_engine.js` Kinetic Phases & Wind-Up Cancelling
- In `updateAnimation(animState, velocity, dt, facingDir)`:
  - Dynamically assign `animState.kineticPhase`:
    - If `clip.hit_frame !== undefined`:
      - `animState.frameIndex < clip.hit_frame ? 'wind_up' : (animState.frameIndex === clip.hit_frame ? 'impact' : 'recovery')`
    - Else if `clipName === 'run'`: `'run'`
    - Else if `clipName === 'dodge'`: `'dodge'`
    - Else: `'idle'`
- In `combat_skills.js`:
  - In `doDodge(invoker)`:
    - Check if player is currently in attack or windup:
      ```javascript
      if (player.animState === 'attack' || player.isChanneling || (player.anim && player.anim.kineticPhase === 'wind_up')) {
        player.attackTimer = 0;
        player.isChanneling = false;
        if (player.anim) player.anim.isActionLocked = false;
        if (window.weaponSwingRenderer) window.weaponSwingRenderer.cancelWindup?.();
      }
      ```
    - Decrement `window.dodgeCharges` if `skillBarController` is not active:
      ```javascript
      if (!window.skillBarController && typeof window.dodgeCharges === 'number' && window.dodgeCharges > 0) {
        window.dodgeCharges--;
      }
      ```
  - In `doFire()`, `doThunder()`, `doFrost()`, `doPrimaryAttack()`:
    - Call `window.weaponSwingRenderer?.triggerSwing(archetype, player.wx, player.wy, tWx, tWy)`.

### Step 5: Verify Line Caps & All Tests
- Ensure `combat_skills.js` $\le 490$ lines.
- Ensure `entity_renderer.js` $\le 440$ lines.
- Run `python tools/lint/check_code_and_doc_hygiene.py --strict`.
- Run `pytest tests/e2e/test_poe2_ui_animation_vfx_e2e.py`.
- Run `pytest tests/unit/test_dodge_and_evasion_iframe.py`.

---

## 5. Verification Method

To independently verify the implementation, the following commands must be executed:

1. **E2E Test Suite Pass**:
   ```bash
   pytest tests/e2e/test_poe2_ui_animation_vfx_e2e.py -v
   ```
   *Expected*: 42 passed (including `test_f09_dodge_iframe_state_and_timer`, `test_f09_dodge_charges_budget_baseline`, `test_cross_dodge_cancels_skill_windup`, `test_boundary_iframe_window_exact_boundaries`).

2. **Dodge Roll & i-Frame Unit Suite**:
   ```bash
   pytest tests/unit/test_dodge_and_evasion_iframe.py -v
   ```
   *Expected*: 16 passed, 0 failed.

3. **Character Animation & Skill VFX Unit Suite**:
   ```bash
   pytest tests/unit/test_character_animation_and_skills_vfx.py -v
   ```
   *Expected*: 10 passed, 0 failed.

4. **Strict Code & Document Hygiene Gate**:
   ```bash
   python tools/lint/check_code_and_doc_hygiene.py --strict
   ```
   *Expected*: 0 Hard Cap violations (`ERROR`), exit code `0`.

5. **Visual Inspection Invalidation Condition**:
   - In browser simulation (`client/webapp/index.html`), pressing Space while executing an attack must immediately cancel the attack motion into a dodge roll.
   - The character leaves behind distinct, ethereal cyan-tinted afterimages that smoothly fade out over ~0.3s without dropping below 60/120 FPS.
