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.
This commit is contained in:
Matthew Mone 2025-12-27 17:21:31 -08:00
parent 68646f7f7b
commit 178389309d
5 changed files with 244 additions and 14 deletions

View file

@ -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);

View file

@ -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();
}

View file

@ -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);

View file

@ -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 {
<!-- Action Bar (Bottom-Center) -->
<div class="action-bar">
<!-- Movement Button -->
<button
class="movement-button ${!this.combatState?.targetingMode
? "active"
: ""}"
@click="${this._handleMovementClick}"
title="Movement Mode (M)"
>
<span class="icon">🚶</span>
<span class="name">Move</span>
</button>
${activeUnit?.skills?.map(
(skill, index) => html`
<button
class="skill-button"
class="skill-button ${this.combatState?.targetingMode &&
this.combatState?.activeSkillId === skill.id
? "active"
: ""}"
?disabled="${!skill.isAvailable}"
@click="${() => this._handleSkillClick(skill.id)}"
@mouseenter="${() => this._handleSkillHover(skill.id)}"

View file

@ -62,6 +62,12 @@ export class GameViewport extends LitElement {
}
}
#handleMovementClick() {
if (gameStateManager.gameLoop) {
gameStateManager.gameLoop.onMovementClicked();
}
}
async firstUpdated() {
const container = this.shadowRoot.getElementById("canvas-container");
const loop = new GameLoop();
@ -127,6 +133,7 @@ export class GameViewport extends LitElement {
.combatState=${this.combatState}
@end-turn=${this.#handleEndTurn}
@skill-click=${this.#handleSkillClick}
@movement-click=${this.#handleMovementClick}
></combat-hud>
<dialogue-overlay></dialogue-overlay>`;
}