From 095bd778fd054f77cd94964bca7c29dfe5aba91e Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Mon, 22 Dec 2025 12:57:13 -0800 Subject: [PATCH] Add CombatHUD component with TypeScript interfaces for combat state management. Implement UI elements for turn queue, unit status, and action bar, including responsive design for mobile. Add event handling for skill selection and turn management. Include comprehensive tests for UI functionality and state updates. --- src/ui/combat-hud.d.ts | 54 ++++ src/ui/combat-hud.js | 567 ++++++++++++++++++++++++++++++++++--- src/ui/combat-hud.spec.js | 113 ++++++++ test/ui/combat-hud.test.js | 541 +++++++++++++++++++++++++++++++++++ 4 files changed, 1236 insertions(+), 39 deletions(-) create mode 100644 src/ui/combat-hud.d.ts create mode 100644 src/ui/combat-hud.spec.js create mode 100644 test/ui/combat-hud.test.js diff --git a/src/ui/combat-hud.d.ts b/src/ui/combat-hud.d.ts new file mode 100644 index 0000000..db8f4f7 --- /dev/null +++ b/src/ui/combat-hud.d.ts @@ -0,0 +1,54 @@ +export interface CombatState { + /** The unit currently taking their turn */ + activeUnit: UnitStatus | null; + + /** Sorted list of units acting next */ + turnQueue: QueueEntry[]; + + /** Is the player currently targeting a skill? */ + targetingMode: boolean; + + /** Global combat info */ + roundNumber: number; +} + +export interface UnitStatus { + id: string; + name: string; + portrait: string; + hp: { current: number; max: number }; + ap: { current: number; max: number }; + charge: number; // 0-100 + statuses: StatusIcon[]; + skills: SkillButton[]; +} + +export interface QueueEntry { + unitId: string; + portrait: string; + team: "PLAYER" | "ENEMY"; + /** 0-100 progress to next turn */ + initiative: number; +} + +export interface StatusIcon { + id: string; + icon: string; // URL or Emoji + turnsRemaining: number; + description: string; +} + +export interface SkillButton { + id: string; + name: string; + icon: string; + costAP: number; + cooldown: number; // 0 = Ready + isAvailable: boolean; // True if affordable and ready +} + +export interface CombatEvents { + "skill-click": { skillId: string }; + "end-turn": void; + "hover-skill": { skillId: string }; // For showing range grid +} diff --git a/src/ui/combat-hud.js b/src/ui/combat-hud.js index e39a4ad..7b56e48 100644 --- a/src/ui/combat-hud.js +++ b/src/ui/combat-hud.js @@ -13,85 +13,574 @@ export class CombatHUD extends LitElement { pointer-events: none; font-family: "Courier New", monospace; color: white; + z-index: 1000; } - .header { + /* Top Bar */ + .top-bar { position: absolute; - top: 20px; - left: 50%; - transform: translateX(-50%); + top: 0; + left: 0; + right: 0; + height: 120px; + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0.9) 0%, + rgba(0, 0, 0, 0.7) 80%, + transparent 100% + ); + pointer-events: auto; + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 30px; + } + + .turn-queue { + display: flex; + align-items: center; + gap: 15px; + flex: 1; + } + + .queue-portrait { + width: 60px; + height: 60px; + border-radius: 50%; + border: 2px solid #666; + overflow: hidden; background: rgba(0, 0, 0, 0.8); - border: 2px solid #ff0000; - padding: 15px 30px; - text-align: center; + position: relative; pointer-events: auto; } - .status-bar { - margin-top: 5px; - font-size: 1.2rem; + .queue-portrait.active { + width: 80px; + height: 80px; + border: 3px solid #ffd700; + box-shadow: 0 0 10px rgba(255, 215, 0, 0.5); + } + + .queue-portrait img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .queue-portrait.enemy { + border-color: #ff6666; + } + + .queue-portrait.player { + border-color: #66ff66; + } + + .enemy-intent { + position: absolute; + bottom: -5px; + right: -5px; + width: 20px; + height: 20px; + background: rgba(0, 0, 0, 0.9); + border: 1px solid #ff6666; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + } + + .global-info { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + background: rgba(0, 0, 0, 0.8); + border: 2px solid #555; + padding: 10px 20px; + pointer-events: auto; + } + + .round-counter { + font-size: 1.1rem; + font-weight: bold; + } + + .threat-level { + font-size: 0.9rem; + padding: 2px 8px; + border-radius: 3px; + } + + .threat-level.low { + background: rgba(0, 255, 0, 0.3); + color: #66ff66; + } + + .threat-level.medium { + background: rgba(255, 255, 0, 0.3); + color: #ffff66; + } + + .threat-level.high { + background: rgba(255, 0, 0, 0.3); color: #ff6666; } - .turn-indicator { + /* Bottom Bar */ + .bottom-bar { position: absolute; - top: 100px; - left: 30px; - background: rgba(0, 0, 0, 0.8); - border: 2px solid #ff0000; - padding: 10px 20px; - font-size: 1rem; + bottom: 0; + left: 0; + right: 0; + height: 180px; + background: linear-gradient( + to top, + rgba(0, 0, 0, 0.9) 0%, + rgba(0, 0, 0, 0.7) 80%, + transparent 100% + ); + pointer-events: auto; + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 20px 30px; } - .instructions { + /* Unit Status (Bottom-Left) */ + .unit-status { + display: flex; + flex-direction: column; + gap: 10px; + background: rgba(0, 0, 0, 0.8); + border: 2px solid #555; + padding: 15px; + min-width: 200px; + pointer-events: auto; + } + + .unit-portrait { + width: 120px; + height: 120px; + border: 2px solid #666; + overflow: hidden; + background: rgba(0, 0, 0, 0.9); + margin: 0 auto; + } + + .unit-portrait img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .unit-name { + text-align: center; + font-size: 1rem; + font-weight: bold; + margin-top: 5px; + } + + .status-icons { + display: flex; + gap: 5px; + justify-content: center; + flex-wrap: wrap; + margin-top: 5px; + } + + .status-icon { + width: 24px; + height: 24px; + border: 1px solid #555; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + background: rgba(0, 0, 0, 0.7); + cursor: help; + position: relative; + } + + .status-icon:hover::after { + content: attr(data-description); position: absolute; - bottom: 30px; + bottom: 100%; left: 50%; transform: translateX(-50%); + background: rgba(0, 0, 0, 0.95); + border: 1px solid #555; + padding: 5px 10px; + white-space: nowrap; + font-size: 0.8rem; + margin-bottom: 5px; + pointer-events: none; + } + + .bar-container { + display: flex; + flex-direction: column; + gap: 5px; + margin-top: 10px; + } + + .bar-label { + font-size: 0.8rem; + display: flex; + justify-content: space-between; + } + + .bar { + height: 20px; background: rgba(0, 0, 0, 0.7); border: 1px solid #555; - padding: 10px 20px; - font-size: 0.9rem; - color: #ccc; + position: relative; + overflow: hidden; + } + + .bar-fill { + height: 100%; + transition: width 0.3s ease; + } + + .bar-fill.hp { + background: #ff0000; + } + + .bar-fill.ap { + background: #ffaa00; + } + + .bar-fill.charge { + background: #0066ff; + } + + /* Action Bar (Bottom-Center) */ + .action-bar { + display: flex; + gap: 10px; + align-items: center; + pointer-events: auto; + } + + .skill-button { + width: 70px; + height: 70px; + background: rgba(0, 0, 0, 0.8); + border: 2px solid #666; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + position: relative; + transition: all 0.2s; + pointer-events: auto; + } + + .skill-button:hover:not(:disabled) { + border-color: #ffd700; + box-shadow: 0 0 10px rgba(255, 215, 0, 0.5); + transform: translateY(-2px); + } + + .skill-button:disabled { + opacity: 0.5; + cursor: not-allowed; + border-color: #333; + } + + .skill-button .hotkey { + position: absolute; + top: 2px; + left: 2px; + font-size: 0.7rem; + background: rgba(0, 0, 0, 0.8); + padding: 2px 4px; + border: 1px solid #555; + } + + .skill-button .icon { + font-size: 1.5rem; + margin-top: 8px; + } + + .skill-button .name { + font-size: 0.7rem; text-align: center; + padding: 0 4px; + } + + .skill-button .cost { + position: absolute; + bottom: 2px; + right: 2px; + font-size: 0.7rem; + color: #ffaa00; + } + + .skill-button .cooldown { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: bold; + } + + /* End Turn Button (Bottom-Right) */ + .end-turn-button { + background: rgba(0, 0, 0, 0.8); + border: 2px solid #ff6666; + padding: 15px 30px; + font-size: 1.1rem; + font-weight: bold; + color: white; + cursor: pointer; + transition: all 0.2s; + pointer-events: auto; + font-family: "Courier New", monospace; + } + + .end-turn-button:hover { + background: rgba(255, 102, 102, 0.2); + box-shadow: 0 0 15px rgba(255, 102, 102, 0.5); + transform: translateY(-2px); + } + + .end-turn-button:active { + transform: translateY(0); + } + + /* Responsive Design - Mobile (< 768px) */ + @media (max-width: 767px) { + .bottom-bar { + flex-direction: column; + align-items: stretch; + gap: 15px; + height: auto; + min-height: 180px; + } + + .unit-status { + width: 100%; + min-width: auto; + } + + .action-bar { + justify-content: center; + flex-wrap: wrap; + } + + .end-turn-button { + width: 100%; + margin-top: 10px; + } + + .top-bar { + flex-direction: column; + height: auto; + min-height: 120px; + gap: 10px; + } + + .turn-queue { + justify-content: center; + flex-wrap: wrap; + } + + .global-info { + align-items: center; + width: 100%; + } } `; } static get properties() { return { - currentState: { type: String }, - currentTurn: { type: String }, + combatState: { type: Object }, }; } constructor() { super(); - this.currentState = null; - this.currentTurn = "PLAYER"; - window.addEventListener("gamestate-changed", (e) => { - this.currentState = e.detail.newState; - }); + this.combatState = null; + } + + _handleSkillClick(skillId) { + this.dispatchEvent( + new CustomEvent("skill-click", { + detail: { skillId }, + bubbles: true, + composed: true, + }) + ); + } + + _handleEndTurn() { + this.dispatchEvent( + new CustomEvent("end-turn", { + bubbles: true, + composed: true, + }) + ); + } + + _handleSkillHover(skillId) { + this.dispatchEvent( + new CustomEvent("hover-skill", { + detail: { skillId }, + bubbles: true, + composed: true, + }) + ); + } + + _getThreatLevel() { + if (!this.combatState?.turnQueue) return "low"; + const enemyCount = this.combatState.turnQueue.filter( + (entry) => entry.team === "ENEMY" + ).length; + if (enemyCount >= 3) return "high"; + if (enemyCount >= 2) return "medium"; + return "low"; + } + + _renderBar(label, current, max, type) { + const percentage = max > 0 ? (current / max) * 100 : 0; + return html` +
+
+ ${label} + ${current}/${max} +
+
+
+
+
+ `; } render() { - // Only show during COMBAT state - if (this.currentState !== "STATE_COMBAT") { + if (!this.combatState) { return html``; } + const { activeUnit, turnQueue, roundNumber } = this.combatState; + const threatLevel = this._getThreatLevel(); + return html` -
-

COMBAT ACTIVE

-
Turn: ${this.currentTurn}
+ +
+ +
+ ${turnQueue?.map( + (entry, index) => html` +
+ ${entry.unitId} + ${index === 0 && entry.team === "ENEMY" + ? html`
` + : ""} +
+ ` + ) || html``} +
+ + +
+
Round ${roundNumber || 1}
+
+ ${threatLevel.toUpperCase()} +
+
-
-
State: ${this.currentState}
-
+ +
+ + ${activeUnit + ? html` +
+
+ ${activeUnit.name} +
+
${activeUnit.name}
-
- Use WASD or Arrow Keys to move cursor | SPACE/ENTER to select + ${activeUnit.statuses?.length > 0 + ? html` +
+ ${activeUnit.statuses.map( + (status) => html` +
+ ${status.icon} +
+ ` + )} +
+ ` + : ""} + ${this._renderBar( + "HP", + activeUnit.hp.current, + activeUnit.hp.max, + "hp" + )} + ${this._renderBar( + "AP", + activeUnit.ap.current, + activeUnit.ap.max, + "ap" + )} + ${this._renderBar("Charge", activeUnit.charge, 100, "charge")} +
+ ` + : html``} + + +
+ ${activeUnit?.skills?.map( + (skill, index) => html` + + ` + ) || html``} +
+ + +
`; } diff --git a/src/ui/combat-hud.spec.js b/src/ui/combat-hud.spec.js new file mode 100644 index 0000000..1170925 --- /dev/null +++ b/src/ui/combat-hud.spec.js @@ -0,0 +1,113 @@ +# Combat HUD Specification: The Tactical Interface + +This document defines the UI overlay active during the `GAME_RUN` / `ACTIVE` phase. It communicates turn order, unit status, and available actions to the player. + +## 1. Visual Description + +**Layout:** A "Letterbox" style overlay that leaves the center of the screen clear for the 3D action. + +### A. Top Bar (Turn & Status) + +* **Turn Queue (Center-Left):** A horizontal list of circular portraits. + + * *Active Unit:* Larger, highlighted with a gold border on the far left. + + * *Next Units:* Smaller icons trailing to the right. + + * *Enemy Intent:* If an enemy is active, a small icon (Sword/Shield) indicates their planned action type. + +* **Global Info (Top-Right):** + + * *Round Counter:* "Round 3" + + * *Threat Level:* "High" (Color coded). + +### B. Bottom Bar (The Dashboard) + +* **Unit Status (Bottom-Left):** + + * *Portrait:* Large 2D art of the active unit. + + * *Bars:* Health (Red), Action Points (Yellow), Charge (Blue). + + * *Buffs:* Small icons row above the bars. + +* **Action Bar (Bottom-Center):** + + * A row of interactive buttons for Skills and Items. + + * *Hotkeys:* (1-5) displayed on the buttons. + + * *State:* Buttons go grey if AP is insufficient or Cooldown is active. + + * *Tooltip:* Hovering shows damage, range, and AP cost. + +* **End Turn (Bottom-Right):** + + * A prominent button to manually end the turn early (saving AP or Charge). + +### C. Floating Elements (World Space) + +* **Damage Numbers:** Pop up over units when hit. + +* **Health Bars:** Small bars hovering over every unit in the 3D scene (billboarded). + +## 2. TypeScript Interfaces (Data Model) + +```typescript +// src/types/CombatHUD.ts + +export interface CombatState { + /** The unit currently taking their turn */ + activeUnit: UnitStatus | null; + + /** Sorted list of units acting next */ + turnQueue: QueueEntry[]; + + /** Is the player currently targeting a skill? */ + targetingMode: boolean; + + /** Global combat info */ + roundNumber: number; +} + +export interface UnitStatus { + id: string; + name: string; + portrait: string; + hp: { current: number; max: number }; + ap: { current: number; max: number }; + charge: number; // 0-100 + statuses: StatusIcon[]; + skills: SkillButton[]; +} + +export interface QueueEntry { + unitId: string; + portrait: string; + team: 'PLAYER' | 'ENEMY'; + /** 0-100 progress to next turn */ + initiative: number; +} + +export interface StatusIcon { + id: string; + icon: string; // URL or Emoji + turnsRemaining: number; + description: string; +} + +export interface SkillButton { + id: string; + name: string; + icon: string; + costAP: number; + cooldown: number; // 0 = Ready + isAvailable: boolean; // True if affordable and ready +} + +export interface CombatEvents { + 'skill-click': { skillId: string }; + 'end-turn': void; + 'hover-skill': { skillId: string }; // For showing range grid +} \ No newline at end of file diff --git a/test/ui/combat-hud.test.js b/test/ui/combat-hud.test.js new file mode 100644 index 0000000..99533b9 --- /dev/null +++ b/test/ui/combat-hud.test.js @@ -0,0 +1,541 @@ +import { expect } from "@esm-bundle/chai"; +import { CombatHUD } from "../../src/ui/combat-hud.js"; + +describe("UI: CombatHUD", () => { + let element; + let container; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + element = document.createElement("combat-hud"); + container.appendChild(element); + }); + + afterEach(() => { + if (container.parentNode) { + container.parentNode.removeChild(container); + } + }); + + // Helper to create mock combat state + function createMockCombatState(overrides = {}) { + return { + activeUnit: { + id: "unit1", + name: "Test Unit", + portrait: "/test/portrait.png", + hp: { current: 80, max: 100 }, + ap: { current: 5, max: 10 }, + charge: 50, + statuses: [ + { + id: "buff1", + icon: "⚡", + turnsRemaining: 2, + description: "Energized", + }, + ], + skills: [ + { + id: "skill1", + name: "Attack", + icon: "⚔", + costAP: 3, + cooldown: 0, + isAvailable: true, + }, + { + id: "skill2", + name: "Heal", + icon: "💚", + costAP: 5, + cooldown: 0, + isAvailable: true, + }, + ], + }, + turnQueue: [ + { + unitId: "unit1", + portrait: "/test/portrait1.png", + team: "PLAYER", + initiative: 100, + }, + { + unitId: "unit2", + portrait: "/test/portrait2.png", + team: "ENEMY", + initiative: 80, + }, + { + unitId: "unit3", + portrait: "/test/portrait3.png", + team: "PLAYER", + initiative: 60, + }, + { + unitId: "unit4", + portrait: "/test/portrait4.png", + team: "ENEMY", + initiative: 40, + }, + { + unitId: "unit5", + portrait: "/test/portrait5.png", + team: "PLAYER", + initiative: 20, + }, + ], + targetingMode: false, + roundNumber: 1, + ...overrides, + }; + } + + // Helper to wait for Lit updates + async function waitForUpdate() { + await element.updateComplete; + // Give DOM a moment to settle + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + // Helper to query shadow DOM + function queryShadow(selector) { + return element.shadowRoot?.querySelector(selector); + } + + function queryShadowAll(selector) { + return element.shadowRoot?.querySelectorAll(selector) || []; + } + + describe("CoA 1: State Hydration", () => { + it("should re-render when combatState is updated", async () => { + const initialState = createMockCombatState(); + element.combatState = initialState; + await waitForUpdate(); + + // Verify initial render + expect(queryShadow(".unit-name")?.textContent.trim()).to.equal( + "Test Unit" + ); + + // Update combat state + const updatedState = createMockCombatState({ + activeUnit: { + ...initialState.activeUnit, + name: "Updated Unit", + }, + }); + element.combatState = updatedState; + await waitForUpdate(); + + // Verify re-render + expect(queryShadow(".unit-name")?.textContent.trim()).to.equal( + "Updated Unit" + ); + }); + + it("should hide Action Bar when activeUnit is null", async () => { + const state = createMockCombatState({ + activeUnit: null, + }); + element.combatState = state; + await waitForUpdate(); + + // Action bar should not be visible or should be empty + const actionBar = queryShadow(".action-bar"); + const skillButtons = queryShadowAll(".skill-button"); + + // Either action bar doesn't exist or has no skill buttons + expect(skillButtons.length).to.equal(0); + }); + + it("should hide Unit Status when activeUnit is null", async () => { + const state = createMockCombatState({ + activeUnit: null, + }); + element.combatState = state; + await waitForUpdate(); + + // Unit status should not be visible + const unitStatus = queryShadow(".unit-status"); + expect(unitStatus).to.be.null; + }); + }); + + describe("CoA 2: Turn Queue Visualization", () => { + it("should display at least 5 units in the queue", async () => { + const state = createMockCombatState(); + element.combatState = state; + await waitForUpdate(); + + const queuePortraits = queryShadowAll(".queue-portrait"); + expect(queuePortraits.length).to.be.at.least(5); + }); + + it("should make the first unit visually distinct (larger and gold border)", async () => { + const state = createMockCombatState(); + element.combatState = state; + await waitForUpdate(); + + const queuePortraits = queryShadowAll(".queue-portrait"); + expect(queuePortraits.length).to.be.greaterThan(0); + + const firstPortrait = queuePortraits[0]; + expect(firstPortrait.classList.contains("active")).to.be.true; + + // Check computed styles for size difference + const firstRect = firstPortrait.getBoundingClientRect(); + if (queuePortraits.length > 1) { + const secondRect = queuePortraits[1].getBoundingClientRect(); + // First should be larger + expect(firstRect.width).to.be.greaterThan(secondRect.width); + expect(firstRect.height).to.be.greaterThan(secondRect.height); + } + + // Check for gold border class + const styles = window.getComputedStyle(firstPortrait); + // The active class should be present, which applies gold border via CSS + expect(firstPortrait.classList.contains("active")).to.be.true; + }); + + it("should show enemy intent icon when enemy is active", async () => { + const state = createMockCombatState({ + turnQueue: [ + { + unitId: "enemy1", + portrait: "/test/enemy.png", + team: "ENEMY", + initiative: 100, + }, + ], + }); + element.combatState = state; + await waitForUpdate(); + + const enemyIntent = queryShadow(".enemy-intent"); + // Enemy intent should be visible when first unit is enemy + const firstPortrait = queryShadow(".queue-portrait.active"); + if (firstPortrait) { + const intent = firstPortrait.querySelector(".enemy-intent"); + // Intent icon should exist for enemy active unit + expect(intent).to.exist; + } + }); + }); + + describe("CoA 3: Action Bar Logic", () => { + it("should display AP cost on skill buttons", async () => { + const state = createMockCombatState(); + element.combatState = state; + await waitForUpdate(); + + const skillButtons = queryShadowAll(".skill-button"); + expect(skillButtons.length).to.be.greaterThan(0); + + // Check first skill button has AP cost displayed + const firstButton = skillButtons[0]; + const costElement = firstButton.querySelector(".cost"); + expect(costElement).to.exist; + expect(costElement.textContent).to.include("AP"); + }); + + it("should disable skill button when unit.ap.current < skill.costAP", async () => { + const state = createMockCombatState({ + activeUnit: { + id: "unit1", + name: "Low AP Unit", + portrait: "/test/portrait.png", + hp: { current: 100, max: 100 }, + ap: { current: 2, max: 10 }, // Only 2 AP + charge: 0, + statuses: [], + skills: [ + { + id: "skill1", + name: "Expensive Skill", + icon: "⚔", + costAP: 5, // Costs 5 AP, but unit only has 2 + cooldown: 0, + isAvailable: false, // Should be false due to insufficient AP + }, + { + id: "skill2", + name: "Cheap Skill", + icon: "💚", + costAP: 1, // Costs 1 AP, unit has 2 + cooldown: 0, + isAvailable: true, + }, + ], + }, + }); + element.combatState = state; + await waitForUpdate(); + + const skillButtons = queryShadowAll(".skill-button"); + expect(skillButtons.length).to.equal(2); + + // First skill (expensive) should be disabled + const expensiveButton = Array.from(skillButtons).find((btn) => + btn.textContent.includes("Expensive") + ); + expect(expensiveButton).to.exist; + expect(expensiveButton.disabled).to.be.true; + expect(expensiveButton.classList.contains("disabled") || + expensiveButton.hasAttribute("disabled")).to.be.true; + + // Second skill (cheap) should be enabled + const cheapButton = Array.from(skillButtons).find((btn) => + btn.textContent.includes("Cheap") + ); + expect(cheapButton).to.exist; + expect(cheapButton.disabled).to.be.false; + }); + + it("should dispatch skill-click event with correct ID when skill button is clicked", async () => { + const state = createMockCombatState(); + element.combatState = state; + await waitForUpdate(); + + let capturedEvent = null; + element.addEventListener("skill-click", (e) => { + capturedEvent = e; + }); + + const skillButtons = queryShadowAll(".skill-button"); + expect(skillButtons.length).to.be.greaterThan(0); + + const firstButton = skillButtons[0]; + firstButton.click(); + + await waitForUpdate(); + + expect(capturedEvent).to.exist; + expect(capturedEvent.detail.skillId).to.equal("skill1"); + }); + + it("should disable skill button when isAvailable is false", async () => { + const state = createMockCombatState({ + activeUnit: { + id: "unit1", + name: "Test Unit", + portrait: "/test/portrait.png", + hp: { current: 100, max: 100 }, + ap: { current: 10, max: 10 }, + charge: 0, + statuses: [], + skills: [ + { + id: "skill1", + name: "On Cooldown", + icon: "⚔", + costAP: 3, + cooldown: 2, // On cooldown + isAvailable: false, + }, + ], + }, + }); + element.combatState = state; + await waitForUpdate(); + + const skillButtons = queryShadowAll(".skill-button"); + expect(skillButtons.length).to.equal(1); + + const button = skillButtons[0]; + expect(button.disabled).to.be.true; + }); + + it("should display cooldown number when skill is on cooldown", async () => { + const state = createMockCombatState({ + activeUnit: { + id: "unit1", + name: "Test Unit", + portrait: "/test/portrait.png", + hp: { current: 100, max: 100 }, + ap: { current: 10, max: 10 }, + charge: 0, + statuses: [], + skills: [ + { + id: "skill1", + name: "On Cooldown", + icon: "⚔", + costAP: 3, + cooldown: 3, + isAvailable: false, + }, + ], + }, + }); + element.combatState = state; + await waitForUpdate(); + + const skillButton = queryShadow(".skill-button"); + const cooldownElement = skillButton?.querySelector(".cooldown"); + expect(cooldownElement).to.exist; + expect(cooldownElement.textContent.trim()).to.equal("3"); + }); + }); + + describe("CoA 4: Responsive Design", () => { + it("should stack Unit Status and Action Bar vertically on mobile (< 768px)", async () => { + const state = createMockCombatState(); + element.combatState = state; + await waitForUpdate(); + + // Simulate mobile viewport + const originalWidth = window.innerWidth; + const originalHeight = window.innerHeight; + + // Mock window.innerWidth to be mobile size + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 600, + }); + + // Trigger a resize or re-render + element.combatState = { ...state }; + await waitForUpdate(); + + const bottomBar = queryShadow(".bottom-bar"); + expect(bottomBar).to.exist; + + // Check if flex-direction is column on mobile + // Note: We can't easily test media queries in unit tests without more setup + // But we can verify the structure exists and can be styled responsively + const unitStatus = queryShadow(".unit-status"); + const actionBar = queryShadow(".action-bar"); + + expect(unitStatus).to.exist; + expect(actionBar).to.exist; + + // Restore original width + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: originalWidth, + }); + }); + }); + + describe("Additional Functionality", () => { + it("should dispatch end-turn event when End Turn button is clicked", async () => { + const state = createMockCombatState(); + element.combatState = state; + await waitForUpdate(); + + let eventDispatched = false; + element.addEventListener("end-turn", () => { + eventDispatched = true; + }); + + const endTurnButton = queryShadow(".end-turn-button"); + expect(endTurnButton).to.exist; + + endTurnButton.click(); + await waitForUpdate(); + + expect(eventDispatched).to.be.true; + }); + + it("should display round number in global info", async () => { + const state = createMockCombatState({ roundNumber: 5 }); + element.combatState = state; + await waitForUpdate(); + + const roundCounter = queryShadow(".round-counter"); + expect(roundCounter).to.exist; + expect(roundCounter.textContent).to.include("Round 5"); + }); + + it("should display threat level in global info", async () => { + const state = createMockCombatState({ + turnQueue: [ + { unitId: "e1", portrait: "/test/e1.png", team: "ENEMY", initiative: 100 }, + { unitId: "e2", portrait: "/test/e2.png", team: "ENEMY", initiative: 90 }, + { unitId: "e3", portrait: "/test/e3.png", team: "ENEMY", initiative: 80 }, + ], + }); + element.combatState = state; + await waitForUpdate(); + + const threatLevel = queryShadow(".threat-level"); + expect(threatLevel).to.exist; + // With 3+ enemies, should be HIGH + expect(threatLevel.classList.contains("high")).to.be.true; + }); + + it("should display unit HP, AP, and Charge bars", async () => { + const state = createMockCombatState(); + element.combatState = state; + await waitForUpdate(); + + const hpBar = queryShadow(".bar-fill.hp"); + const apBar = queryShadow(".bar-fill.ap"); + const chargeBar = queryShadow(".bar-fill.charge"); + + expect(hpBar).to.exist; + expect(apBar).to.exist; + expect(chargeBar).to.exist; + + // Check that bars have correct width percentages + expect(hpBar.style.width).to.include("%"); + expect(apBar.style.width).to.include("%"); + expect(chargeBar.style.width).to.include("%"); + }); + + it("should display status icons with tooltips", async () => { + const state = createMockCombatState(); + element.combatState = state; + await waitForUpdate(); + + const statusIcons = queryShadowAll(".status-icon"); + expect(statusIcons.length).to.equal(1); + + const statusIcon = statusIcons[0]; + expect(statusIcon.getAttribute("data-description")).to.exist; + expect(statusIcon.textContent).to.include("⚡"); + }); + + it("should display hotkeys (1-5) on skill buttons", async () => { + const state = createMockCombatState(); + element.combatState = state; + await waitForUpdate(); + + const skillButtons = queryShadowAll(".skill-button"); + expect(skillButtons.length).to.be.greaterThan(0); + + skillButtons.forEach((button, index) => { + const hotkey = button.querySelector(".hotkey"); + expect(hotkey).to.exist; + expect(hotkey.textContent.trim()).to.equal(String(index + 1)); + }); + }); + + it("should dispatch hover-skill event when hovering over skill", async () => { + const state = createMockCombatState(); + element.combatState = state; + await waitForUpdate(); + + let capturedEvent = null; + element.addEventListener("hover-skill", (e) => { + capturedEvent = e; + }); + + const skillButton = queryShadow(".skill-button"); + expect(skillButton).to.exist; + + // Simulate mouseenter event + skillButton.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + await waitForUpdate(); + + expect(capturedEvent).to.exist; + expect(capturedEvent.detail.skillId).to.equal("skill1"); + }); + }); +}); +