From de8d3d7c98eb94367f00ba3215c1c461023fa16c Mon Sep 17 00:00:00 2001 From: HikeMap User Date: Sun, 11 Jan 2026 14:08:58 -0600 Subject: [PATCH] Add player attack animations in combat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add playerAttack CSS animation (rubber band toward monster) - Add animatePlayerAttack() function for player icon animation - Add scrollToMonsterAndAnimate() for scrolling to target monsters - Add executeAnimatedPlayerSkill() for animated multi-target attacks - Modify executeMultiHitSkill() to animate each hit with scrolling - Modify executePlayerSkill() to animate single and multi-target attacks - Player attacks now animate before damage, then scroll to target monster 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- index.html | 293 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 262 insertions(+), 31 deletions(-) diff --git a/index.html b/index.html index 40b30cf..c60f21e 100644 --- a/index.html +++ b/index.html @@ -2691,6 +2691,32 @@ top: 0; left: 0; } + /* Player attack animation - rubber band toward monster (right side) */ + @keyframes playerAttack { + 0% { + transform: translateX(0); + } + 20% { + transform: translateX(-20px) scale(0.9); + } + 50% { + transform: translateX(30px) scale(1.15); + } + 70% { + transform: translateX(5px) scale(1.05); + } + 100% { + transform: translateX(0) scale(1); + } + } + .player-entry.attacking { + border-color: #4ecdc4; + box-shadow: 0 0 15px rgba(78, 205, 196, 0.6); + background: rgba(78, 205, 196, 0.15); + } + .player-entry.attacking .player-entry-icon { + animation: playerAttack 0.5s ease-out; + } .monster-side { flex: 1; max-width: 200px; @@ -16190,6 +16216,192 @@ } } + // Animate player attack - returns a Promise that resolves when animation completes + function animatePlayerAttack() { + return new Promise((resolve) => { + const playerEntry = document.querySelector('.player-entry'); + if (!playerEntry) { + resolve(); + return; + } + + // Add attacking class to trigger animation + playerEntry.classList.add('attacking'); + + // Remove class after animation completes + setTimeout(() => { + playerEntry.classList.remove('attacking'); + resolve(); + }, 500); + }); + } + + // Scroll to a monster and animate receiving damage - returns Promise + function scrollToMonsterAndAnimate(monsterIndex, animationType = 'attack') { + return new Promise((resolve) => { + const container = document.getElementById('monsterList'); + if (!container) { + resolve(); + return; + } + + const entries = container.querySelectorAll('.monster-entry'); + const entry = entries[monsterIndex]; + if (!entry) { + resolve(); + return; + } + + // Scroll the monster into view smoothly + entry.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + // Highlight the entry being attacked + entry.classList.add('selected'); + + // Wait for scroll, then animate + setTimeout(() => { + const icon = entry.querySelector('.monster-entry-icon'); + if (icon) { + // Simple hit reaction animation + icon.style.animation = 'none'; + icon.offsetHeight; // Force reflow + icon.style.animation = 'monsterAttack 0.4s ease-out reverse'; + + setTimeout(() => { + icon.style.animation = ''; + resolve(); + }, 400); + } else { + resolve(); + } + }, 300); + }); + } + + // Execute player skill with full animation sequence + async function executeAnimatedPlayerSkill(skillId, targets, skill, displayName, rawSkill) { + const hitCount = skill.hitCount || skill.hits || 1; + const skillAccuracy = rawSkill ? (rawSkill.accuracy || 95) : 95; + const skillTarget = rawSkill ? rawSkill.target : (skill.target || 'enemy'); + + let rawDamage; + if (rawSkill && rawSkill.calculate) { + rawDamage = rawSkill.calculate(combatState.player.atk); + } else if (rawSkill && rawSkill.basePower) { + rawDamage = Math.floor(combatState.player.atk * (rawSkill.basePower / 100)); + } else { + rawDamage = combatState.player.atk; + } + + let grandTotalDamage = 0; + let monstersHit = 0; + let monstersKilled = 0; + + // For each target (or each hit for multi-hit skills) + for (let i = 0; i < targets.length; i++) { + const targetIndex = targets[i]; + const currentTarget = combatState.monsters[targetIndex]; + + // Skip if already dead + if (currentTarget.hp <= 0) { + if (targets.length > 1) { + addCombatLog(`💨 ${currentTarget.data.name} already defeated!`, 'info'); + } + continue; + } + + // Animate player attacking + await animatePlayerAttack(); + + // Scroll to and highlight the target + await scrollToMonsterAndAnimate(targetIndex); + + // Calculate hit + const hitChance = calculateHitChance( + combatState.player.accuracy, + currentTarget.dodge, + skillAccuracy + ); + + if (!rollHit(hitChance)) { + addCombatLog(`❌ ${displayName} missed ${currentTarget.data.name}! (${hitChance}% chance)`, 'miss'); + playSfx('missed'); + // Brief pause before next attack + await new Promise(r => setTimeout(r, 300)); + continue; + } + + monstersHit++; + + // Calculate effective defense + let effectiveMonsterDef = currentTarget.def; + if (currentTarget.buffs && currentTarget.buffs.defense && currentTarget.buffs.defense.turnsLeft > 0) { + const buffPercent = currentTarget.buffs.defense.percent || 50; + effectiveMonsterDef = Math.floor(currentTarget.def * (1 + buffPercent / 100)); + } + + // For same_target hitCount, apply all hits at once + const hitsOnThisTarget = (skillTarget !== 'all_enemies' && targets.length === 1) ? hitCount : 1; + let totalDamage = 0; + + for (let hit = 0; hit < hitsOnThisTarget; hit++) { + const baseDamage = calculateDamage(rawDamage, effectiveMonsterDef); + const damage = applyDamageVariance(baseDamage); + totalDamage += damage; + currentTarget.hp -= damage; + } + + grandTotalDamage += totalDamage; + + // Log the hit + if (hitsOnThisTarget > 1) { + addCombatLog(`✨ ${displayName} hits ${currentTarget.data.name} ${hitsOnThisTarget} times for ${totalDamage} total damage!`, 'damage'); + playSfx('player_skill'); + } else { + addCombatLog(`⚔️ ${displayName} hits ${currentTarget.data.name} for ${totalDamage} damage!`, 'damage'); + playSfx('player_attack'); + } + + // Check if killed + if (currentTarget.hp <= 0) { + monstersKilled++; + recordMonsterKill(currentTarget); + animateMonsterAttack(targetIndex, 'death'); + playSfx('monster_death'); + + const xpReward = (currentTarget.data?.xpReward || 10) * currentTarget.level; + playerStats.xp += xpReward; + combatState.player.xpGained = (combatState.player.xpGained || 0) + xpReward; + + addCombatLog(`💀 ${currentTarget.data.name} was defeated! +${xpReward} XP`, 'victory'); + removeMonster(currentTarget.id); + checkLevelUp(); + savePlayerStats(); + updateRpgHud(); + } + + // Update UI after each hit + updateCombatUI(); + renderMonsterList(); + + // Brief pause between targets + if (i < targets.length - 1) { + await new Promise(r => setTimeout(r, 400)); + } + } + + // Summary for multi-target skills + if (targets.length > 1 && skillTarget === 'all_enemies') { + if (monstersHit === 0) { + addCombatLog(`❌ ${displayName} missed all enemies!`, 'miss'); + } else { + addCombatLog(`🌟 ${displayName} complete: ${monstersHit} enemies hit for ${grandTotalDamage} total damage!`, 'damage'); + } + } + + return { monstersHit, grandTotalDamage, monstersKilled }; + } + // Render the monster list in combat UI function renderMonsterList() { const container = document.getElementById('monsterList'); @@ -16326,8 +16538,8 @@ renderMonsterList(); } - // Execute multi-hit skill with selected targets - function executeMultiHitSkill() { + // Execute multi-hit skill with selected targets (animated version) + async function executeMultiHitSkill() { if (!combatState || !combatState.targetingMode || !combatState.pendingSkill) return; const skill = combatState.pendingSkill; @@ -16352,19 +16564,24 @@ let grandTotalDamage = 0; let hitsLanded = 0; let monstersKilled = 0; - const hitResults = []; // Track results for each hit - // Process each hit with its selected target + // Process each hit with animation for (let hitNum = 0; hitNum < targetIndices.length; hitNum++) { const targetIndex = targetIndices[hitNum]; const currentTarget = combatState.monsters[targetIndex]; // Skip if target is already dead (from previous hit) if (currentTarget.hp <= 0) { - hitResults.push({ hit: hitNum + 1, target: currentTarget.data.name, result: 'already defeated' }); + addCombatLog(`💨 Hit ${hitNum + 1}: ${currentTarget.data.name} already defeated!`, 'info'); continue; } + // Animate player attacking + await animatePlayerAttack(); + + // Scroll to and highlight the target monster + await scrollToMonsterAndAnimate(targetIndex); + // Calculate hit chance const hitChance = calculateHitChance( combatState.player.accuracy, @@ -16374,7 +16591,9 @@ // Roll for hit if (!rollHit(hitChance)) { - hitResults.push({ hit: hitNum + 1, target: currentTarget.data.name, result: 'miss', hitChance }); + addCombatLog(`❌ Hit ${hitNum + 1}: Missed ${currentTarget.data.name}! (${hitChance}% chance)`, 'miss'); + playSfx('missed'); + await new Promise(r => setTimeout(r, 300)); continue; } @@ -16392,45 +16611,34 @@ grandTotalDamage += damage; hitsLanded++; - hitResults.push({ hit: hitNum + 1, target: currentTarget.data.name, result: 'hit', damage }); + addCombatLog(`⚔️ Hit ${hitNum + 1}: ${damage} damage to ${currentTarget.data.name}!`, 'damage'); + playSfx('player_attack'); // Check if this killed the monster if (currentTarget.hp <= 0) { monstersKilled++; - recordMonsterKill(currentTarget); // Track kill for bestiary - // Play death animation + recordMonsterKill(currentTarget); animateMonsterAttack(targetIndex, 'death'); playSfx('monster_death'); - // Award XP immediately const xpReward = (currentTarget.data?.xpReward || 10) * currentTarget.level; playerStats.xp += xpReward; combatState.player.xpGained = (combatState.player.xpGained || 0) + xpReward; - hitResults[hitResults.length - 1].killed = true; - hitResults[hitResults.length - 1].xp = xpReward; + addCombatLog(`💀 ${currentTarget.data.name} defeated! +${xpReward} XP`, 'victory'); removeMonster(currentTarget.id); checkLevelUp(); savePlayerStats(); updateRpgHud(); } - } - // Log results for each hit - hitResults.forEach(r => { - if (r.result === 'miss') { - addCombatLog(`❌ Hit ${r.hit}: Missed ${r.target}! (${r.hitChance}% chance)`, 'miss'); - playSfx('missed'); - } else if (r.result === 'already defeated') { - addCombatLog(`💨 Hit ${r.hit}: ${r.target} already defeated!`, 'info'); - } else if (r.result === 'hit') { - if (r.killed) { - addCombatLog(`⚔️ Hit ${r.hit}: ${r.damage} damage to ${r.target}!`, 'damage'); - addCombatLog(`💀 ${r.target} defeated! +${r.xp} XP`, 'victory'); - } else { - addCombatLog(`⚔️ Hit ${r.hit}: ${r.damage} damage to ${r.target}!`, 'damage'); - } - playSfx('player_attack'); + // Update UI after each hit + updateCombatUI(); + renderMonsterList(); + + // Brief pause between hits + if (hitNum < targetIndices.length - 1) { + await new Promise(r => setTimeout(r, 400)); } - }); + } // Summary if (hitsLanded > 0) { @@ -16581,8 +16789,8 @@ return true; } - // Execute a player skill - function executePlayerSkill(skillId) { + // Execute a player skill (async for animations) + async function executePlayerSkill(skillId) { console.log('[DEBUG] executePlayerSkill called with:', skillId); if (!combatState || combatState.turn !== 'player') { console.log('[DEBUG] Early return - combatState:', !!combatState, 'turn:', combatState?.turn); @@ -16692,6 +16900,29 @@ // Determine targets based on skill.target (reuse variables from above) const targets = skillTarget === 'all_enemies' ? livingMonsters : [target]; + // Animate player attack + await animatePlayerAttack(); + + // For all_enemies skills with multiple targets, animate each hit sequentially + if (skillTarget === 'all_enemies' && targets.length > 1) { + const targetIndices = targets.map(t => combatState.monsters.indexOf(t)); + await executeAnimatedPlayerSkill(skillId, targetIndices, skill, displayName, dbSkill || hardcodedSkill); + + // Check victory and end turn + const remainingMonsters = combatState.monsters.filter(m => m.hp > 0); + if (remainingMonsters.length === 0) { + handleCombatVictory(); + return; + } + endPlayerTurn(); + return; + } + + // Scroll to the target monster for single-target attacks + if (targets.length === 1) { + await scrollToMonsterAndAnimate(combatState.selectedTargetIndex); + } + // Calculate base damage - support both old calculate() and new basePower let rawDamage; if (hardcodedSkill && hardcodedSkill.calculate) {