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) {