+
this._handlePortraitClick(activeUnit)}"
+ style="cursor: pointer;"
+ title="Click to view character sheet (C)"
+ >
${activeUnit.name}
diff --git a/src/ui/components/CharacterSheet.js b/src/ui/components/CharacterSheet.js
new file mode 100644
index 0000000..f76be6d
--- /dev/null
+++ b/src/ui/components/CharacterSheet.js
@@ -0,0 +1,1701 @@
+import { LitElement, html, css } from "lit";
+import "./SkillTreeUI.js";
+
+/**
+ * CharacterSheet.js
+ * The Explorer's Dossier - UI component for viewing and managing an Explorer unit.
+ * Combines Stat visualization, Inventory management (Paper Doll), and Skill Tree progression.
+ */
+export class CharacterSheet extends LitElement {
+ static get styles() {
+ return css`
+ * {
+ box-sizing: border-box;
+ }
+ :host {
+ display: block;
+ font-family: "Courier New", monospace;
+ color: white;
+ }
+
+ dialog {
+ width: 80vw;
+ height: 80vh;
+ max-width: 1200px;
+ max-height: 900px;
+ background: rgba(10, 10, 20, 0.95);
+ border: 3px solid #555;
+ box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
+ padding: 0;
+ margin: auto;
+ font-family: inherit;
+ color: inherit;
+ }
+
+ dialog::backdrop {
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+ }
+
+ .container {
+ display: grid;
+ grid-template-columns: 250px 1fr 350px;
+ grid-template-rows: auto 1fr;
+ grid-template-areas:
+ "header header header"
+ "stats paper-doll tabs";
+ height: 100%;
+ width: 100%;
+ min-height: 0; /* Allow grid items to shrink below content size */
+ }
+
+ /* --- HEADER --- */
+ .header {
+ grid-area: header;
+ display: grid;
+ grid-template-columns: 100px 1fr auto;
+ align-items: center;
+ padding: 15px 20px;
+ background: rgba(0, 0, 0, 0.5);
+ border-bottom: 2px solid #555;
+ gap: 20px;
+ height: fit-content;
+ }
+
+ .portrait {
+ width: 90px;
+ height: 90px;
+ border: 2px solid #00ffff;
+ border-radius: 4px;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 48px;
+ overflow: hidden;
+ }
+
+ .portrait img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .identity {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0; /* Allow flex item to shrink */
+ }
+
+ .name {
+ font-size: 24px;
+ font-weight: bold;
+ color: #00ffff;
+ margin: 0;
+ }
+
+ .class-title {
+ font-size: 16px;
+ color: #888;
+ margin: 0;
+ }
+
+ .level {
+ font-size: 14px;
+ color: #aaa;
+ margin: 0;
+ }
+
+ .xp-section {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ margin-top: 10px;
+ width: 100%;
+ min-width: 0; /* Prevent overflow */
+ }
+
+ .xp-bar-container {
+ width: 100%;
+ height: 20px;
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid #555;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .xp-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #ffd700, #ffaa00);
+ transition: width 0.3s;
+ }
+
+ .xp-label {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 11px;
+ color: white;
+ text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
+ z-index: 1;
+ }
+
+ .sp-badge {
+ display: inline-block;
+ background: #00ff00;
+ color: #000;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: bold;
+ margin-top: 5px;
+ }
+
+ .close-button {
+ background: transparent;
+ border: 2px solid #ff6666;
+ color: #ff6666;
+ width: 40px;
+ height: 40px;
+ font-size: 24px;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .close-button:hover {
+ background: #ff6666;
+ color: white;
+ }
+
+ /* --- LEFT PANEL: STATS --- */
+ .stats-panel {
+ grid-area: stats;
+ background: rgba(20, 20, 30, 0.9);
+ border-right: 2px solid #555;
+ padding: 20px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ min-height: 0; /* Allow grid item to shrink */
+ height: 100%; /* Fill grid area */
+ max-height: 100%; /* Constrain to grid area */
+ }
+
+ .stats-panel dl {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ margin: 0;
+ padding: 0;
+ min-height: 0; /* Allow flex container to shrink */
+ flex: 1 1 0; /* Grow, shrink, basis 0 - fill available space but allow shrinking */
+ }
+
+ .stat-item {
+ position: relative;
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid #444;
+ padding: 10px;
+ cursor: pointer;
+ transition: all 0.2s;
+ margin: 0;
+ list-style: none;
+ anchor-name: --stat-item;
+ }
+
+ .stat-item:hover {
+ border-color: #00ffff;
+ background: rgba(0, 255, 255, 0.1);
+ }
+
+ .stat-item.buffed {
+ border-color: #00ff00;
+ }
+
+ .stat-item.debuffed {
+ border-color: #ff0000;
+ }
+
+ .stat-label {
+ font-size: 12px;
+ color: #aaa;
+ margin-bottom: 5px;
+ margin-top: 0;
+ font-weight: normal;
+ }
+
+ .stat-value-container {
+ margin: 0;
+ }
+
+ .stat-trigger {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ width: 100%;
+ cursor: pointer;
+ font-family: inherit;
+ color: inherit;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 5px;
+ position: relative;
+ }
+
+ .stat-trigger:hover {
+ opacity: 0.8;
+ }
+
+ .stat-trigger:focus-visible {
+ outline: 2px solid #00ffff;
+ outline-offset: 2px;
+ }
+
+ .stat-value {
+ font-size: 20px;
+ font-weight: bold;
+ color: white;
+ }
+
+ .stat-value.buffed {
+ color: #00ff00;
+ }
+
+ .stat-value.debuffed {
+ color: #ff6666;
+ }
+
+ .health-bar-container {
+ width: 100%;
+ height: 24px;
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid #555;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .health-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #ff0000, #ff6666);
+ transition: width 0.3s;
+ }
+
+ .health-label {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 12px;
+ color: white;
+ text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
+ z-index: 1;
+ }
+
+ .ap-icons {
+ display: flex;
+ gap: 5px;
+ flex-wrap: wrap;
+ }
+
+ .ap-icon {
+ width: 20px;
+ height: 20px;
+ background: #00ffff;
+ border: 1px solid #00aaff;
+ border-radius: 2px;
+ }
+
+ .ap-icon.empty {
+ background: rgba(0, 170, 255, 0.3);
+ }
+
+ .tooltip {
+ background: rgba(0, 0, 0, 0.95);
+ border: 2px solid #00ffff;
+ padding: 10px;
+ min-width: 200px;
+ z-index: 1000;
+ color: #ffffff;
+ /* CSS Anchor Positioning - positioning set via inline styles per tooltip */
+ position: fixed;
+ margin: 0;
+ left: anchor(left);
+ top: anchor(top);
+ transform: translateY(-100%);
+ }
+
+ /* Popover API styling */
+ .tooltip::backdrop {
+ background: transparent;
+ }
+
+ .tooltip-title {
+ font-weight: bold;
+ margin-bottom: 8px;
+ margin-top: 0;
+ color: #00ffff;
+ font-size: 14px;
+ }
+
+ .tooltip-breakdown {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 12px;
+ margin: 0;
+ color: #ffffff;
+ }
+
+ .tooltip-row {
+ display: flex;
+ justify-content: space-between;
+ color: #ffffff;
+ }
+
+ .tooltip-row dt,
+ .tooltip-row dd {
+ margin: 0;
+ color: #ffffff;
+ }
+
+ .tooltip-total {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid #555;
+ font-weight: bold;
+ color: #ffffff;
+ }
+
+ /* --- CENTER PANEL: PAPER DOLL --- */
+ .paper-doll-panel {
+ grid-area: paper-doll;
+ background: rgba(15, 15, 25, 0.9);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ position: relative;
+ container-type: inline-size;
+ container-name: paper-doll;
+ min-width: 0;
+ min-height: 0; /* Allow grid item to shrink */
+ height: 100%; /* Fill grid area */
+ }
+
+ .paper-doll-container {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ max-height: 100%;
+ aspect-ratio: 2/3;
+ display: grid;
+ grid-template-columns: 1fr 1.5fr 1fr;
+ grid-template-rows: 1fr 1.5fr 1fr;
+ grid-template-areas:
+ ". . ."
+ "mainHand body accessory"
+ "offHand belt1 belt2";
+ align-items: center;
+ justify-items: center;
+ /* Fit within container while maintaining aspect ratio */
+ object-fit: contain;
+ }
+
+ /* Ensure container fits within panel bounds */
+ @container paper-doll {
+ .paper-doll-container {
+ width: min(100cqw - 40px, (100cqh - 40px) * 2 / 3);
+ height: min((100cqw - 40px) * 3 / 2, 100cqh - 40px);
+ }
+ }
+
+ .unit-silhouette {
+ grid-area: body;
+ width: 60%;
+ aspect-ratio: 2/3;
+ background: rgba(50, 50, 70, 0.5);
+ border: 2px dashed #666;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: clamp(24px, 4cqw, 48px);
+ color: #666;
+ }
+
+ .equipment-slot {
+ width: clamp(50px, 7cqw, 70px);
+ height: clamp(50px, 7cqw, 70px);
+ background: rgba(0, 0, 0, 0.6);
+ border: 2px solid #555;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.2s;
+ position: relative;
+ }
+
+ .equipment-slot:hover {
+ border-color: #00ffff;
+ background: rgba(0, 255, 255, 0.1);
+ transform: scale(1.1);
+ }
+
+ .equipment-slot.selected {
+ border-color: #00ff00;
+ background: rgba(0, 255, 0, 0.2);
+ }
+
+ .equipment-slot.mainHand {
+ grid-area: mainHand;
+ }
+
+ .equipment-slot.offHand {
+ grid-area: offHand;
+ }
+
+ .equipment-slot.body {
+ grid-area: body;
+ z-index: 1;
+ }
+
+ .equipment-slot.accessory {
+ grid-area: accessory;
+ }
+
+ .equipment-slot.belt1 {
+ grid-area: belt1;
+ }
+
+ .equipment-slot.belt2 {
+ grid-area: belt2;
+ }
+
+ /* Legacy class names for backward compatibility */
+ .equipment-slot.weapon {
+ grid-area: mainHand;
+ }
+
+ .equipment-slot.armor {
+ grid-area: body;
+ z-index: 1;
+ }
+
+ .equipment-slot.relic {
+ grid-area: accessory;
+ }
+
+ .equipment-slot.utility {
+ grid-area: offHand;
+ }
+
+ .slot-icon {
+ font-size: clamp(20px, 3cqw, 32px);
+ color: #666;
+ }
+
+ .item-icon {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ padding: 5px;
+ }
+
+ .slot-label {
+ position: absolute;
+ bottom: -20px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 10px;
+ color: #aaa;
+ white-space: nowrap;
+ }
+
+ /* --- RIGHT PANEL: TABS --- */
+ .tabs-panel {
+ grid-area: tabs;
+ background: rgba(20, 20, 30, 0.9);
+ border-left: 2px solid #555;
+ display: flex;
+ flex-direction: column;
+ min-height: 0; /* Allow flex item to shrink */
+ height: 100%; /* Fill grid area */
+ }
+
+ .tab-buttons {
+ display: flex;
+ border-bottom: 2px solid #555;
+ }
+
+ .tab-button {
+ flex: 1;
+ background: rgba(0, 0, 0, 0.5);
+ border: none;
+ border-right: 1px solid #555;
+ color: #aaa;
+ padding: 12px;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 14px;
+ transition: all 0.2s;
+ }
+
+ .tab-button:last-child {
+ border-right: none;
+ }
+
+ .tab-button:hover {
+ background: rgba(0, 255, 255, 0.1);
+ color: white;
+ }
+
+ .tab-button.active {
+ background: rgba(0, 255, 255, 0.2);
+ color: #00ffff;
+ border-bottom: 2px solid #00ffff;
+ margin-bottom: -2px;
+ }
+
+ .tab-content {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 20px;
+ min-height: 0; /* Allow flex item to shrink */
+ }
+
+ .inventory-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
+ gap: 10px;
+ }
+
+ .item-card {
+ width: 80px;
+ height: 80px;
+ background: rgba(0, 0, 0, 0.6);
+ border: 2px solid #555;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.2s;
+ position: relative;
+ }
+
+ .item-card:hover {
+ border-color: #00ffff;
+ transform: scale(1.1);
+ }
+
+ .item-card img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ padding: 5px;
+ }
+
+ .skills-container {
+ height: 100%;
+ min-height: 0; /* Allow container to shrink */
+ max-height: 100%; /* Constrain to parent */
+ display: flex;
+ flex-direction: column;
+ }
+
+ .mastery-container {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ }
+
+ .mastery-class {
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid #555;
+ padding: 15px;
+ }
+
+ .mastery-class-name {
+ font-weight: bold;
+ margin-bottom: 10px;
+ margin-top: 0;
+ color: #00ffff;
+ font-size: 16px;
+ }
+
+ .mastery-class p {
+ margin: 5px 0;
+ }
+
+ .mastery-progress {
+ width: 100%;
+ height: 20px;
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid #555;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .mastery-progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #00ffff, #00aaff);
+ transition: width 0.3s;
+ }
+ `;
+ }
+
+ static get properties() {
+ return {
+ unit: { type: Object },
+ readOnly: { type: Boolean },
+ activeTab: { type: String },
+ selectedSlot: { type: String },
+ inventory: { type: Array },
+ gameMode: { type: String }, // "DUNGEON" or "HUB"
+ inventoryManager: { type: Object }, // InventoryManager instance
+ treeDef: { type: Object }, // Skill tree definition
+ };
+ }
+
+ constructor() {
+ super();
+ this.unit = null;
+ this.readOnly = false;
+ this.activeTab = "INVENTORY";
+ this.selectedSlot = null;
+ this.inventory = [];
+ this.gameMode = "HUB";
+ this.inventoryManager = null;
+ this.treeDef = null;
+ }
+
+ /**
+ * Gets or creates a simple tree definition from unit
+ * @returns {Object|null}
+ */
+ _getTreeDefinition() {
+ if (this.treeDef) {
+ return this.treeDef;
+ }
+
+ if (!this.unit) {
+ return null;
+ }
+
+ // Create a simple mock tree for demonstration
+ // In production, this would come from SkillTreeFactory
+ // This matches the mock tree in SkillTreeUI.js
+ return {
+ id: `TREE_${this.unit.activeClassId}`,
+ nodes: {
+ ROOT: {
+ id: "ROOT",
+ tier: 1,
+ type: "STAT_BOOST",
+ children: ["NODE_1", "NODE_2"],
+ data: { stat: "health", value: 10 },
+ req: 1,
+ cost: 1,
+ },
+ NODE_1: {
+ id: "NODE_1",
+ tier: 2,
+ type: "ACTIVE_SKILL",
+ children: ["NODE_3"],
+ data: { name: "Shield Bash", id: "SKILL_SHIELD_BASH" },
+ req: 2,
+ cost: 1,
+ },
+ NODE_2: {
+ id: "NODE_2",
+ tier: 2,
+ type: "STAT_BOOST",
+ children: [],
+ data: { stat: "defense", value: 5 },
+ req: 2,
+ cost: 1,
+ },
+ NODE_3: {
+ id: "NODE_3",
+ tier: 3,
+ type: "PASSIVE_ABILITY",
+ children: [],
+ data: { name: "Iron Skin", id: "PASSIVE_IRON_SKIN" },
+ req: 3,
+ cost: 2,
+ },
+ },
+ };
+ }
+
+ /**
+ * Calculates effective stat value with breakdown
+ * @param {string} statName - Name of the stat
+ * @returns {Object} - { total, breakdown }
+ */
+ _getEffectiveStat(statName) {
+ if (!this.unit) {
+ return { total: 0, breakdown: [] };
+ }
+
+ const breakdown = [];
+ let total = 0;
+
+ // Base stat - always show, even if 0
+ const baseValue = this.unit.baseStats?.[statName] || 0;
+ breakdown.push({ source: "Base", value: baseValue });
+ total += baseValue;
+
+ // Equipment stats from loadout (new system)
+ if (this.unit.loadout && this.inventoryManager) {
+ const loadoutSlots = ["mainHand", "offHand", "body", "accessory"];
+ for (const slot of loadoutSlots) {
+ const itemInstance = this.unit.loadout[slot];
+ if (itemInstance) {
+ const itemDef = this.inventoryManager.itemRegistry.get(
+ itemInstance.defId
+ );
+ if (itemDef && itemDef.stats) {
+ const itemValue = itemDef.stats[statName] || 0;
+ if (itemValue !== 0) {
+ breakdown.push({
+ source: itemDef.name || slot,
+ value: itemValue,
+ });
+ total += itemValue;
+ }
+ }
+ }
+ }
+ } else if (this.unit.equipment) {
+ // Fallback to legacy equipment system
+ const slots = ["weapon", "armor", "utility", "relic"];
+ for (const slot of slots) {
+ const item = this.unit.equipment[slot];
+ if (item && item.stats) {
+ const itemValue =
+ item.getStat?.(statName) || item.stats[statName] || 0;
+ if (itemValue !== 0) {
+ breakdown.push({ source: item.name || slot, value: itemValue });
+ total += itemValue;
+ }
+ }
+ }
+ }
+
+ // Skill tree stat boosts from unlocked nodes
+ if (this.unit.classMastery) {
+ const mastery = this.unit.classMastery[this.unit.activeClassId];
+ if (mastery && mastery.unlockedNodes) {
+ const tree = this._getTreeDefinition();
+ if (tree) {
+ for (const nodeId of mastery.unlockedNodes) {
+ const nodeDef = tree.nodes[nodeId];
+ if (
+ nodeDef &&
+ nodeDef.type === "STAT_BOOST" &&
+ nodeDef.data &&
+ nodeDef.data.stat === statName
+ ) {
+ const boostValue = nodeDef.data.value || 0;
+ if (boostValue !== 0) {
+ breakdown.push({
+ source: `Skill Tree: ${this._getNodeName(nodeDef)}`,
+ value: boostValue,
+ });
+ total += boostValue;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Status effects (buffs/debuffs)
+ if (this.unit.statusEffects) {
+ for (const effect of this.unit.statusEffects) {
+ if (effect.statModifiers && effect.statModifiers[statName]) {
+ const modValue = effect.statModifiers[statName];
+ if (modValue !== 0) {
+ breakdown.push({
+ source: effect.name || effect.id || "Status Effect",
+ value: modValue,
+ });
+ total += modValue;
+ }
+ }
+ }
+ }
+
+ return { total, breakdown };
+ }
+
+ /**
+ * Gets node name/title for skill tree nodes
+ * @param {Object} nodeDef - Node definition
+ * @returns {string}
+ */
+ _getNodeName(nodeDef) {
+ if (nodeDef.data?.name) {
+ return nodeDef.data.name;
+ }
+ if (nodeDef.type === "STAT_BOOST" && nodeDef.data?.stat) {
+ return `${nodeDef.data.stat} +${nodeDef.data.value || 0}`;
+ }
+ return nodeDef.type || "Unknown";
+ }
+
+ /**
+ * Determines if a stat is buffed or debuffed
+ * @param {string} statName - Name of the stat
+ * @returns {string} - "buffed", "debuffed", or ""
+ */
+ _getStatModifierState(statName) {
+ if (!this.unit || !this.unit.statusEffects) {
+ return "";
+ }
+
+ let hasBuff = false;
+ let hasDebuff = false;
+
+ for (const effect of this.unit.statusEffects) {
+ if (effect.statModifiers && effect.statModifiers[statName]) {
+ const modValue = effect.statModifiers[statName];
+ if (modValue > 0) {
+ hasBuff = true;
+ } else if (modValue < 0) {
+ hasDebuff = true;
+ }
+ }
+ }
+
+ if (hasBuff && !hasDebuff) return "buffed";
+ if (hasDebuff && !hasBuff) return "debuffed";
+ return "";
+ }
+
+ /**
+ * Renders a stat item with tooltip
+ * @param {string} label - Stat label
+ * @param {string} statName - Stat name in unit.stats
+ * @returns {TemplateResult}
+ */
+ _renderStat(label, statName) {
+ const { total, breakdown } = this._getEffectiveStat(statName);
+ const modifierState = this._getStatModifierState(statName);
+ const tooltipId = `tooltip-${statName}`;
+
+ // Special handling for Health
+ const isHealth = statName === "health";
+ let current = 0;
+ let max = total;
+ let percent = 0;
+ if (isHealth) {
+ current = this.unit?.currentHealth || 0;
+ max = this.unit?.maxHealth || total;
+ percent = max > 0 ? (current / max) * 100 : 0;
+ }
+
+ // Special handling for Speed/AP
+ const isSpeed = statName === "speed";
+ let maxAP = 0;
+ let currentAP = 0;
+ const apIcons = [];
+ if (isSpeed) {
+ maxAP = 3 + Math.floor(total / 5);
+ currentAP = this.unit?.currentAP || 0;
+ for (let i = 0; i < maxAP; i++) {
+ apIcons.push(
+ html`
`
+ );
+ }
+ }
+
+ return html`
+
+
${label}
+
+
+ ${isHealth
+ ? html`
+
+
+
${current} / ${max}
+
+ `
+ : html`
+ ${total}
+ ${isSpeed
+ ? html`
+
+ ${apIcons}
+
+ `
+ : ""}
+ `}
+
+
+
+
+ `;
+ }
+
+ /**
+ * Gets item definition from loadout slot
+ * @param {string} slotName - Loadout slot name (mainHand, offHand, body, accessory, belt[index])
+ * @returns {Object|null} - Item definition or null
+ */
+ _getItemFromLoadout(slotName) {
+ if (!this.unit.loadout || !this.inventoryManager) {
+ return null;
+ }
+
+ let itemInstance = null;
+ if (slotName.startsWith("belt")) {
+ const index = parseInt(slotName.replace("belt", "")) || 0;
+ itemInstance = this.unit.loadout.belt?.[index];
+ } else {
+ itemInstance = this.unit.loadout[slotName];
+ }
+
+ if (!itemInstance) {
+ return null;
+ }
+
+ return this.inventoryManager.itemRegistry.get(itemInstance.defId);
+ }
+
+ /**
+ * Renders an equipment slot button
+ * @param {string} slotType - Slot type (MAIN_HAND, OFF_HAND, BODY, ACCESSORY)
+ * @param {string} loadoutSlot - Loadout slot name (mainHand, offHand, body, accessory)
+ * @param {string} defaultIcon - Default icon emoji
+ * @param {string} label - Slot label
+ * @returns {TemplateResult}
+ */
+ _renderEquipmentSlot(slotType, loadoutSlot, defaultIcon, label) {
+ const itemDef = this._getItemFromLoadout(loadoutSlot);
+ const isSelected = this.selectedSlot === slotType;
+ const slotClass = loadoutSlot; // mainHand, offHand, body, accessory
+
+ return html`
+
this._handleSlotClick(slotType)}"
+ ?disabled="${this.readOnly}"
+ aria-label="${label} slot${itemDef ? `: ${itemDef.name}` : " (empty)"}"
+ >
+ ${itemDef
+ ? html` `
+ : html`${defaultIcon} `}
+ ${label}
+
+ `;
+ }
+
+ /**
+ * Renders a belt slot button
+ * @param {number} index - Belt slot index (0 or 1)
+ * @returns {TemplateResult}
+ */
+ _renderBeltSlot(index) {
+ const slotType = `BELT_${index}`;
+ const isSelected = this.selectedSlot === slotType;
+ const itemDef = this._getItemFromLoadout(`belt${index}`);
+
+ return html`
+
this._handleSlotClick(slotType)}"
+ ?disabled="${this.readOnly}"
+ aria-label="Belt slot ${index + 1}${itemDef
+ ? `: ${itemDef.name}`
+ : " (empty)"}"
+ >
+ ${itemDef
+ ? html` `
+ : html`🧪 `}
+ Belt ${index + 1}
+
+ `;
+ }
+
+ /**
+ * Handles equipment slot click
+ * @param {string} slotType - Type of slot clicked
+ */
+ _handleSlotClick(slotType) {
+ if (this.readOnly) return;
+
+ if (this.selectedSlot === slotType) {
+ this.selectedSlot = null;
+ } else {
+ this.selectedSlot = slotType;
+ this.activeTab = "INVENTORY";
+ }
+ this.requestUpdate();
+ }
+
+ /**
+ * Handles item click in inventory
+ * @param {Object} item - Item to equip
+ */
+ _handleItemClick(item) {
+ if (this.readOnly || !this.selectedSlot) return;
+
+ // Use inventoryManager if available
+ if (this.inventoryManager && item.uid) {
+ // Map slot types to InventoryManager slot names
+ const slotMap = {
+ WEAPON: "MAIN_HAND",
+ ARMOR: "BODY",
+ UTILITY: "OFF_HAND",
+ RELIC: "ACCESSORY",
+ MAIN_HAND: "MAIN_HAND",
+ OFF_HAND: "OFF_HAND",
+ BODY: "BODY",
+ ACCESSORY: "ACCESSORY",
+ BELT_0: "BELT",
+ BELT_1: "BELT",
+ };
+
+ let slot = slotMap[this.selectedSlot] || this.selectedSlot;
+ let beltIndex = undefined;
+
+ // Handle belt slots
+ if (this.selectedSlot === "BELT_0" || this.selectedSlot === "BELT_1") {
+ beltIndex = this.selectedSlot === "BELT_0" ? 0 : 1;
+ }
+
+ // Create ItemInstance from item
+ const itemInstance = {
+ uid: item.uid,
+ defId: item.defId,
+ isNew: item.isNew || false,
+ quantity: item.quantity || 1,
+ };
+
+ // Use InventoryManager to equip
+ const success = this.inventoryManager.equipItem(
+ this.unit,
+ itemInstance,
+ slot,
+ beltIndex
+ );
+
+ if (success) {
+ // Dispatch equip event
+ this.dispatchEvent(
+ new CustomEvent("equip-item", {
+ detail: {
+ unitId: this.unit.id,
+ slot: slot,
+ item: item,
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+
+ this.selectedSlot = null;
+ this.requestUpdate();
+ }
+ return;
+ }
+
+ // Fallback to legacy equipment system
+ const slotMap = {
+ WEAPON: "weapon",
+ ARMOR: "armor",
+ UTILITY: "utility",
+ RELIC: "relic",
+ };
+
+ const equipmentKey = slotMap[this.selectedSlot];
+ if (!equipmentKey) return;
+
+ // Check if item can be equipped
+ if (item.canEquip && !item.canEquip(this.unit)) {
+ return;
+ }
+
+ // Swap items
+ const oldItem = this.unit.equipment[equipmentKey];
+ this.unit.equipment[equipmentKey] = item;
+
+ // Remove item from inventory
+ const itemIndex = this.inventory.indexOf(item);
+ if (itemIndex !== -1) {
+ this.inventory.splice(itemIndex, 1);
+ }
+
+ // Add old item to inventory if it exists
+ if (oldItem) {
+ this.inventory.push(oldItem);
+ }
+
+ // Dispatch equip event
+ this.dispatchEvent(
+ new CustomEvent("equip-item", {
+ detail: {
+ unitId: this.unit.id,
+ slot: this.selectedSlot,
+ item: item,
+ oldItem: oldItem,
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+
+ this.selectedSlot = null;
+ this.requestUpdate();
+ }
+
+ /**
+ * Gets filtered inventory based on selected slot
+ * @returns {Array}
+ */
+ _getFilteredInventory() {
+ // Use inventoryManager if available
+ if (this.inventoryManager) {
+ const stash =
+ this.gameMode === "DUNGEON"
+ ? this.inventoryManager.runStash
+ : this.inventoryManager.hubStash;
+
+ // Defensive check: ensure stash exists and has getAllItems method
+ if (!stash || typeof stash.getAllItems !== "function") {
+ return this.inventory || [];
+ }
+
+ const items = stash.getAllItems();
+
+ // Ensure items is an array
+ if (!Array.isArray(items)) {
+ return this.inventory || [];
+ }
+
+ // Convert ItemInstance to UI format
+ const uiItems = items.map((itemInstance) => {
+ const itemDef = this.inventoryManager.itemRegistry?.get(
+ itemInstance.defId
+ );
+ return {
+ uid: itemInstance.uid,
+ defId: itemInstance.defId,
+ name: itemDef?.name || itemInstance.defId,
+ type: itemDef?.type || "UTILITY",
+ stats: itemDef?.stats || {},
+ canEquip: itemDef ? (unit) => itemDef.canEquip(unit) : () => true,
+ quantity: itemInstance.quantity,
+ isNew: itemInstance.isNew,
+ };
+ });
+
+ if (!this.selectedSlot) {
+ return uiItems;
+ }
+
+ const typeMap = {
+ WEAPON: "WEAPON",
+ ARMOR: "ARMOR",
+ UTILITY: "UTILITY",
+ RELIC: "RELIC",
+ MAIN_HAND: "WEAPON",
+ OFF_HAND: "UTILITY",
+ BODY: "ARMOR",
+ ACCESSORY: "RELIC",
+ };
+
+ const filterType = typeMap[this.selectedSlot];
+ if (!filterType) return uiItems;
+
+ return uiItems.filter((item) => item.type === filterType);
+ }
+
+ // Fallback to legacy inventory array
+ // Ensure inventory is always an array
+ const inventory = Array.isArray(this.inventory) ? this.inventory : [];
+
+ if (!this.selectedSlot) {
+ return inventory;
+ }
+
+ const typeMap = {
+ WEAPON: "WEAPON",
+ ARMOR: "ARMOR",
+ UTILITY: "UTILITY",
+ RELIC: "RELIC",
+ };
+
+ const filterType = typeMap[this.selectedSlot];
+ if (!filterType) return inventory;
+
+ return inventory.filter((item) => item.type === filterType);
+ }
+
+ /**
+ * Gets XP percentage for current class
+ * @returns {number}
+ */
+ _getXPPercent() {
+ if (!this.unit || !this.unit.classMastery) return 0;
+ const mastery = this.unit.classMastery[this.unit.activeClassId];
+ if (!mastery) return 0;
+
+ // Simplified: assume 100 XP per level for now
+ const xpForNextLevel = mastery.level * 100;
+ return mastery.xp / xpForNextLevel;
+ }
+
+ /**
+ * Gets skill points for current class
+ * @returns {number}
+ */
+ _getSkillPoints() {
+ if (!this.unit || !this.unit.classMastery) return 0;
+ const mastery = this.unit.classMastery[this.unit.activeClassId];
+ return mastery?.skillPoints || 0;
+ }
+
+ /**
+ * Gets class name from class ID
+ * @returns {string}
+ */
+ _getClassName() {
+ if (!this.unit) return "";
+ const classId = this.unit.activeClassId;
+ // Simple mapping - could be enhanced with a registry
+ const classNames = {
+ CLASS_VANGUARD: "Vanguard",
+ CLASS_WEAVER: "Aether Weaver",
+ CLASS_SCAVENGER: "Scavenger",
+ CLASS_TINKER: "Tinker",
+ CLASS_CUSTODIAN: "Custodian",
+ };
+ return classNames[classId] || classId;
+ }
+
+ render() {
+ if (!this.unit) {
+ return html``;
+ }
+
+ const mastery = this.unit.classMastery?.[this.unit.activeClassId] || {
+ level: 1,
+ xp: 0,
+ skillPoints: 0,
+ };
+
+ const xpPercent = Math.min(this._getXPPercent() * 100, 100);
+ const skillPoints = this._getSkillPoints();
+ const filteredInventory = this._getFilteredInventory();
+
+ return html`
+
+
+
+
+
+
+
+
+ ${this._renderStat("Health", "health")}
+ ${this._renderStat("AP", "speed")}
+ ${this._renderStat("Attack", "attack")}
+ ${this._renderStat("Defense", "defense")}
+ ${this._renderStat("Magic", "magic")}
+ ${this._renderStat("Willpower", "willpower")}
+ ${this._renderStat("Tech", "tech")}
+
+
+
+
+
+
+
👤
+ ${this._renderEquipmentSlot(
+ "MAIN_HAND",
+ "mainHand",
+ "⚔️",
+ "Main Hand"
+ )}
+ ${this._renderEquipmentSlot(
+ "OFF_HAND",
+ "offHand",
+ "🛡️",
+ "Off Hand"
+ )}
+ ${this._renderEquipmentSlot("BODY", "body", "🛡️", "Body")}
+ ${this._renderEquipmentSlot(
+ "ACCESSORY",
+ "accessory",
+ "💎",
+ "Accessory"
+ )}
+ ${this._renderBeltSlot(0)} ${this._renderBeltSlot(1)}
+
+
+
+
+
+
+ {
+ this.activeTab = "INVENTORY";
+ this.requestUpdate();
+ }}"
+ role="tab"
+ aria-selected="${this.activeTab === "INVENTORY"}"
+ aria-controls="inventory-panel"
+ id="inventory-tab"
+ >
+ Inventory
+
+ {
+ this.activeTab = "SKILLS";
+ this.requestUpdate();
+ }}"
+ role="tab"
+ aria-selected="${this.activeTab === "SKILLS"}"
+ aria-controls="skills-panel"
+ id="skills-tab"
+ >
+ Skills
+
+ {
+ this.activeTab = "MASTERY";
+ this.requestUpdate();
+ }}"
+ role="tab"
+ aria-selected="${this.activeTab === "MASTERY"}"
+ aria-controls="mastery-panel"
+ id="mastery-tab"
+ >
+ Mastery
+
+
+
+
+ ${this.activeTab === "INVENTORY"
+ ? html`
+
+ ${filteredInventory.map(
+ (item) => html`
+
this._handleItemClick(item)}"
+ title="${item.name}"
+ aria-label="Equip ${item.name}"
+ >
+ ${item.icon
+ ? html` `
+ : html`📦 `}
+
+ `
+ )}
+ ${filteredInventory.length === 0
+ ? html`
+ No items available
+
`
+ : ""}
+
+ `
+ : ""}
+ ${this.activeTab === "SKILLS"
+ ? html`
+
+ ${this.unit
+ ? html`
`
+ : html`
No unit selected
`}
+
+ `
+ : ""}
+ ${this.activeTab === "MASTERY"
+ ? html`
+
+ ${Object.entries(this.unit.classMastery || {}).map(
+ ([classId, masteryData]) => html`
+
+ ${classId}
+ Level ${masteryData.level}
+
+
+ `
+ )}
+
+ `
+ : ""}
+
+
+
+
+ `;
+ }
+
+ firstUpdated() {
+ const dialog = this.shadowRoot.querySelector("dialog");
+ if (dialog) {
+ dialog.showModal();
+ // Handle ESC key and backdrop clicks
+ dialog.addEventListener("cancel", (e) => {
+ e.preventDefault();
+ this._handleClose();
+ });
+ dialog.addEventListener("click", (e) => {
+ if (e.target === dialog) {
+ this._handleClose();
+ }
+ });
+ }
+ }
+
+ _handleClose() {
+ const dialog = this.shadowRoot.querySelector("dialog");
+ if (dialog) {
+ dialog.close();
+ }
+ this.dispatchEvent(
+ new CustomEvent("close", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ /**
+ * Handles unlock-request event from SkillTreeUI
+ * @param {CustomEvent} event - Unlock request event
+ */
+ _handleUnlockRequest(event) {
+ const { nodeId, cost } = event.detail;
+
+ if (!this.unit || !this.unit.classMastery) {
+ return;
+ }
+
+ const mastery = this.unit.classMastery[this.unit.activeClassId];
+ if (!mastery) {
+ return;
+ }
+
+ if (mastery.skillPoints < cost) {
+ console.warn("Insufficient skill points");
+ return;
+ }
+
+ // Deduct skill points and unlock node
+ mastery.skillPoints -= cost;
+ if (!mastery.unlockedNodes) {
+ mastery.unlockedNodes = [];
+ }
+ if (!mastery.unlockedNodes.includes(nodeId)) {
+ mastery.unlockedNodes.push(nodeId);
+ }
+
+ // Recalculate unit stats to include the new skill tree boost
+ const tree = this._getTreeDefinition();
+ if (
+ this.unit.recalculateStats &&
+ typeof this.unit.recalculateStats === "function"
+ ) {
+ this.unit.recalculateStats(
+ this.inventoryManager?.itemRegistry || null,
+ tree
+ );
+ }
+
+ // Update the view
+ this.requestUpdate();
+
+ // Force SkillTreeUI to update by finding it and incrementing updateTrigger
+ // This is needed because we mutated nested properties which LitElement doesn't detect
+ setTimeout(() => {
+ const skillTreeUI = this.shadowRoot?.querySelector("skill-tree-ui");
+ if (skillTreeUI) {
+ // Increment updateTrigger to force a re-render
+ skillTreeUI.updateTrigger = (skillTreeUI.updateTrigger || 0) + 1;
+ }
+ }, 0);
+
+ // Dispatch event for persistence/other systems
+ this.dispatchEvent(
+ new CustomEvent("skill-unlocked", {
+ detail: {
+ unitId: this.unit.id,
+ nodeId: nodeId,
+ cost: cost,
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+}
+
+customElements.define("character-sheet", CharacterSheet);
diff --git a/src/ui/components/SkillTreeUI.js b/src/ui/components/SkillTreeUI.js
new file mode 100644
index 0000000..46e3652
--- /dev/null
+++ b/src/ui/components/SkillTreeUI.js
@@ -0,0 +1,863 @@
+import { LitElement, html, css } from "lit";
+
+/**
+ * SkillTreeUI.js
+ * Interactive skill tree component with CSS 3D voxel nodes and SVG connections.
+ * Renders the progression tree for an Explorer unit.
+ */
+export class SkillTreeUI extends LitElement {
+ static get styles() {
+ return css`
+ :host {
+ display: block;
+ width: 100%;
+ height: 100%;
+ min-height: 0; /* Allow host to shrink */
+ max-height: 100%; /* Constrain to parent */
+ position: relative;
+ }
+
+ .tree-container {
+ width: 100%;
+ height: 100%;
+ min-height: 0; /* Allow container to shrink */
+ max-height: 100%; /* Constrain to parent */
+ overflow-y: auto;
+ overflow-x: hidden;
+ position: relative;
+ background: rgba(0, 0, 0, 0.3);
+ }
+
+ .tree-content {
+ position: relative;
+ min-height: 100%;
+ padding: 40px 20px;
+ display: flex;
+ flex-direction: column-reverse;
+ gap: 60px;
+ }
+
+ .tier-row {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 40px;
+ flex-wrap: wrap;
+ min-height: 120px;
+ position: relative;
+ }
+
+ .tier-label {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 14px;
+ color: #888;
+ font-weight: bold;
+ writing-mode: vertical-rl;
+ text-orientation: mixed;
+ }
+
+ /* SVG Connections Overlay */
+ .connections-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ z-index: 1;
+ }
+
+ .connection-line {
+ fill: none;
+ stroke-width: 2;
+ transition: stroke 0.3s;
+ }
+
+ .connection-line.unlocked {
+ stroke: #00ffff;
+ filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.5));
+ }
+
+ .connection-line.available {
+ stroke: #666;
+ opacity: 0.5;
+ }
+
+ .connection-line.locked {
+ stroke: #333;
+ opacity: 0.3;
+ }
+
+ /* Voxel Node */
+ .voxel-node {
+ position: relative;
+ width: 80px;
+ height: 80px;
+ transform-style: preserve-3d;
+ cursor: pointer;
+ z-index: 2;
+ }
+
+ .voxel-cube {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ transform-style: preserve-3d;
+ }
+
+ /* Cube Faces */
+ .cube-face {
+ position: absolute;
+ width: 80px;
+ height: 80px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backface-visibility: hidden;
+ }
+
+ .cube-face.front {
+ transform: translateZ(40px);
+ }
+
+ .cube-face.back {
+ transform: rotateY(180deg) translateZ(40px);
+ }
+
+ .cube-face.right {
+ transform: rotateY(90deg) translateZ(40px);
+ }
+
+ .cube-face.left {
+ transform: rotateY(-90deg) translateZ(40px);
+ }
+
+ .cube-face.top {
+ transform: rotateX(90deg) translateZ(40px);
+ }
+
+ .cube-face.bottom {
+ transform: rotateX(-90deg) translateZ(40px);
+ }
+
+ /* Node States */
+ .voxel-node.locked .cube-face {
+ background: rgba(50, 50, 50, 0.8);
+ border-color: #444;
+ }
+
+ .voxel-node.available .cube-face {
+ background: rgba(0, 100, 200, 0.6);
+ border-color: #00aaff;
+ animation: pulse-available 2s ease-in-out infinite;
+ }
+
+ .voxel-node.unlocked .cube-face {
+ background: rgba(0, 200, 255, 0.8);
+ border-color: #00ffff;
+ box-shadow: 0 0 15px rgba(0, 255, 255, 0.6);
+ animation: rotate-unlocked 8s linear infinite;
+ }
+
+ .voxel-node.unlocked .voxel-cube {
+ animation: rotate-unlocked 8s linear infinite;
+ }
+
+ @keyframes pulse-available {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ opacity: 0.6;
+ }
+ 50% {
+ transform: translateY(-5px);
+ opacity: 0.9;
+ }
+ }
+
+ @keyframes rotate-unlocked {
+ from {
+ transform: rotateY(0deg) rotateX(0deg);
+ }
+ to {
+ transform: rotateY(360deg) rotateX(15deg);
+ }
+ }
+
+ .node-icon {
+ font-size: 32px;
+ color: white;
+ text-shadow: 0 0 5px rgba(0, 0, 0, 0.8);
+ }
+
+ .node-icon img {
+ width: 48px;
+ height: 48px;
+ object-fit: contain;
+ }
+
+ /* Inspector Footer */
+ .inspector {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(10, 10, 20, 0.95);
+ border-top: 3px solid #00ffff;
+ padding: 20px;
+ transform: translateY(100%);
+ transition: transform 0.3s ease-out;
+ z-index: 1000;
+ max-height: 200px;
+ overflow-y: auto;
+ }
+
+ .inspector.visible {
+ transform: translateY(0);
+ }
+
+ .inspector-content {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ max-width: 800px;
+ margin: 0 auto;
+ }
+
+ .inspector-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .inspector-title {
+ font-size: 20px;
+ font-weight: bold;
+ color: #00ffff;
+ margin: 0;
+ }
+
+ .inspector-close {
+ background: transparent;
+ border: 2px solid #ff6666;
+ color: #ff6666;
+ width: 30px;
+ height: 30px;
+ cursor: pointer;
+ font-size: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .inspector-close:hover {
+ background: #ff6666;
+ color: white;
+ }
+
+ .inspector-description {
+ color: #aaa;
+ font-size: 14px;
+ margin: 0;
+ }
+
+ .inspector-requirements {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ font-size: 12px;
+ color: #888;
+ }
+
+ .unlock-button {
+ background: #00ff00;
+ color: #000;
+ border: none;
+ padding: 12px 24px;
+ font-size: 16px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-family: inherit;
+ }
+
+ .unlock-button:hover:not(:disabled) {
+ background: #00cc00;
+ transform: scale(1.05);
+ }
+
+ .unlock-button:disabled {
+ background: #666;
+ color: #999;
+ cursor: not-allowed;
+ }
+
+ .error-message {
+ color: #ff6666;
+ font-size: 12px;
+ margin-top: 5px;
+ }
+ `;
+ }
+
+ static get properties() {
+ return {
+ unit: { type: Object },
+ treeDef: { type: Object },
+ selectedNodeId: { type: String },
+ updateTrigger: { type: Number }, // Triggers update when unlocked nodes change
+ };
+ }
+
+ constructor() {
+ super();
+ this.unit = null;
+ this.treeDef = null;
+ this.selectedNodeId = null;
+ this.updateTrigger = 0;
+ this.nodeRefs = new Map();
+ this.resizeObserver = null;
+ this.connectionPaths = [];
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this._setupResizeObserver();
+ this._scrollToAvailableTier();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect();
+ }
+ }
+
+ updated(changedProperties) {
+ super.updated(changedProperties);
+ if (changedProperties.has("unit") || changedProperties.has("treeDef")) {
+ this._updateConnections();
+ this._scrollToAvailableTier();
+ }
+ if (changedProperties.has("selectedNodeId")) {
+ this._updateConnections();
+ }
+ }
+
+ firstUpdated() {
+ this._updateConnections();
+ this._scrollToAvailableTier();
+ }
+
+ /**
+ * Sets up ResizeObserver to track node positions for connection lines
+ */
+ _setupResizeObserver() {
+ if (typeof ResizeObserver === "undefined") {
+ return;
+ }
+
+ this.resizeObserver = new ResizeObserver(() => {
+ this._updateConnections();
+ });
+
+ // Observe the tree container
+ const container = this.shadowRoot?.querySelector(".tree-container");
+ if (container) {
+ this.resizeObserver.observe(container);
+ }
+ }
+
+ /**
+ * Gets or creates a simple tree definition from unit
+ * @returns {Object}
+ */
+ _getTreeDefinition() {
+ if (this.treeDef) {
+ return this.treeDef;
+ }
+
+ if (!this.unit) {
+ return null;
+ }
+
+ // Create a simple mock tree for demonstration
+ // In production, this would come from SkillTreeFactory
+ return {
+ id: `TREE_${this.unit.activeClassId}`,
+ nodes: {
+ ROOT: {
+ id: "ROOT",
+ tier: 1,
+ type: "STAT_BOOST",
+ children: ["NODE_1", "NODE_2"],
+ data: { stat: "health", value: 10 },
+ req: 1,
+ cost: 1,
+ },
+ NODE_1: {
+ id: "NODE_1",
+ tier: 2,
+ type: "ACTIVE_SKILL",
+ children: ["NODE_3"],
+ data: { name: "Shield Bash", id: "SKILL_SHIELD_BASH" },
+ req: 2,
+ cost: 1,
+ },
+ NODE_2: {
+ id: "NODE_2",
+ tier: 2,
+ type: "STAT_BOOST",
+ children: [],
+ data: { stat: "defense", value: 5 },
+ req: 2,
+ cost: 1,
+ },
+ NODE_3: {
+ id: "NODE_3",
+ tier: 3,
+ type: "PASSIVE_ABILITY",
+ children: [],
+ data: { name: "Iron Skin", id: "PASSIVE_IRON_SKIN" },
+ req: 3,
+ cost: 2,
+ },
+ },
+ };
+ }
+
+ /**
+ * Calculates node status: LOCKED, AVAILABLE, or UNLOCKED
+ * @param {string} nodeId - Node ID
+ * @param {Object} nodeDef - Node definition
+ * @returns {string}
+ */
+ _calculateNodeStatus(nodeId, nodeDef) {
+ if (!this.unit || !this.unit.classMastery) {
+ return "LOCKED";
+ }
+
+ const mastery = this.unit.classMastery[this.unit.activeClassId];
+ if (!mastery) {
+ return "LOCKED";
+ }
+
+ // Check if unlocked
+ if (mastery.unlockedNodes && mastery.unlockedNodes.includes(nodeId)) {
+ return "UNLOCKED";
+ }
+
+ // Check if available (parent unlocked and level requirement met)
+ const unitLevel = mastery.level || 1;
+ const levelReq = nodeDef.req || 1;
+
+ // Find parent nodes
+ const parentNodes = this._findParentNodes(nodeId);
+ const hasUnlockedParent =
+ parentNodes.length === 0 ||
+ parentNodes.some((parentId) =>
+ mastery.unlockedNodes?.includes(parentId)
+ );
+
+ if (hasUnlockedParent && unitLevel >= levelReq) {
+ return "AVAILABLE";
+ }
+
+ return "LOCKED";
+ }
+
+ /**
+ * Finds parent nodes for a given node
+ * @param {string} nodeId - Node ID
+ * @returns {string[]}
+ */
+ _findParentNodes(nodeId) {
+ const tree = this._getTreeDefinition();
+ if (!tree) return [];
+
+ const parents = [];
+ for (const [id, node] of Object.entries(tree.nodes)) {
+ if (node.children && node.children.includes(nodeId)) {
+ parents.push(id);
+ }
+ }
+ return parents;
+ }
+
+ /**
+ * Updates SVG connection lines between nodes
+ */
+ _updateConnections() {
+ const tree = this._getTreeDefinition();
+ if (!tree) return;
+
+ const svgContainer = this.shadowRoot?.querySelector(".connections-overlay");
+ if (!svgContainer) return;
+
+ let svg = svgContainer.querySelector("svg");
+ if (!svg) {
+ svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("width", "100%");
+ svg.setAttribute("height", "100%");
+ svgContainer.appendChild(svg);
+ }
+
+ const container = this.shadowRoot?.querySelector(".tree-container");
+ if (!container) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const paths = [];
+
+ // Clear existing paths
+ svg.innerHTML = "";
+
+ // Draw connections for each node
+ for (const [nodeId, nodeDef] of Object.entries(tree.nodes)) {
+ if (!nodeDef.children || nodeDef.children.length === 0) continue;
+
+ const parentElement = this.shadowRoot?.querySelector(
+ `[data-node-id="${nodeId}"]`
+ );
+ if (!parentElement) continue;
+
+ const parentRect = parentElement.getBoundingClientRect();
+ const parentCenter = {
+ x: parentRect.left + parentRect.width / 2 - containerRect.left,
+ y: parentRect.top + parentRect.height / 2 - containerRect.top,
+ };
+
+ for (const childId of nodeDef.children) {
+ const childElement = this.shadowRoot?.querySelector(
+ `[data-node-id="${childId}"]`
+ );
+ if (!childElement) continue;
+
+ const childRect = childElement.getBoundingClientRect();
+ const childCenter = {
+ x: childRect.left + childRect.width / 2 - containerRect.left,
+ y: childRect.top + childRect.height / 2 - containerRect.top,
+ };
+
+ // Determine line style based on child status
+ const childStatus = this._calculateNodeStatus(childId, tree.nodes[childId]);
+ const pathClass = `connection-line ${childStatus}`;
+
+ // Create path with 90-degree bends (circuit board style)
+ const midX = parentCenter.x;
+ const midY = childCenter.y;
+ const pathData = `M ${parentCenter.x} ${parentCenter.y} L ${midX} ${parentCenter.y} L ${midX} ${midY} L ${childCenter.x} ${midY} L ${childCenter.x} ${childCenter.y}`;
+
+ const path = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "path"
+ );
+ path.setAttribute("d", pathData);
+ path.setAttribute("class", pathClass);
+ svg.appendChild(path);
+ }
+ }
+ }
+
+ /**
+ * Scrolls to the highest tier with an available node
+ */
+ _scrollToAvailableTier() {
+ const tree = this._getTreeDefinition();
+ if (!tree || !this.unit) return;
+
+ // Find highest tier with available nodes
+ let highestTier = 0;
+ let targetElement = null;
+
+ for (const [nodeId, nodeDef] of Object.entries(tree.nodes)) {
+ const status = this._calculateNodeStatus(nodeId, nodeDef);
+ if (status === "AVAILABLE" && nodeDef.tier > highestTier) {
+ highestTier = nodeDef.tier;
+ const element = this.shadowRoot?.querySelector(
+ `[data-node-id="${nodeId}"]`
+ );
+ if (element) {
+ targetElement = element;
+ }
+ }
+ }
+
+ if (targetElement) {
+ setTimeout(() => {
+ targetElement.scrollIntoView({ behavior: "smooth", block: "center" });
+ }, 100);
+ }
+ }
+
+ /**
+ * Handles node click
+ * @param {string} nodeId - Node ID
+ */
+ _handleNodeClick(nodeId) {
+ this.selectedNodeId = nodeId;
+ this.requestUpdate();
+ }
+
+ /**
+ * Handles unlock button click
+ */
+ _handleUnlock() {
+ if (!this.selectedNodeId) return;
+
+ const tree = this._getTreeDefinition();
+ if (!tree) return;
+
+ const nodeDef = tree.nodes[this.selectedNodeId];
+ if (!nodeDef) return;
+
+ const cost = nodeDef.cost || 1;
+
+ // Dispatch unlock request event
+ this.dispatchEvent(
+ new CustomEvent("unlock-request", {
+ detail: {
+ nodeId: this.selectedNodeId,
+ cost: cost,
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ /**
+ * Gets node icon based on type
+ * @param {Object} nodeDef - Node definition
+ * @returns {string}
+ */
+ _getNodeIcon(nodeDef) {
+ if (!nodeDef || !nodeDef.type) return "❓";
+
+ switch (nodeDef.type) {
+ case "STAT_BOOST":
+ return "📈";
+ case "ACTIVE_SKILL":
+ return "⚔️";
+ case "PASSIVE_ABILITY":
+ return "✨";
+ default:
+ return "🔷";
+ }
+ }
+
+ /**
+ * Gets node name/title
+ * @param {Object} nodeDef - Node definition
+ * @returns {string}
+ */
+ _getNodeName(nodeDef) {
+ if (nodeDef.data?.name) {
+ return nodeDef.data.name;
+ }
+ if (nodeDef.type === "STAT_BOOST" && nodeDef.data?.stat) {
+ return `${nodeDef.data.stat} +${nodeDef.data.value || 0}`;
+ }
+ return nodeDef.type || "Unknown";
+ }
+
+ /**
+ * Gets unlock validation error message
+ * @param {string} nodeId - Node ID
+ * @returns {string|null}
+ */
+ _getUnlockError(nodeId) {
+ if (!this.unit || !this.selectedNodeId) return null;
+
+ const tree = this._getTreeDefinition();
+ if (!tree) return "Tree definition not found";
+
+ const nodeDef = tree.nodes[nodeId];
+ if (!nodeDef) return "Node not found";
+
+ const status = this._calculateNodeStatus(nodeId, nodeDef);
+ const mastery = this.unit.classMastery[this.unit.activeClassId];
+
+ if (status === "LOCKED") {
+ const levelReq = nodeDef.req || 1;
+ const unitLevel = mastery?.level || 1;
+ const parentNodes = this._findParentNodes(nodeId);
+
+ if (parentNodes.length > 0) {
+ const unlockedParents = parentNodes.filter((pid) =>
+ mastery.unlockedNodes?.includes(pid)
+ );
+ if (unlockedParents.length === 0) {
+ const firstParent = tree.nodes[parentNodes[0]];
+ return `Requires: ${this._getNodeName(firstParent)}`;
+ }
+ }
+
+ if (unitLevel < levelReq) {
+ return `Requires Level ${levelReq}`;
+ }
+
+ return "Node is locked";
+ }
+
+ if (status === "AVAILABLE") {
+ const cost = nodeDef.cost || 1;
+ const skillPoints = mastery?.skillPoints || 0;
+
+ if (skillPoints < cost) {
+ return "Insufficient Points";
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Groups nodes by tier
+ * @returns {Object}
+ */
+ _groupNodesByTier() {
+ const tree = this._getTreeDefinition();
+ if (!tree) return {};
+
+ const tiers = {};
+ for (const [nodeId, nodeDef] of Object.entries(tree.nodes)) {
+ const tier = nodeDef.tier || 1;
+ if (!tiers[tier]) {
+ tiers[tier] = [];
+ }
+ tiers[tier].push({ id: nodeId, def: nodeDef });
+ }
+
+ return tiers;
+ }
+
+ render() {
+ if (!this.unit) {
+ return html`
No unit selected
`;
+ }
+
+ const tree = this._getTreeDefinition();
+ if (!tree) {
+ return html`
No skill tree available
`;
+ }
+
+ const tiers = this._groupNodesByTier();
+ const sortedTiers = Object.keys(tiers)
+ .map(Number)
+ .sort((a, b) => b - a); // Reverse order for column-reverse
+
+ const selectedNodeDef =
+ this.selectedNodeId && tree.nodes[this.selectedNodeId]
+ ? tree.nodes[this.selectedNodeId]
+ : null;
+ const selectedStatus = selectedNodeDef
+ ? this._calculateNodeStatus(this.selectedNodeId, selectedNodeDef)
+ : null;
+ const unlockError = selectedNodeDef
+ ? this._getUnlockError(this.selectedNodeId)
+ : null;
+ const mastery = this.unit.classMastery?.[this.unit.activeClassId];
+ const skillPoints = mastery?.skillPoints || 0;
+ const canUnlock =
+ selectedStatus === "AVAILABLE" &&
+ !unlockError &&
+ skillPoints >= (selectedNodeDef?.cost || 1);
+
+ return html`
+
+
+
+
+
+
+
+
+ ${sortedTiers.map(
+ (tier) => html`
+
+
Tier ${tier}
+ ${tiers[tier].map(
+ ({ id, def }) => {
+ const status = this._calculateNodeStatus(id, def);
+ return html`
+
this._handleNodeClick(id)}"
+ title="${this._getNodeName(def)}"
+ >
+
+
+
${this._getNodeIcon(def)}
+
+
+
+
+
+
+
+
+ `;
+ }
+ )}
+
+ `
+ )}
+
+
+
+
+
+
+ ${selectedNodeDef
+ ? html`
+
+
+ ${selectedNodeDef.data?.description ||
+ `Type: ${selectedNodeDef.type}`}
+
+ ${selectedNodeDef.req
+ ? html`
+ Level Requirement: ${selectedNodeDef.req}
+ Cost: ${selectedNodeDef.cost || 1} Skill Point(s)
+
`
+ : ""}
+
+ ${selectedStatus === "UNLOCKED"
+ ? "Unlocked"
+ : selectedStatus === "AVAILABLE"
+ ? `Unlock (${selectedNodeDef.cost || 1} SP)`
+ : "Locked"}
+
+ ${unlockError
+ ? html`
${unlockError}
`
+ : ""}
+ `
+ : html`
Select a node to view details
`}
+
+
+ `;
+ }
+}
+
+customElements.define("skill-tree-ui", SkillTreeUI);
diff --git a/src/ui/deployment-hud.js b/src/ui/deployment-hud.js
index a230f6d..57d3052 100644
--- a/src/ui/deployment-hud.js
+++ b/src/ui/deployment-hud.js
@@ -70,24 +70,90 @@ export class DeploymentHUD extends LitElement {
transform: translateY(-5px);
}
- .unit-card.selected {
+ .unit-card[selected] {
border-color: #00ffff;
box-shadow: 0 0 15px #00ffff;
}
- .unit-card.deployed {
+ .unit-card[deployed] {
border-color: #00ff00;
opacity: 0.5;
}
+ .unit-card[suggested] {
+ border-color: #ffaa00;
+ box-shadow: 0 0 10px rgba(255, 170, 0, 0.5);
+ background: #332200;
+ }
+
+ .unit-card[suggested]:hover {
+ background: #443300;
+ }
+
+ /* Selected takes priority over suggested */
+ .unit-card[selected][suggested] {
+ border-color: #00ffff;
+ box-shadow: 0 0 15px #00ffff;
+ background: #223322; /* Slightly green-tinted background to show it's both */
+ }
+
+ .unit-card[selected][suggested]:hover {
+ background: #334433;
+ }
+
+ .tutorial-hint {
+ position: absolute;
+ top: 80px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(0, 0, 0, 0.9);
+ border: 2px solid #ffaa00;
+ padding: 15px 25px;
+ text-align: center;
+ pointer-events: auto;
+ font-size: 1rem;
+ color: #ffaa00;
+ max-width: 500px;
+ border-radius: 5px;
+ box-shadow: 0 0 20px rgba(255, 170, 0, 0.3);
+ }
+
+ .unit-portrait {
+ width: 100%;
+ height: 60%;
+ object-fit: cover;
+ background: #111;
+ border-bottom: 1px solid #444;
+ }
+
.unit-icon {
font-size: 2rem;
margin-bottom: 5px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 60%;
+ background: #111;
+ border-bottom: 1px solid #444;
}
+
+ .unit-info {
+ height: 40%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding: 5px;
+ width: 100%;
+ box-sizing: border-box;
+ }
+
.unit-name {
font-size: 0.8rem;
text-align: center;
font-weight: bold;
+ margin-bottom: 2px;
}
.unit-class {
font-size: 0.7rem;
@@ -132,6 +198,7 @@ export class DeploymentHUD extends LitElement {
selectedId: { type: String }, // ID of unit currently being placed
maxUnits: { type: Number },
currentState: { type: String }, // Current game state
+ missionDef: { type: Object }, // Mission definition for tutorial hints and suggested units
};
}
@@ -139,17 +206,29 @@ export class DeploymentHUD extends LitElement {
super();
this.squad = [];
this.deployedIds = [];
+ this.deployedIndices = []; // Store indices from GameLoop
this.selectedId = null;
this.maxUnits = 4;
this.currentState = null;
+ this.missionDef = null;
window.addEventListener("deployment-update", (e) => {
- this.deployedIds = e.detail.deployedIndices;
+ // Store the indices - we'll convert to IDs when squad is available
+ this.deployedIndices = e.detail.deployedIndices || [];
+ this._updateDeployedIds();
+ this.requestUpdate(); // Trigger re-render
});
window.addEventListener("gamestate-changed", (e) => {
this.currentState = e.detail.newState;
});
}
+ updated(changedProperties) {
+ // Update deployedIds when squad changes
+ if (changedProperties.has("squad")) {
+ this._updateDeployedIds();
+ }
+ }
+
render() {
// Hide the deployment HUD when not in deployment state
// Show by default (when currentState is null) since we start in deployment
@@ -160,9 +239,18 @@ export class DeploymentHUD extends LitElement {
return html``;
}
+ // Ensure deployedIds is up to date
+ this._updateDeployedIds();
+
const deployedCount = this.deployedIds.length;
const canStart = deployedCount > 0; // At least 1 unit required
+ // Get tutorial hint and suggested units from mission definition
+ const tutorialHint = this.missionDef?.deployment?.tutorial_hint;
+ const suggestedUnits = this.missionDef?.deployment?.suggested_units || [];
+ const defaultHint =
+ "Select a unit below, then click a green tile to place.";
+
return html`
- Select a unit below, then click a green tile to place.
+ ${tutorialHint || defaultHint}