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"); + }); + }); +}); +