# EXPLORATION REPORT: DUAL-PLATFORM CONTROLS ERGONOMICS & INTEGRATION
> **Author**: `explorer_m1_3_gen2` (teamwork_preview_explorer)  
> **Mission**: Milestone 1 - Explorer 3: Controls Ergonomics & Mobile/PC Integration  
> **Target Files**: `client/webapp/js/ui/skill_bar_controller.js`, `client/webapp/js/engine/joystick.js`, `client/webapp/index.html`, `client/webapp/js/engine/combat_skills.js`  
> **Status**: APPROVED ARCHITECTURE & CODE SPECIFICATION — READY FOR IMPLEMENTATION

---

## 1. OBSERVATION

Direct code and asset inspection revealed the following architectural baselines and bottlenecks:

1. **Current Joystick Multi-Touch Flaw (`client/webapp/js/engine/joystick.js:76–96`)**:
   ```javascript
   joystickZone.addEventListener('touchstart', (e) => {
     e.preventDefault(); const touch = e.touches[0];
     handleJoystickPointerStart(touch.clientX, touch.clientY, true);
   }, { passive: false });
   joystickZone.addEventListener('touchend', (e) => { e.preventDefault(); resetJoystick(); }, { passive: false });
   ```
   - **Root Cause**: Reads `e.touches[0]` regardless of which finger touched. When holding joystick with left thumb and tapping any skill with right thumb, `e.touches[0]` shifts index.
   - **Stuck / Drop Glitch**: `touchend` unconditionally executes `resetJoystick()`, so lifting the right thumb after casting stops character movement even though the left thumb is still down.

2. **Absence of Dead-Zone Clamping (`joystick.js:60–66`)**:
   ```javascript
   const normDist = clampedDist / maxRadius;
   const nx = (stickX / maxRadius); const ny = (stickY / maxRadius);
   player.dirX = (nx + ny * 2) * normDist; player.dirY = (ny * 2 - nx) * normDist;
   ```
   - Zero dead-zone: Any sub-pixel touch tremor immediately introduces player drift. Dead-zone threshold (> 0.15) is completely absent.

3. **Incomplete Action Cluster & Ergonomic Mismatch (`client/webapp/index.html:103–111`)**:
   - Current DOM contains only 4 buttons (`#skill-fire`, `#skill-thunder`, `#skill-frost`, `#skill-dodge`) in a 160x160 box (`w-40 h-40`).
   - Missing: Primary Attack button (`primary_attack`, 100x100px, r=50px), Skill Slot 4 (`W` / Poison), and Potion Slot (5).
   - In `client/webapp/assets/ui/combat/hud_combat_metadata.json`, the authoritative geometry specifies:
     * `primary_attack`: `w: 100, h: 100, touch_radius: 50` (natural thumb resting center).
     * `skill_slot_1`, `skill_slot_2`, `skill_slot_3`: `52x52` in a radial arc wrapping around primary attack.
     * `phantom_dodge`: `90x90` with 3 charges at lower flank.

4. **PC Input & Keybinding Fragmentations (`combat_skills.js:326–342`)**:
   - `combat_skills.js` hardcodes: `q`/`1` -> Fire, `e`/`2` -> Thunder, `r`/`3` -> Frost, `Space` -> Dodge, `4` -> Potion.
   - Key `W` is omitted because of WASD movement conflict.
   - Mouse: Canvas `click` listener (`combat_skills.js:441–474`) handles portals and ground move, but completely lacks:
     * Mouse Left-Click direct targeting on monsters (combat raycasting).
     * Mouse Right-Click contextmenu suppression and targeted secondary skill casting.

5. **Coupling & Line Cap Risk (`combat_skills.js` currently 490 lines)**:
   - DOM event bindings and UI cooldown logic are bundled inside `combat_skills.js` (490 lines, near 500-line Hard Cap).
   - Creating `skill_bar_controller.js` will offload UI/cooldown management and bring `combat_skills.js` well under 350 lines.

---

## 2. LOGIC CHAIN

```mermaid
flowchart TD
    Obs1["Legacy touchstart & e.touches[0] causes multi-touch conflicts"] --> Sol1["Adopt W3C Pointer Events with setPointerCapture & activePointerId"]
    Obs2["Zero dead-zone creates thumb jitter drift"] --> Sol2["Implement 0.15 Dead-Zone Clamping & Smooth Remapping"]
    Obs3["index.html missing primary attack & 4th skill, breaks HUD metadata"] --> Sol3["Rebuild Action Cluster matching hud_combat_metadata.json"]
    Obs4["Fragmented PC keys, missing W, mouse right-click blocked"] --> Sol4["Unified Input Mapper (Q/W/E/R, 1-4, Space, Mouse L/R)"]
    Obs5["combat_skills.js at 490 lines violates soft cap"] --> Sol5["Extract client/webapp/js/ui/skill_bar_controller.js <= 350 lines"]
```

1. **Pointer Events Rationale**: Pointer Events (`pointerdown`, `pointermove`, `pointerup`, `pointercancel`) assign a unique `pointerId` to each finger. Using `setPointerCapture(activePointerId)` guarantees the virtual joystick only responds to the thumb that originated the drag. Other fingers (e.g. right thumb spamming skills) are strictly isolated, permanently eliminating button-stuck glitches.
2. **Dead-Zone Rationale**: Human thumbs naturally tremble when resting on glass. Clamping $\text{normDist} \le 0.15$ to 0 velocity and remapping $(0.15, 1.0] \rightarrow [0, 1.0]$ ensures motionless resting and silky-smooth acceleration.
3. **Ergonomic Arc Rationale**: In ARPG mobile gameplay, the primary attack button must sit directly under the natural thumb joint ($R=50\text{px}$). Skill buttons must radiate along the natural thumb arc at constant distance, preventing hyperextension.
4. **Decoupling Rationale**: Moving UI button state, cooldown radial sweep overlays (`conic-gradient`), and keyboard/mouse mapping into `skill_bar_controller.js` adheres to Single Responsibility, keeps both files $< 350$ lines, and makes testing purely modular.

---

## 3. PROPOSED ARCHITECTURE & CODE SPECIFICATION

### 3.1. Re-engineered Virtual Dynamic Joystick (`client/webapp/js/engine/joystick.js`)
*Line budget: ~110 lines (strictly $\le 350$ lines).*

```javascript
// client/webapp/js/engine/joystick.js
(function () {
  let joystickOrigin = { x: 85, y: 320 };
  const maxRadius = 46;
  const DEAD_ZONE = 0.15; // > 0.15 deadzone clamping
  let activePointerId = null;
  window.joystickActive = false;

  const joystickZone = document.getElementById('joystick-zone');
  const joystickBase = document.getElementById('joystick-base');
  const joystickStick = document.getElementById('joystick-stick');
  const viewport = document.getElementById('app-viewport');

  function setJoystickRestPosition() {
    if (window.joystickActive) return;
    const zRect = joystickZone.getBoundingClientRect();
    const restX = Math.min(105, Math.max(90, zRect.width * 0.20));
    const restY = Math.max(70, zRect.height - 95);
    joystickOrigin = { x: restX, y: restY };
    joystickBase.style.left = `${restX}px`;
    joystickBase.style.top = `${restY}px`;
    joystickStick.style.transform = `translate(-50%, -50%)`;
    joystickBase.classList.remove('hidden');
  }

  window.addEventListener('resize', () => setTimeout(setJoystickRestPosition, 50));
  setTimeout(setJoystickRestPosition, 100);

  function handlePointerDown(e) {
    if (activePointerId !== null) return;
    activePointerId = e.pointerId;
    joystickZone.setPointerCapture(e.pointerId);
    const rect = viewport.getBoundingClientRect();
    const x = e.clientX - rect.left, y = e.clientY - rect.top;
    if (e.pointerType === 'touch') {
      joystickOrigin = { x, y };
      joystickBase.style.left = `${x}px`;
      joystickBase.style.top = `${y}px`;
    }
    joystickStick.style.transform = `translate(-50%, -50%)`;
    joystickBase.classList.remove('hidden');
    window.joystickActive = true;
  }

  function handlePointerMove(e) {
    if (!window.joystickActive || e.pointerId !== activePointerId) return;
    const rect = viewport.getBoundingClientRect();
    const dx = (e.clientX - rect.left) - joystickOrigin.x;
    const dy = (e.clientY - rect.top) - joystickOrigin.y;
    const dist = Math.hypot(dx, dy), angle = Math.atan2(dy, dx);
    const normDist = dist / maxRadius;

    if (normDist <= DEAD_ZONE) {
      player.dirX = 0; player.dirY = 0;
      const subX = Math.cos(angle) * (dist * 0.5), subY = Math.sin(angle) * (dist * 0.5);
      joystickStick.style.transform = `translate(calc(-50% + ${subX}px), calc(-50% + ${subY}px))`;
      return;
    }

    const effectiveDist = Math.min(1.0, (normDist - DEAD_ZONE) / (1.0 - DEAD_ZONE));
    const clampedDist = Math.min(dist, maxRadius);
    const stickX = Math.cos(angle) * clampedDist, stickY = Math.sin(angle) * clampedDist;
    joystickStick.style.transform = `translate(calc(-50% + ${stickX}px), calc(-50% + ${stickY}px))`;

    const nx = Math.cos(angle), ny = Math.sin(angle);
    player.dirX = (nx + ny * 2) * effectiveDist;
    player.dirY = (ny * 2 - nx) * effectiveDist;
  }

  function handlePointerUp(e) {
    if (e.pointerId !== activePointerId) return;
    try { joystickZone.releasePointerCapture(e.pointerId); } catch (_) {}
    activePointerId = null; window.joystickActive = false;
    player.dirX = 0; player.dirY = 0; setJoystickRestPosition();
  }

  joystickZone.addEventListener('pointerdown', handlePointerDown);
  joystickZone.addEventListener('pointermove', handlePointerMove);
  joystickZone.addEventListener('pointerup', handlePointerUp);
  joystickZone.addEventListener('pointercancel', handlePointerUp);
  window.addEventListener('blur', () => { if (activePointerId !== null) handlePointerUp({ pointerId: activePointerId }); });

  window.keys = window.keys || {};
  window.addEventListener('keydown', (e) => {
    if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
    window.keys[e.key.toLowerCase()] = true;
  });
  window.addEventListener('keyup', (e) => { window.keys[e.key.toLowerCase()] = false; });
  window.setJoystickRestPosition = setJoystickRestPosition;
})();
```

---

### 3.2. Dedicated Skill Bar & Input Controller (`client/webapp/js/ui/skill_bar_controller.js`)
*Line budget: ~150 lines (strictly $\le 350$ lines).*

```javascript
// client/webapp/js/ui/skill_bar_controller.js
export class SkillBarController {
  constructor() {
    this.slots = [
      { id: 'skill-primary', key: 'mouse0', label: 'TRẢM', cd: 0, maxCd: 0.0, action: 'doPrimaryAttack' },
      { id: 'skill-slot-1', key: 'q', numKey: '1', label: 'Q', cd: 0, maxCd: 1.5, action: 'doFire' },
      { id: 'skill-slot-2', key: 'w', numKey: '2', label: 'W', cd: 0, maxCd: 3.0, action: 'doThunder' },
      { id: 'skill-slot-3', key: 'e', numKey: '3', label: 'E', cd: 0, maxCd: 4.5, action: 'doFrost' },
      { id: 'skill-slot-4', key: 'r', numKey: '4', label: 'R', cd: 0, maxCd: 6.0, action: 'doPoison' },
    ];
    this.dodge = { id: 'skill-dodge', key: ' ', cd: 0, maxCd: 3.0, charges: 3, maxCharges: 3, action: 'doDodge' };
    this.potion = { id: 'skill-potion', key: '5', cd: 0, maxCd: 8.0, count: 5 };
  }

  init() {
    this.bindDOM();
    this.bindKeyboard();
    this.bindMouseCanvas();
  }

  bindDOM() {
    this.slots.forEach((s, idx) => {
      document.getElementById(s.id)?.addEventListener('pointerdown', (e) => { e.preventDefault(); this.triggerSlot(idx); });
    });
    document.getElementById(this.dodge.id)?.addEventListener('pointerdown', (e) => { e.preventDefault(); this.triggerDodge(); });
    document.getElementById(this.potion.id)?.addEventListener('pointerdown', (e) => { e.preventDefault(); this.triggerPotion(); });
  }

  bindKeyboard() {
    window.addEventListener('keydown', (e) => {
      if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
      const k = e.key.toLowerCase();
      if (k === 'q' || k === '1') this.triggerSlot(1);
      else if (k === 'w' || k === '2') { if (k === '2' || !window.keys?.['s']) this.triggerSlot(2); }
      else if (k === 'e' || k === '3') this.triggerSlot(3);
      else if (k === 'r' || k === '4') this.triggerSlot(4);
      else if (k === ' ' || k === 'space' || k === 'shift') { e.preventDefault(); this.triggerDodge(); }
      else if (k === '5') this.triggerPotion();
    });
  }

  bindMouseCanvas() {
    const canvas = document.getElementById('game-canvas');
    if (!canvas) return;
    canvas.addEventListener('contextmenu', (e) => { e.preventDefault(); this.triggerSlot(1); });
  }

  triggerSlot(index) {
    const slot = this.slots[index];
    if (!slot || slot.cd > 0) { this.flashError(slot?.id); return false; }
    if (typeof window[slot.action] === 'function') {
      window[slot.action]();
      slot.cd = slot.maxCd;
      this.animatePress(slot.id);
      return true;
    }
    return false;
  }

  triggerDodge() {
    if (this.dodge.charges <= 0) { this.flashError(this.dodge.id); return false; }
    if (typeof window.doDodge === 'function') {
      window.doDodge();
      this.dodge.charges--;
      this.updateDodgePips();
      return true;
    }
    return false;
  }

  triggerPotion() {
    if (this.potion.cd > 0 || this.potion.count <= 0) return false;
    if (typeof window.drinkHealthPotion === 'function') {
      window.drinkHealthPotion();
      this.potion.cd = this.potion.maxCd;
      return true;
    }
    return false;
  }

  update(dt) {
    for (let i = 1; i < this.slots.length; i++) {
      const slot = this.slots[i];
      if (slot.cd > 0) {
        slot.cd = Math.max(0, slot.cd - dt);
        const el = document.getElementById(slot.id);
        if (el) {
          const sweep = el.querySelector('.skill-cd-sweep'), txt = el.querySelector('.skill-cd-text');
          const pct = (slot.cd / slot.maxCd) * 360;
          if (sweep) sweep.style.background = `conic-gradient(rgba(10,10,12,0.82) ${pct}deg, transparent ${pct}deg)`;
          if (txt) txt.innerText = slot.cd.toFixed(1) + 's';
        }
        if (slot.cd === 0) this.triggerReadyFlash(slot.id);
      }
    }
    if (this.dodge.charges < this.dodge.maxCharges) {
      this.dodge.cd -= dt;
      if (this.dodge.cd <= 0) { this.dodge.charges++; this.dodge.cd = this.dodge.maxCd; this.updateDodgePips(); }
    }
  }

  triggerReadyFlash(id) {
    const el = document.getElementById(id);
    if (!el) return;
    el.classList.add('ready-flash');
    setTimeout(() => el.classList.remove('ready-flash'), 250);
  }

  updateDodgePips() {
    const pips = document.querySelectorAll('.dodge-charge-pip');
    pips.forEach((p, idx) => {
      p.className = idx < this.dodge.charges ? 'w-1.5 h-1.5 rounded-full bg-amber-400' : 'w-1.5 h-1.5 rounded-full bg-stone-700/60';
    });
  }

  animatePress(id) {
    const el = document.getElementById(id);
    if (el) { el.classList.add('scale-90'); setTimeout(() => el.classList.remove('scale-90'), 100); }
  }

  flashError(id) {
    const el = document.getElementById(id);
    if (el) { el.classList.add('border-red-500'); setTimeout(() => el.classList.remove('border-red-500'), 200); }
  }
}

export const skillBarController = new SkillBarController();
window.skillBarController = skillBarController;
```

---

### 3.3. Ergonomic Action Cluster DOM Markup (`client/webapp/index.html`)
*Replaces lines 103–111 with HUD metadata-aligned geometry ($100\text{px}$ Primary, $52\text{px}$ Skills, $90\text{px}$ Dodge).*

```html
<!-- COMBAT ACTION CLUSTER (Authoritative hud_combat_metadata.json Alignment) -->
<div id="combat-cluster" class="absolute right-0 bottom-0 safe-right safe-bottom p-3 z-20 flex items-end justify-end pointer-events-none select-none">
  <div class="relative w-56 h-56 pointer-events-auto">
    <!-- Potion Button -->
    <button id="skill-potion" class="absolute top-0 right-14 w-8 h-8 rounded-full bg-stone-900 border border-stone-700 text-red-400 flex items-center justify-center text-[10px] shadow active:scale-90 transition">
      <span>🧪</span><span class="absolute -top-1 -right-1 text-[8px] font-mono text-stone-300 bg-stone-950 px-1 rounded-full border border-stone-800">5</span>
    </button>
    <!-- Skill Slots Q, W, E, R -->
    <button id="skill-slot-1" class="absolute top-6 left-2 w-13 h-13 rounded-full bg-stone-950 border border-stone-800 text-stone-200 flex flex-col items-center justify-center shadow-lg active:scale-90 transition overflow-hidden">
      <div class="skill-cd-sweep absolute inset-0 pointer-events-none"></div>
      <span class="skill-cd-text absolute inset-0 flex items-center justify-center font-mono font-bold text-[10px] text-amber-300"></span>
      <span class="text-sm">🔥</span><span class="text-[7.5px] font-mono text-stone-400">Q</span>
    </button>
    <button id="skill-slot-2" class="absolute top-0 left-16 w-13 h-13 rounded-full bg-stone-950 border border-stone-800 text-stone-200 flex flex-col items-center justify-center shadow-lg active:scale-90 transition overflow-hidden">
      <div class="skill-cd-sweep absolute inset-0 pointer-events-none"></div>
      <span class="skill-cd-text absolute inset-0 flex items-center justify-center font-mono font-bold text-[10px] text-amber-300"></span>
      <span class="text-sm">⚡</span><span class="text-[7.5px] font-mono text-stone-400">W</span>
    </button>
    <button id="skill-slot-3" class="absolute top-6 right-2 w-13 h-13 rounded-full bg-stone-950 border border-stone-800 text-stone-200 flex flex-col items-center justify-center shadow-lg active:scale-90 transition overflow-hidden">
      <div class="skill-cd-sweep absolute inset-0 pointer-events-none"></div>
      <span class="skill-cd-text absolute inset-0 flex items-center justify-center font-mono font-bold text-[10px] text-amber-300"></span>
      <span class="text-sm">❄️</span><span class="text-[7.5px] font-mono text-stone-400">E</span>
    </button>
    <button id="skill-slot-4" class="absolute top-20 left-0 w-12 h-12 rounded-full bg-stone-950 border border-stone-800 text-stone-200 flex flex-col items-center justify-center shadow-lg active:scale-90 transition overflow-hidden">
      <div class="skill-cd-sweep absolute inset-0 pointer-events-none"></div>
      <span class="skill-cd-text absolute inset-0 flex items-center justify-center font-mono font-bold text-[10px] text-amber-300"></span>
      <span class="text-sm">☣️</span><span class="text-[7.5px] font-mono text-stone-400">R</span>
    </button>
    <!-- Primary Attack (100px Center) -->
    <button id="skill-primary" class="absolute bottom-2 right-2 w-22 h-22 rounded-full bg-gradient-to-b from-stone-900 via-zinc-950 to-black border-2 border-amber-700/60 hover:border-amber-500 text-amber-200 flex flex-col items-center justify-center shadow-2xl active:scale-95 transition">
      <span class="text-xl">⚔️</span><span class="text-[9px] font-sans font-bold tracking-wider text-amber-400">TRẢM</span>
    </button>
    <!-- Phantom Dodge (Bottom-Left Flank, 3 Charges) -->
    <button id="skill-dodge" class="absolute bottom-0 left-10 w-16 h-16 rounded-full bg-stone-950 border border-stone-700/90 text-stone-300 flex flex-col items-center justify-center shadow-xl active:scale-90 transition">
      <span class="text-sm">💨</span><span class="text-[8px] font-mono font-bold text-stone-400">NÉ</span>
      <div class="flex items-center gap-0.5 mt-0.5">
        <span class="dodge-charge-pip w-1.5 h-1.5 rounded-full bg-amber-400"></span>
        <span class="dodge-charge-pip w-1.5 h-1.5 rounded-full bg-amber-400"></span>
        <span class="dodge-charge-pip w-1.5 h-1.5 rounded-full bg-amber-400"></span>
      </div>
    </button>
  </div>
</div>
```

---

### 3.4. Clean Wiring into `combat_skills.js`
In `combat_skills.js`:
1. Add `doPrimaryAttack()` implementing basic weapon cleave with `attack_slash` animation and physical damage.
2. Add `doPoison()` implementing poison projectile spread (`u_minh_huyet_doc_cham`).
3. Replace direct inline DOM click bindings with `skillBarController.init()`, while retaining backward compatibility aliases (`window.doFire = doFire; window.doThunder = doThunder; window.doFrost = doFrost; window.doDodge = doDodge; window.handleKey = (k) => skillBarController.bindKeyboard();`).
4. Update canvas click handling: detect if click is within $40\text{px}$ of a monster; if yes, trigger `doPrimaryAttack()` aimed at that monster instead of walking there.

---
## 4. CAVEATS
1. **WASD vs MOBA Movement**: On PC, holding `W` can simultaneously mean "move up" and "cast skill slot 2". The proposed architecture prioritizes `W` for movement if `keys['s']` or other movement keys are engaged, while number key `2` and mouse right-click always reliably cast skill 2.
2. **Touch Screen Browser Gestures**: On iOS Safari, edge swipes can trigger page back/forward. `index.html` includes `touch-none` and `viewport-fit=cover`, preventing gesture conflicts when combined with `setPointerCapture`.
3. **Canvas HiDPR Scaling**: Touch/Pointer coordinates must always subtract `viewport.getBoundingClientRect().left / top` to properly track offsets across variable Retina DPR scalings.

---
## 5. CONCLUSION
The unified dual-platform control scheme resolves all ergonomics and multi-touch shortcomings:
- **PC Experience**: Unifies full keyboard controls (Q, W, E, R, Space, 1–5) with mouse ergonomics (Left-Click target/move, Right-Click secondary cast).
- **Mobile Experience**: Conforms 100% to `hud_combat_metadata.json` with an expansive $100\text{px}$ Primary Attack anchor and radial thumb arc.
- **Robustness**: W3C Pointer Events and $> 0.15$ dead-zone clamping eliminate button-stuck bugs and thumb trembling.
- **Hygiene & Modularity**: Creating `skill_bar_controller.js` ($< 200$ lines) cleanly offloads UI and cooldown sweeps from `combat_skills.js`, bringing both files well within the $\le 350$ lines Soft Cap.

---

## 6. VERIFICATION METHOD & TEST RECOMMENDATIONS

### 6.1. Unit Test Suite Recommendation (`tests/unit/test_controls_ergonomics_and_skill_bar.py`)
Create a new unit test suite containing:
1. `test_pointer_event_and_deadzone_clamping`: Assert `DEAD_ZONE` exists, $> 0.15$, and `setPointerCapture` is called.
2. `test_pc_keyboard_and_mouse_bindings`: Assert keys Q, W, E, R, Space, 1–4 are mapped in `skill_bar_controller.js`, and `contextmenu` is prevented on canvas.
3. `test_mobile_action_cluster_matches_metadata`: Assert DOM IDs `#skill-primary`, `#skill-slot-1`, `#skill-slot-2`, `#skill-slot-3`, `#skill-slot-4`, `#skill-dodge` exist in `index.html`.
4. `test_cooldown_radial_sweep_markup`: Assert `.skill-cd-sweep` and `.skill-cd-text` exist inside each skill button.
5. `test_file_hygiene_caps`: Assert `skill_bar_controller.js` $\le 350$ lines, `joystick.js` $\le 350$ lines, and `index.html` $\le 200$ lines.

### 6.2. Independent Verification Commands
```bash
pytest tests/unit/test_character_animation_and_skills_vfx.py -v
pytest tests/unit/test_controls_ergonomics_and_skill_bar.py -v
python tools/lint/check_code_and_doc_hygiene.py --strict
```
