aether-shards/src/ui/combat-hud.js
Matthew Mone 178389309d Add skill unlocking and movement functionality to combat system
Implement event handling for skill unlocking to refresh the combat HUD. Introduce movement mode activation via hotkey during combat, allowing players to switch between skill targeting and movement. Enhance the GameLoop to manage skill usage and cooldowns effectively, and update the CombatHUD with new UI elements for movement actions. Ensure proper integration with existing game state management for a seamless user experience.
2025-12-27 17:21:31 -08:00

702 lines
17 KiB
JavaScript

import { LitElement, html, css } from "lit";
export class CombatHUD extends LitElement {
static get styles() {
return css`
:host {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
font-family: "Courier New", monospace;
color: white;
z-index: 1000;
}
/* Top Bar */
.top-bar {
position: absolute;
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);
position: relative;
pointer-events: auto;
}
.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;
}
/* Bottom Bar */
.bottom-bar {
position: absolute;
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;
}
/* 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: 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;
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;
color: white;
}
.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.active {
background: rgba(255, 215, 0, 0.2);
border-color: #ffd700;
box-shadow: 0 0 15px rgba(255, 215, 0, 0.6);
}
.movement-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;
color: white;
}
.movement-button:hover {
border-color: #66ff66;
box-shadow: 0 0 10px rgba(102, 255, 102, 0.5);
transform: translateY(-2px);
}
.movement-button.active {
background: rgba(102, 255, 102, 0.2);
border-color: #66ff66;
box-shadow: 0 0 15px rgba(102, 255, 102, 0.6);
}
.movement-button .icon {
font-size: 1.5rem;
margin-top: 8px;
}
.movement-button .name {
font-size: 0.7rem;
text-align: center;
padding: 0 4px;
}
.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;
color: white;
}
.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 {
combatState: { type: Object },
};
}
constructor() {
super();
this.combatState = null;
}
_handleSkillClick(skillId) {
this.dispatchEvent(
new CustomEvent("skill-click", {
detail: { skillId },
bubbles: true,
composed: true,
})
);
}
_handleEndTurn(event) {
this.dispatchEvent(
new CustomEvent("end-turn", {
bubbles: true,
composed: true,
})
);
// Blur the button to prevent it from retaining focus
// This prevents spacebar from triggering it when moving units
if (event && event.target) {
event.target.blur();
}
}
_handleMovementClick() {
this.dispatchEvent(
new CustomEvent("movement-click", {
bubbles: true,
composed: true,
})
);
}
_handleSkillHover(skillId) {
this.dispatchEvent(
new CustomEvent("hover-skill", {
detail: { skillId },
bubbles: true,
composed: true,
})
);
}
_handlePortraitClick(unit) {
// Dispatch open-character-sheet event with unit ID
// GameLoop will resolve to full unit object
if (unit && unit.unitId) {
window.dispatchEvent(
new CustomEvent("open-character-sheet", {
detail: {
unitId: unit.unitId,
readOnly: false,
},
})
);
} else if (unit) {
// If unit object is provided directly, use it
window.dispatchEvent(
new CustomEvent("open-character-sheet", {
detail: {
unit: unit,
readOnly: false,
},
})
);
}
}
_getThreatLevel() {
if (!this.combatState) return "low";
const queue =
this.combatState.enrichedQueue || this.combatState.turnQueue || [];
// If turnQueue is string[], we can't filter by team, so use enrichedQueue
const enemyCount =
Array.isArray(queue) && queue.length > 0 && typeof queue[0] === "object"
? queue.filter((entry) => entry.team === "ENEMY").length
: 0; // Fallback if we only have string IDs
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`
<div class="bar-container">
<div class="bar-label">
<span>${label}</span>
<span>${current}/${max}</span>
</div>
<div class="bar">
<div class="bar-fill ${type}" style="width: ${percentage}%"></div>
</div>
</div>
`;
}
render() {
if (!this.combatState) {
return html``;
}
const { activeUnit, enrichedQueue, turnQueue, roundNumber, round } =
this.combatState;
// Use enrichedQueue if available (for UI), otherwise fall back to turnQueue
const displayQueue = enrichedQueue || turnQueue || [];
const threatLevel = this._getThreatLevel();
return html`
<!-- Top Bar -->
<div class="top-bar">
<!-- Turn Queue (Center-Left) -->
<div class="turn-queue">
${displayQueue?.map(
(entry, index) => html`
<div
class="queue-portrait ${entry.team.toLowerCase()} ${index === 0
? "active"
: ""}"
>
<img src="${entry.portrait}" alt="${entry.unitId}" />
${index === 0 && entry.team === "ENEMY"
? html`<div class="enemy-intent">⚔</div>`
: ""}
</div>
`
) || html``}
</div>
<!-- Global Info (Top-Right) -->
<div class="global-info">
<div class="round-counter">Round ${round || roundNumber || 1}</div>
<div class="threat-level ${threatLevel}">
${threatLevel.toUpperCase()}
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="bottom-bar">
<!-- Unit Status (Bottom-Left) -->
${activeUnit
? html`
<div class="unit-status">
<div
class="unit-portrait"
@click="${() => this._handlePortraitClick(activeUnit)}"
style="cursor: pointer;"
title="Click to view character sheet (C)"
>
<img src="${activeUnit.portrait}" alt="${activeUnit.name}" />
</div>
<div class="unit-name">${activeUnit.name}</div>
${activeUnit.statuses?.length > 0
? html`
<div class="status-icons">
${activeUnit.statuses.map(
(status) => html`
<div
class="status-icon"
data-description="${status.description} (${status.turnsRemaining} turns)"
title="${status.description}"
>
${status.icon}
</div>
`
)}
</div>
`
: ""}
${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")}
</div>
`
: html``}
<!-- Action Bar (Bottom-Center) -->
<div class="action-bar">
<!-- Movement Button -->
<button
class="movement-button ${!this.combatState?.targetingMode
? "active"
: ""}"
@click="${this._handleMovementClick}"
title="Movement Mode (M)"
>
<span class="icon">🚶</span>
<span class="name">Move</span>
</button>
${activeUnit?.skills?.map(
(skill, index) => html`
<button
class="skill-button ${this.combatState?.targetingMode &&
this.combatState?.activeSkillId === skill.id
? "active"
: ""}"
?disabled="${!skill.isAvailable}"
@click="${() => this._handleSkillClick(skill.id)}"
@mouseenter="${() => this._handleSkillHover(skill.id)}"
title="${skill.name} - ${skill.costAP} AP${skill.cooldown > 0
? ` (CD: ${skill.cooldown})`
: ""}"
>
<span class="hotkey">${index + 1}</span>
<span class="icon">${skill.icon}</span>
<span class="name">${skill.name}</span>
<span class="cost">${skill.costAP}AP</span>
${skill.cooldown > 0
? html`<div class="cooldown">${skill.cooldown}</div>`
: ""}
</button>
`
) || html``}
</div>
<!-- End Turn Button (Bottom-Right) -->
<button class="end-turn-button" @click="${this._handleEndTurn}">
END TURN
</button>
</div>
`;
}
}
customElements.define("combat-hud", CombatHUD);