aether-shards/src/ui/team-builder.js

594 lines
18 KiB
JavaScript
Raw Normal View History

import { LitElement, html, css } from 'lit';
// Import Tier 1 Class Definitions
// Note: This assumes the build environment supports JSON imports (e.g. Import Attributes or a loader)
import vanguardDef from '../assets/data/classes/vanguard.json' with { type: 'json' };
import weaverDef from '../assets/data/classes/aether_weaver.json' with { type: 'json' };
import scavengerDef from '../assets/data/classes/scavenger.json' with { type: 'json' };
import tinkerDef from '../assets/data/classes/tinker.json' with { type: 'json' };
import custodianDef from '../assets/data/classes/custodian.json' with { type: 'json' };
// UI Metadata Mapping (Data not in the raw engine JSONs)
const CLASS_METADATA = {
'CLASS_VANGUARD': {
icon: '🛡️',
image: 'assets/images/portraits/vanguard.png', // Placeholder path
role: 'Tank',
description: 'A heavy frontline tank specialized in absorbing damage and protecting allies.'
},
'CLASS_WEAVER': {
icon: '✨',
image: 'assets/images/portraits/weaver.png',
role: 'Magic DPS',
description: 'A master of elemental magic capable of creating powerful synergy chains.'
},
'CLASS_SCAVENGER': {
icon: '🎒',
image: 'assets/images/portraits/scavenger.png',
role: 'Utility',
description: 'Highly mobile utility expert who excels at finding loot and avoiding traps.'
},
'CLASS_TINKER': {
icon: '🔧',
image: 'assets/images/portraits/tinker.png',
role: 'Tech',
description: 'Uses ancient technology to deploy turrets and control the battlefield.'
},
'CLASS_CUSTODIAN': {
icon: '🌿',
image: 'assets/images/portraits/custodian.png',
role: 'Healer',
description: 'A spiritual healer focused on removing corruption and sustaining the squad.'
}
};
const RAW_TIER_1_CLASSES = [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef];
export class TeamBuilder extends LitElement {
static get styles() {
return css`
:host {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
font-family: 'Courier New', monospace; /* Placeholder for Voxel Font */
color: white;
pointer-events: none; /* Let clicks pass through to 3D scene where empty */
z-index: 10;
box-sizing: border-box;
}
/* Responsive Container Layout */
.container {
display: grid;
grid-template-columns: 280px 1fr 300px; /* Wider side panels on desktop */
grid-template-rows: 1fr 100px;
grid-template-areas:
"roster squad details"
"footer footer footer";
height: 100%;
width: 100%;
pointer-events: auto;
background: rgba(0, 0, 0, 0.6); /* Slightly darker background for readability */
backdrop-filter: blur(4px);
}
/* Mobile Layout (< 1024px) */
@media (max-width: 1024px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 200px 1fr 200px 80px; /* Roster, Squad, Details, Footer */
grid-template-areas:
"roster"
"squad"
"details"
"footer";
}
}
/* --- LEFT PANEL: ROSTER --- */
.roster-panel {
grid-area: roster;
background: rgba(20, 20, 30, 0.9);
border-right: 2px solid #555;
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
@media (max-width: 1024px) {
.roster-panel {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
border-right: none;
border-bottom: 2px solid #555;
align-items: center;
}
}
.class-card {
background: #333;
border: 2px solid #555;
padding: 15px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 15px;
/* Button Reset */
width: 100%;
text-align: left;
font-family: inherit;
color: inherit;
appearance: none;
}
@media (max-width: 1024px) {
.class-card {
width: 200px; /* Fixed width cards for horizontal scroll */
flex-shrink: 0;
height: 80%;
}
}
.class-card:hover:not(:disabled) {
border-color: #00ffff;
background: #444;
transform: translateX(5px);
}
@media (max-width: 1024px) {
.class-card:hover:not(:disabled) {
transform: translateY(-5px); /* Hop up on mobile */
}
}
.class-card:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(1);
border-color: #444;
}
/* --- CENTER PANEL: SLOTS --- */
.squad-panel {
grid-area: squad;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
gap: 30px;
flex-wrap: wrap; /* Allow wrapping on very small screens */
overflow-y: auto;
}
/* Wrapper to hold the slot button and the absolute remove button as siblings */
.slot-wrapper {
position: relative;
width: 180px; /* Increased size */
height: 240px; /* Increased size */
transition: transform 0.2s;
}
.slot-wrapper:hover {
transform: scale(1.05);
}
.squad-slot {
width: 100%;
height: 100%;
background: rgba(10, 10, 10, 0.8);
border: 3px dashed #666;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
overflow: hidden;
/* Button Reset */
font-family: inherit;
color: inherit;
padding: 0;
appearance: none;
}
/* Image placeholder style */
.unit-image {
width: 100%;
height: 70%;
object-fit: cover;
background-color: #222; /* Fallback */
border-bottom: 2px solid #555;
}
.unit-info {
height: 30%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
background: rgba(30,30,40,0.9);
}
.squad-slot.filled {
border: 3px solid #00ff00;
border-style: solid;
background: rgba(0, 20, 0, 0.8);
}
.squad-slot.selected {
border-color: #00ffff;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
}
.remove-btn {
position: absolute;
top: -15px;
right: -15px;
background: #cc0000;
border: 2px solid white;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
font-weight: bold;
z-index: 2; /* Ensure it sits on top of the slot button */
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
box-shadow: 2px 2px 5px rgba(0,0,0,0.5);
}
.remove-btn:hover {
background: #ff0000;
transform: scale(1.1);
}
/* --- RIGHT PANEL: DETAILS --- */
.details-panel {
grid-area: details;
background: rgba(20, 20, 30, 0.9);
border-left: 2px solid #555;
padding: 1.5rem;
overflow-y: auto;
}
@media (max-width: 1024px) {
.details-panel {
border-left: none;
border-top: 2px solid #555;
display: grid;
grid-template-columns: 1fr 1fr; /* Split content on mobile */
gap: 20px;
}
}
/* --- FOOTER --- */
.footer {
grid-area: footer;
display: flex;
justify-content: center;
align-items: center;
background: rgba(10, 10, 20, 0.95);
border-top: 2px solid #555;
}
.embark-btn {
padding: 15px 60px;
font-size: 1.8rem;
background: #008800;
color: white;
border: 3px solid #00ff00;
cursor: pointer;
text-transform: uppercase;
font-weight: bold;
font-family: inherit;
letter-spacing: 2px;
transition: all 0.2s;
box-shadow: 0 0 15px rgba(0, 255, 0, 0.2);
}
.embark-btn:hover:not(:disabled) {
background: #00aa00;
box-shadow: 0 0 25px rgba(0, 255, 0, 0.6);
transform: scale(1.02);
}
.embark-btn:disabled {
background: #333;
border-color: #555;
color: #777;
cursor: not-allowed;
box-shadow: none;
}
h2, h3, h4 { margin-top: 0; color: #00ffff; }
ul { padding-left: 1.2rem; }
li { margin-bottom: 5px; }
/* Helper for placeholder images */
.placeholder-img {
display: flex;
align-items: center;
justify-content: center;
background: #444;
color: #888;
font-size: 3rem;
}
`;
}
static get properties() {
return {
availableClasses: { type: Array }, // Input: List of class definition objects
squad: { type: Array }, // Internal State: The 4 slots
selectedSlotIndex: { type: Number },
hoveredClass: { type: Object }
};
}
constructor() {
super();
this.squad = [null, null, null, null];
this.selectedSlotIndex = 0; // Default to first slot
this.hoveredClass = null;
// Initialize by merging Raw Data with UI Metadata
this.availableClasses = RAW_TIER_1_CLASSES.map(cls => {
const meta = CLASS_METADATA[cls.id] || {};
return {
...cls,
...meta, // Adds icon, role, description, image path
unlocked: true // Default all Tier 1s to unlocked
};
});
}
connectedCallback() {
super.connectedCallback();
this._loadMetaProgression();
}
/**
* Loads unlocked classes from persistence (Local Storage / Game State).
* Merges Tier 2 classes into availableClasses if unlocked.
*/
_loadMetaProgression() {
// Mock Implementation: Retrieve unlocked Tier 2 classes from a service or storage
// In a real implementation, you would import a MetaProgressionManager here.
// Example: const unlockedIds = MetaProgression.getUnlockedClasses();
const storedData = localStorage.getItem('aether_shards_unlocks');
if (storedData) {
try {
const unlocks = JSON.parse(storedData);
// This is where you would fetch the full class definition for unlocked Tier 2s
// and append them to this.availableClasses
console.log('Loaded unlocks:', unlocks);
} catch (e) {
console.error('Failed to load meta progression', e);
}
}
}
render() {
const isSquadValid = this.squad.some(u => u !== null);
return html`
<div class="container">
<!-- ROSTER LIST -->
<div class="roster-panel">
<h3>Roster</h3>
${this.availableClasses.map(cls => html`
<button
type="button"
class="class-card"
?disabled="${!cls.unlocked}"
@click="${() => this._assignClass(cls)}"
@mouseenter="${() => this.hoveredClass = cls}"
@mouseleave="${() => this.hoveredClass = null}"
aria-label="Select Class: ${cls.name}"
>
<div class="icon" style="font-size: 1.5rem;">${cls.icon || '⚔️'}</div>
<div>
<strong>${cls.name}</strong><br>
<small>${cls.role || 'Tier ' + cls.tier}</small>
</div>
</button>
`)}
</div>
<!-- CENTER SQUAD SLOTS -->
<div class="squad-panel">
${this.squad.map((unit, index) => html`
<div class="slot-wrapper">
<button
type="button"
class="squad-slot ${unit ? 'filled' : ''} ${this.selectedSlotIndex === index ? 'selected' : ''}"
@click="${() => this._selectSlot(index)}"
aria-label="${unit ? `Slot ${index + 1}: ${unit.name}` : `Slot ${index + 1}: Empty`}"
aria-pressed="${this.selectedSlotIndex === index}"
>
${unit
? html`
<!-- Use image property if available, otherwise show large icon placeholder -->
${unit.image
? html`<img src="${unit.image}" alt="${unit.name}" class="unit-image" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'">`
: ''
}
<div class="unit-image placeholder-img" style="${unit.image ? 'display:none' : ''}">
${unit.icon || '🛡️'}
</div>
<div class="unit-info">
<strong>${unit.name}</strong>
<small>${this.availableClasses.find(c => c.id === unit.classId)?.role}</small>
</div>
`
: html`
<div class="placeholder-img" style="background:transparent; color: #555;">+</div>
<div class="unit-info" style="background:transparent;">
<span>Slot ${index + 1}</span>
<small>Empty</small>
</div>
`
}
</button>
${unit
? html`
<button
type="button"
class="remove-btn"
@click="${() => this._removeUnit(index)}"
aria-label="Remove ${unit.name} from Slot ${index + 1}"
>
X
</button>`
: ''
}
</div>
`)}
</div>
<!-- RIGHT DETAILS PANEL -->
<div class="details-panel">
${this.hoveredClass
? html`
<div>
<h2>${this.hoveredClass.name}</h2>
<p><em>${this.hoveredClass.role || 'Tier ' + this.hoveredClass.tier} Class</em></p>
<hr>
<p>${this.hoveredClass.description || 'A skilled explorer ready for the depths.'}</p>
</div>
<div>
<h4>Base Stats</h4>
<ul>
<li>HP: ${this.hoveredClass.base_stats?.health}</li>
<li>Atk: ${this.hoveredClass.base_stats?.attack}</li>
<li>Def: ${this.hoveredClass.base_stats?.defense}</li>
<li>Mag: ${this.hoveredClass.base_stats?.magic}</li>
<li>Spd: ${this.hoveredClass.base_stats?.speed}</li>
<li>Will: ${this.hoveredClass.base_stats?.willpower}</li>
<li>Move: ${this.hoveredClass.base_stats?.movement}</li>
${this.hoveredClass.base_stats?.tech ? html`<li>Tech: ${this.hoveredClass.base_stats.tech}</li>` : ''}
</ul>
<h4>Starting Gear</h4>
<ul>
${this.hoveredClass.starting_equipment
? this.hoveredClass.starting_equipment.map(item => html`<li>${this._formatItemName(item)}</li>`)
: html`<li>None</li>`}
</ul>
</div>
`
: html`<p>Hover over a class or squad member to see details.</p>`
}
</div>
<!-- FOOTER -->
<div class="footer">
<button
type="button"
class="embark-btn"
?disabled="${!isSquadValid}"
@click="${this._handleEmbark}"
>
DESCEND
</button>
</div>
</div>
`;
}
// --- LOGIC ---
_selectSlot(index) {
this.selectedSlotIndex = index;
// If slot has a unit, show its details in hover panel
if (this.squad[index]) {
// Need to find the original class ref to show details
const originalClass = this.availableClasses.find(c => c.id === this.squad[index].classId);
if (originalClass) this.hoveredClass = originalClass;
}
}
_assignClass(classDef) {
if (!classDef.unlocked && classDef.unlocked !== undefined) return; // Logic check redundancy for tests without DOM checks
// 1. Create a lightweight manifest for the slot
const unitManifest = {
classId: classDef.id,
name: classDef.name, // In real app, auto-generate name like "Valerius"
icon: classDef.icon,
image: classDef.image // Pass image path
};
// 2. Update State (Trigger Re-render)
const newSquad = [...this.squad];
newSquad[this.selectedSlotIndex] = unitManifest;
this.squad = newSquad;
// 3. Auto-advance selection
if (this.selectedSlotIndex < 3) {
this.selectedSlotIndex++;
}
// 4. Dispatch Event (For 3D Scene to show model)
this.dispatchEvent(new CustomEvent('squad-update', {
detail: { slot: this.selectedSlotIndex, unit: unitManifest },
bubbles: true,
composed: true
}));
}
_removeUnit(index) {
// No stopPropagation needed as elements are siblings now
const newSquad = [...this.squad];
newSquad[index] = null;
this.squad = newSquad;
this.selectedSlotIndex = index; // Select the empty slot
// Dispatch Event (To clear 3D model)
this.dispatchEvent(new CustomEvent('squad-update', {
detail: { slot: index, unit: null },
bubbles: true,
composed: true
}));
}
_handleEmbark() {
const manifest = this.squad.filter(u => u !== null);
this.dispatchEvent(new CustomEvent('embark', {
detail: { squad: manifest },
bubbles: true,
composed: true
}));
}
// Helpers to make IDs readable (e.g. "ITEM_RUSTY_BLADE" -> "Rusty Blade")
_formatItemName(id) {
return id.replace('ITEM_', '').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
}
_formatSkillName(id) {
return id.replace('SKILL_', '').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
}
}
customElements.define('team-builder', TeamBuilder);