Add comprehensive tests for the InventoryManager and InventoryContainer to validate item management functionalities. Implement integration tests for the CharacterSheet component, ensuring proper interaction with the inventory system. Update the Explorer class to support new inventory features and maintain backward compatibility. Refactor related components for improved clarity and performance.
631 lines
15 KiB
JavaScript
631 lines
15 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;
|
|
}
|
|
|
|
.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 {
|
|
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();
|
|
}
|
|
}
|
|
|
|
_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">
|
|
${activeUnit?.skills?.map(
|
|
(skill, index) => html`
|
|
<button
|
|
class="skill-button"
|
|
?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);
|