From 178389309dcb6b3fe67461ff3d254d77ee78dbdf Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Sat, 27 Dec 2025 17:21:31 -0800 Subject: [PATCH] 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. --- src/core/GameLoop.js | 161 ++++++++++++++++++++++++++++++++++++--- src/grid/VoxelManager.js | 6 +- src/index.js | 11 +++ src/ui/combat-hud.js | 73 +++++++++++++++++- src/ui/game-viewport.js | 7 ++ 5 files changed, 244 insertions(+), 14 deletions(-) diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index a380592..e162e0d 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -312,6 +312,43 @@ export class GameLoop { // Open character sheet for active unit this.openCharacterSheet(); } + if (code === "KeyM") { + // Movement mode hotkey + if (this.gameStateManager?.currentState === "STATE_COMBAT") { + this.onMovementClicked(); + } + } + // Number key hotkeys for skills (1-5) + if ( + this.gameStateManager?.currentState === "STATE_COMBAT" && + this.turnSystem + ) { + const activeUnit = this.turnSystem.getActiveUnit(); + if (activeUnit && activeUnit.team === "PLAYER") { + const skills = activeUnit.actions || []; + let skillIndex = -1; + + // Map key codes to skill indices (1-5) + if (code === "Digit1" || code === "Numpad1") { + skillIndex = 0; + } else if (code === "Digit2" || code === "Numpad2") { + skillIndex = 1; + } else if (code === "Digit3" || code === "Numpad3") { + skillIndex = 2; + } else if (code === "Digit4" || code === "Numpad4") { + skillIndex = 3; + } else if (code === "Digit5" || code === "Numpad5") { + skillIndex = 4; + } + + if (skillIndex >= 0 && skillIndex < skills.length) { + const skill = skills[skillIndex]; + if (skill && skill.id) { + this.onSkillClicked(skill.id); + } + } + } + } } /** @@ -488,6 +525,12 @@ export class GameLoop { const activeUnit = this.turnSystem.getActiveUnit(); if (!activeUnit || activeUnit.team !== "PLAYER") return; + // If clicking the same skill that's already active, cancel targeting + if (this.combatState === "TARGETING_SKILL" && this.activeSkillId === skillId) { + this.cancelSkillTargeting(); + return; + } + // Find skill in unit's actions const skill = (activeUnit.actions || []).find((a) => a.id === skillId); if (!skill) { @@ -516,6 +559,9 @@ export class GameLoop { ); } + // Update combat state to refresh UI (show cancel button) + this.updateCombatState().catch(console.error); + console.log(`Entering targeting mode for skill: ${skillId}`); } @@ -655,6 +701,29 @@ export class GameLoop { this.updateMovementHighlights(activeUnit); } } + // Update combat state to refresh UI + this.updateCombatState().catch(console.error); + } + + /** + * Handles movement button click from CombatHUD. + * Returns to movement mode from skill targeting. + */ + onMovementClicked() { + if (!this.turnSystem) return; + + const activeUnit = this.turnSystem.getActiveUnit(); + if (!activeUnit || activeUnit.team !== "PLAYER") return; + + // If we're in skill targeting mode, cancel it and return to movement + if (this.combatState === "TARGETING_SKILL") { + this.cancelSkillTargeting(); + } else { + // If already in movement mode, ensure movement highlights are shown + this.updateMovementHighlights(activeUnit); + // Update combat state to refresh UI + this.updateCombatState().catch(console.error); + } } /** @@ -1576,9 +1645,28 @@ export class GameLoop { // Build active unit status if we have an active unit (for UI) let unitStatus = null; if (activeUnit) { - // Calculate max AP using formula: 3 + floor(speed/5) - const speed = activeUnit.baseStats?.speed || 10; - const maxAP = 3 + Math.floor(speed / 5); + // Calculate effective speed (including equipment and skill tree bonuses) + let effectiveSpeed = activeUnit.baseStats?.speed || 10; + + // Add equipment bonuses if available + if (activeUnit.loadout && this.inventoryManager) { + const loadoutSlots = ["mainHand", "offHand", "body", "accessory"]; + for (const slot of loadoutSlots) { + const itemInstance = activeUnit.loadout[slot]; + if (itemInstance) { + const itemDef = this.inventoryManager.itemRegistry?.get( + itemInstance.defId + ); + if (itemDef && itemDef.stats && itemDef.stats.speed) { + effectiveSpeed += itemDef.stats.speed; + } + } + } + } + + // Calculate max AP using formula: 3 + floor(effectiveSpeed/5) + // We'll add skill tree bonuses to speed below when we generate the skill tree + let maxAP = 3 + Math.floor(effectiveSpeed / 5); // Convert status effects to status icons const statuses = (activeUnit.statusEffects || []).map((effect) => ({ @@ -1642,6 +1730,22 @@ export class GameLoop { const factory = new SkillTreeFactory(templateRegistry, skillMap); const skillTree = factory.createTree(classDef); + // Add speed boosts from unlocked nodes to effective speed + for (const nodeId of mastery.unlockedNodes) { + const nodeDef = skillTree.nodes?.[nodeId]; + if ( + nodeDef && + nodeDef.type === "STAT_BOOST" && + nodeDef.data && + nodeDef.data.stat === "speed" + ) { + effectiveSpeed += nodeDef.data.value || 0; + } + } + + // Recalculate maxAP with skill tree bonuses + maxAP = 3 + Math.floor(effectiveSpeed / 5); + // Add unlocked ACTIVE_SKILL nodes to skills array for (const nodeId of mastery.unlockedNodes) { const nodeDef = skillTree.nodes?.[nodeId]; @@ -1654,18 +1758,41 @@ export class GameLoop { // Add skill to skills array (avoid duplicates) if (!skills.find((s) => s.id === skillId)) { - // Get costAP and cooldown from full skill definition + // Get costAP from full skill definition const costAP = fullSkill?.costs?.ap || skillData.costAP || 3; - const cooldown = fullSkill?.cooldown_turns || skillData.cooldown || 0; + const baseCooldown = fullSkill?.cooldown_turns || skillData.cooldown || 0; + + // Ensure skill exists in unit.actions for cooldown tracking + if (!activeUnit.actions) { + activeUnit.actions = []; + } + let existingAction = activeUnit.actions.find( + (a) => a.id === skillId + ); + + // If action doesn't exist, create it with cooldown 0 (ready to use immediately) + if (!existingAction) { + existingAction = { + id: skillId, + name: skillData.name || fullSkill?.name || "Unknown Skill", + icon: skillData.icon || fullSkill?.icon || "⚔", + costAP: costAP, + cooldown: 0, // Newly unlocked skills start ready to use + }; + activeUnit.actions.push(existingAction); + } + + // Use current cooldown from the action (which gets decremented by TurnSystem) + const currentCooldown = existingAction.cooldown || 0; skills.push({ id: skillId, - name: skillData.name || fullSkill?.name || "Unknown Skill", - icon: skillData.icon || fullSkill?.icon || "⚔", + name: existingAction.name, + icon: existingAction.icon, costAP: costAP, - cooldown: cooldown, + cooldown: currentCooldown, isAvailable: - activeUnit.currentAP >= costAP && cooldown === 0, + activeUnit.currentAP >= costAP && currentCooldown === 0, }); } } @@ -1796,7 +1923,8 @@ export class GameLoop { // UI-enriched fields (for backward compatibility) activeUnit: unitStatus, // Object for UI enrichedQueue: enrichedQueue, // Objects for UI display - targetingMode: false, // Will be set when player selects a skill + targetingMode: this.combatState === "TARGETING_SKILL", // True when player is targeting a skill + activeSkillId: this.activeSkillId || null, // ID of the skill being targeted (for UI toggle state) roundNumber: turnSystemState.round, // Alias for UI }; @@ -1818,6 +1946,19 @@ export class GameLoop { return; } + // Clear any active skill targeting state and highlights + if (this.combatState === "TARGETING_SKILL" || this.activeSkillId) { + this.combatState = "IDLE"; + this.activeSkillId = null; + // Clear skill targeting highlights (range and AoE reticle) + if (this.voxelManager) { + this.voxelManager.clearHighlights(); + } + } + + // Clear movement highlights + this.clearMovementHighlights(); + // DELEGATE to TurnSystem this.turnSystem.endTurn(activeUnit); diff --git a/src/grid/VoxelManager.js b/src/grid/VoxelManager.js index 7c8ef3f..2ad3535 100644 --- a/src/grid/VoxelManager.js +++ b/src/grid/VoxelManager.js @@ -246,7 +246,7 @@ export class VoxelManager { // mesh.material.dispose(); } - mesh.dispose(); + // Note: Mesh objects don't have a dispose() method, only geometry and material do }); this.meshes.clear(); @@ -514,7 +514,7 @@ export class VoxelManager { mesh.material.dispose(); } } - mesh.dispose(); + // Note: Mesh objects don't have a dispose() method, only geometry and material do }); this.rangeHighlights.clear(); } @@ -534,7 +534,7 @@ export class VoxelManager { mesh.material.dispose(); } } - mesh.dispose(); + // Note: Mesh objects don't have a dispose() method, only geometry and material do }); this.aoeReticle.clear(); } diff --git a/src/index.js b/src/index.js index 50e2197..13a6ffa 100644 --- a/src/index.js +++ b/src/index.js @@ -150,9 +150,20 @@ window.addEventListener("open-character-sheet", async (e) => { console.log(`Unlocked node ${nodeId} for ${cost} skill points`); }; + // Handle skill-unlocked event to refresh combat HUD + const handleSkillUnlocked = (event) => { + const { unitId } = event.detail; + // If we're in combat and the gameLoop exists, update combat state to refresh the HUD + if (gameStateManager?.gameLoop && gameStateManager.currentState === "STATE_COMBAT") { + // Update combat state to refresh the HUD with newly unlocked skills + gameStateManager.gameLoop.updateCombatState().catch(console.error); + } + }; + characterSheet.addEventListener("close", handleClose); characterSheet.addEventListener("equip-item", handleEquipItem); characterSheet.addEventListener("unlock-request", handleUnlockRequest); + characterSheet.addEventListener("skill-unlocked", handleSkillUnlocked); // Append to document body - dialog will handle its own display document.body.appendChild(characterSheet); diff --git a/src/ui/combat-hud.js b/src/ui/combat-hud.js index 5c0397c..72f14fa 100644 --- a/src/ui/combat-hud.js +++ b/src/ui/combat-hud.js @@ -278,6 +278,7 @@ export class CombatHUD extends LitElement { position: relative; transition: all 0.2s; pointer-events: auto; + color: white; } .skill-button:hover:not(:disabled) { @@ -292,6 +293,52 @@ export class CombatHUD extends LitElement { 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; @@ -300,6 +347,7 @@ export class CombatHUD extends LitElement { background: rgba(0, 0, 0, 0.8); padding: 2px 4px; border: 1px solid #555; + color: white; } .skill-button .icon { @@ -439,6 +487,15 @@ export class CombatHUD extends LitElement { } } + _handleMovementClick() { + this.dispatchEvent( + new CustomEvent("movement-click", { + bubbles: true, + composed: true, + }) + ); + } + _handleSkillHover(skillId) { this.dispatchEvent( new CustomEvent("hover-skill", { @@ -596,10 +653,24 @@ export class CombatHUD extends LitElement {
+ + ${activeUnit?.skills?.map( (skill, index) => html`