|
<script lang="ts"> |
|
import { onMount } from 'svelte'; |
|
import { fade } from 'svelte/transition'; |
|
import type { PicletInstance, BattleMove } from '$lib/db/schema'; |
|
import BattleField from '../Battle/BattleField.svelte'; |
|
import BattleControls from '../Battle/BattleControls.svelte'; |
|
import { BattleEngine } from '$lib/battle-engine/BattleEngine'; |
|
import type { BattleState, MoveAction } from '$lib/battle-engine/types'; |
|
import { picletInstanceToBattleDefinition, battlePicletToInstance, stripBattlePrefix } from '$lib/utils/battleConversion'; |
|
import { calculateBattleXp, processAllLevelUps } from '$lib/services/levelingService'; |
|
import { db } from '$lib/db/index'; |
|
import { getEffectivenessText, getEffectivenessColor } from '$lib/types/picletTypes'; |
|
|
|
export let playerPiclet: PicletInstance; |
|
export let enemyPiclet: PicletInstance; |
|
export let isWildBattle: boolean = true; |
|
export let onBattleEnd: (result: any) => void = () => {}; |
|
export let rosterPiclets: PicletInstance[] = []; |
|
|
|
|
|
let battleEngine: BattleEngine; |
|
let battleState: BattleState; |
|
let currentPlayerPiclet = playerPiclet; |
|
let currentEnemyPiclet = enemyPiclet; |
|
|
|
|
|
let currentMessage = isWildBattle |
|
? `A wild ${enemyPiclet.nickname} appeared!` |
|
: `Trainer wants to battle!`; |
|
let battlePhase: 'intro' | 'main' | 'moveSelect' | 'picletSelect' | 'ended' = 'intro'; |
|
let processingTurn = false; |
|
let battleEnded = false; |
|
|
|
// HP animation states |
|
let playerHpPercentage = playerPiclet.currentHp / playerPiclet.maxHp; |
|
let enemyHpPercentage = enemyPiclet.currentHp / enemyPiclet.maxHp; |
|
|
|
// Visual effects state |
|
let playerEffects: Array<{type: string, emoji: string, duration: number}> = []; |
|
let enemyEffects: Array<{type: string, emoji: string, duration: number}> = []; |
|
let playerFlash = false; |
|
let enemyFlash = false; |
|
let playerFaint = false; |
|
let enemyFaint = false; |
|
|
|
|
|
let battleResultsVisible = false; |
|
let battleResults = { |
|
victory: false, |
|
xpGained: 0, |
|
levelUps: [], |
|
newLevel: 0 |
|
}; |
|
|
|
|
|
onMount(() => { |
|
// Initialize battle engine with converted piclet definitions |
|
const playerDefinition = picletInstanceToBattleDefinition(playerPiclet); |
|
const enemyDefinition = picletInstanceToBattleDefinition(enemyPiclet); |
|
|
|
battleEngine = new BattleEngine(playerDefinition, enemyDefinition, playerPiclet.level, enemyPiclet.level); |
|
battleState = battleEngine.getState(); |
|
|
|
// Start intro sequence |
|
setTimeout(() => { |
|
currentMessage = `Go, ${playerPiclet.nickname}!`; |
|
setTimeout(() => { |
|
currentMessage = `What will ${playerPiclet.nickname} do?`; |
|
battlePhase = 'main'; |
|
}, 1500); |
|
}, 2000); |
|
}); |
|
|
|
function handleAction(action: string) { |
|
if (processingTurn || battleEnded) return; |
|
|
|
switch (action) { |
|
case 'catch': |
|
if (isWildBattle) { |
|
processingTurn = true; |
|
currentMessage = 'You threw a Piclet Ball!'; |
|
setTimeout(() => { |
|
currentMessage = 'The wild piclet broke free!'; |
|
processingTurn = false; |
|
}, 2000); |
|
} |
|
break; |
|
case 'run': |
|
if (isWildBattle) { |
|
currentMessage = 'Got away safely!'; |
|
battleEnded = true; |
|
setTimeout(() => onBattleEnd(false), 1500); |
|
} else { |
|
currentMessage = "You can't run from a trainer battle!"; |
|
} |
|
break; |
|
} |
|
} |
|
|
|
function handleMoveSelect(move: BattleMove) { |
|
if (!battleEngine) return; |
|
|
|
battlePhase = 'main'; |
|
processingTurn = true; |
|
|
|
// Find the corresponding move in the battle engine |
|
const battleMove = battleState.playerPiclet.moves.find(m => m.move.name === move.name); |
|
if (!battleMove) return; |
|
|
|
const moveAction: MoveAction = { |
|
type: 'move', |
|
moveIndex: battleState.playerPiclet.moves.indexOf(battleMove) |
|
}; |
|
|
|
try { |
|
// Choose random enemy move (could be improved with AI) |
|
const availableEnemyMoves = battleState.opponentPiclet.moves.filter(m => m.currentPP > 0); |
|
if (availableEnemyMoves.length === 0) { |
|
currentMessage = `${currentEnemyPiclet.nickname} has no moves left!`; |
|
processingTurn = false; |
|
return; |
|
} |
|
|
|
const randomEnemyMove = availableEnemyMoves[Math.floor(Math.random() * availableEnemyMoves.length)]; |
|
const enemyMoveIndex = battleState.opponentPiclet.moves.indexOf(randomEnemyMove); |
|
const enemyAction: MoveAction = { |
|
type: 'move', |
|
moveIndex: enemyMoveIndex |
|
}; |
|
|
|
|
|
const logBefore = battleEngine.getLog(); |
|
|
|
|
|
battleEngine.executeActions(moveAction, enemyAction); |
|
battleState = battleEngine.getState(); |
|
|
|
|
|
const logAfter = battleEngine.getLog(); |
|
const newLogEntries = logAfter.slice(logBefore.length); |
|
const result = { log: newLogEntries }; |
|
|
|
|
|
if (result.log && result.log.length > 0) { |
|
let messageIndex = 0; |
|
function showNextBattleMessage() { |
|
if (messageIndex < result.log.length) { |
|
const message = result.log[messageIndex]; |
|
currentMessage = message; |
|
|
|
// Trigger visual effects based on message content |
|
triggerVisualEffectsFromMessage(message); |
|
|
|
messageIndex++; |
|
setTimeout(showNextBattleMessage, 1500); |
|
} else { |
|
// After all messages, check battle end or continue |
|
finalizeTurn(); |
|
} |
|
} |
|
showNextBattleMessage(); |
|
} else { |
|
finalizeTurn(); |
|
} |
|
|
|
function finalizeTurn() { |
|
// Update UI state from battle engine |
|
updateUIFromBattleState(); |
|
|
|
// Check for battle end |
|
if (battleState.winner) { |
|
battleEnded = true; |
|
const winMessage = battleState.winner === 'player' |
|
? `${currentEnemyPiclet.nickname} fainted! You won!` |
|
: `${currentPlayerPiclet.nickname} fainted! You lost!`; |
|
currentMessage = winMessage; |
|
|
|
// Process battle results with XP and level ups |
|
setTimeout(async () => { |
|
await handleBattleResults(battleState.winner === 'player'); |
|
}, 2000); |
|
} else { |
|
setTimeout(() => { |
|
currentMessage = `What will ${currentPlayerPiclet.nickname} do?`; |
|
processingTurn = false; |
|
}, 1000); |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Battle engine error:', error); |
|
currentMessage = 'Something went wrong in battle!'; |
|
processingTurn = false; |
|
} |
|
} |
|
|
|
|
|
function triggerVisualEffectsFromMessage(message: string) { |
|
// Use internal battle prefixes for reliable animation targeting |
|
const playerInternalName = battleState?.playerPiclet?.definition?.name || ''; |
|
const enemyInternalName = battleState?.opponentPiclet?.definition?.name || ''; |
|
|
|
// Damage effects |
|
if (message.includes('took') && message.includes('damage')) { |
|
if (message.includes(playerInternalName)) { |
|
triggerDamageFlash('player'); |
|
updateUIFromBattleState(); |
|
} else if (message.includes(enemyInternalName)) { |
|
triggerDamageFlash('enemy'); |
|
updateUIFromBattleState(); |
|
} |
|
} |
|
|
|
|
|
if (message.includes('critical hit')) { |
|
triggerEffect('both', 'critical', '💥', 1000); |
|
} |
|
|
|
|
|
if (message.includes("It's super effective")) { |
|
triggerEffect('both', 'superEffective', '⚡', 800); |
|
} else if (message.includes("not very effective")) { |
|
triggerEffect('both', 'notVeryEffective', '💨', 800); |
|
} |
|
|
|
|
|
if (message.includes('was burned')) { |
|
const target = message.includes(playerInternalName) ? 'player' : 'enemy'; |
|
triggerEffect(target, 'burn', '🔥', 1200); |
|
} else if (message.includes('was poisoned')) { |
|
const target = message.includes(playerInternalName) ? 'player' : 'enemy'; |
|
triggerEffect(target, 'poison', '☠️', 1200); |
|
} else if (message.includes('was paralyzed')) { |
|
const target = message.includes(playerInternalName) ? 'player' : 'enemy'; |
|
triggerEffect(target, 'paralyze', '⚡', 1200); |
|
} else if (message.includes('fell asleep')) { |
|
const target = message.includes(playerInternalName) ? 'player' : 'enemy'; |
|
triggerEffect(target, 'sleep', '😴', 1200); |
|
} else if (message.includes('was frozen')) { |
|
const target = message.includes(playerInternalName) ? 'player' : 'enemy'; |
|
triggerEffect(target, 'freeze', '❄️', 1200); |
|
} |
|
|
|
|
|
if (message.includes("'s") && (message.includes('rose') || message.includes('fell'))) { |
|
const target = message.includes(playerInternalName) ? 'player' : 'enemy'; |
|
const isIncrease = message.includes('rose'); |
|
|
|
if (message.includes('attack')) { |
|
triggerEffect(target, isIncrease ? 'attackUp' : 'attackDown', isIncrease ? '⚔️' : '🔻', 1000); |
|
} else if (message.includes('defense')) { |
|
triggerEffect(target, isIncrease ? 'defenseUp' : 'defenseDown', isIncrease ? '🛡️' : '🔻', 1000); |
|
} else if (message.includes('speed')) { |
|
triggerEffect(target, isIncrease ? 'speedUp' : 'speedDown', isIncrease ? '💨' : '🐌', 1000); |
|
} else if (message.includes('accuracy')) { |
|
triggerEffect(target, isIncrease ? 'accuracyUp' : 'accuracyDown', isIncrease ? '🎯' : '👁️', 1000); |
|
} |
|
} |
|
|
|
|
|
if (message.includes('recovered') && message.includes('HP')) { |
|
const target = message.includes(playerInternalName) ? 'player' : 'enemy'; |
|
triggerEffect(target, 'heal', '💚', 1000); |
|
// Update HP bar immediately for healing animation sync |
|
updateUIFromBattleState(); |
|
} |
|
|
|
|
|
if (message.includes('missed')) { |
|
triggerEffect('both', 'miss', '💫', 800); |
|
} |
|
|
|
|
|
if (message.includes('fainted')) { |
|
if (message.includes(playerInternalName)) { |
|
triggerFaintAnimation('player'); |
|
} else if (message.includes(enemyInternalName)) { |
|
triggerFaintAnimation('enemy'); |
|
} |
|
} |
|
} |
|
|
|
function triggerDamageFlash(target: 'player' | 'enemy') { |
|
if (target === 'player') { |
|
playerFlash = true; |
|
setTimeout(() => playerFlash = false, 1000); // Match original Snaplings flicker duration |
|
} else { |
|
enemyFlash = true; |
|
setTimeout(() => enemyFlash = false, 1000); // Match original Snaplings flicker duration |
|
} |
|
} |
|
|
|
function triggerFaintAnimation(target: 'player' | 'enemy') { |
|
if (target === 'player') { |
|
playerFaint = true; |
|
// Don't reset - faint animation should persist until battle ends |
|
} else { |
|
enemyFaint = true; |
|
// Don't reset - faint animation should persist until battle ends |
|
} |
|
} |
|
|
|
function triggerEffect(target: 'player' | 'enemy' | 'both', type: string, emoji: string, duration: number) { |
|
const effect = { type, emoji, duration }; |
|
|
|
if (target === 'player' || target === 'both') { |
|
playerEffects = [...playerEffects, effect]; |
|
setTimeout(() => { |
|
playerEffects = playerEffects.filter(e => e !== effect); |
|
}, duration); |
|
} |
|
|
|
if (target === 'enemy' || target === 'both') { |
|
enemyEffects = [...enemyEffects, effect]; |
|
setTimeout(() => { |
|
enemyEffects = enemyEffects.filter(e => e !== effect); |
|
}, duration); |
|
} |
|
} |
|
|
|
function updateUIFromBattleState() { |
|
if (!battleState) return; |
|
|
|
// Update player piclet state |
|
currentPlayerPiclet = battlePicletToInstance(battleState.playerPiclet, currentPlayerPiclet); |
|
playerHpPercentage = battleState.playerPiclet.currentHp / battleState.playerPiclet.maxHp; |
|
|
|
// Update enemy piclet state |
|
currentEnemyPiclet = battlePicletToInstance(battleState.opponentPiclet, currentEnemyPiclet); |
|
enemyHpPercentage = battleState.opponentPiclet.currentHp / battleState.opponentPiclet.maxHp; |
|
} |
|
|
|
function handlePicletSelect(piclet: PicletInstance) { |
|
if (!battleEngine) return; |
|
|
|
battlePhase = 'main'; |
|
currentMessage = `Come back, ${currentPlayerPiclet.nickname}!`; |
|
|
|
setTimeout(() => { |
|
// Convert the selected piclet to battle definition and switch |
|
const newPicletDefinition = picletInstanceToBattleDefinition(piclet); |
|
|
|
try { |
|
// TODO: Implement switching in battle engine |
|
// For now, just update the UI |
|
currentPlayerPiclet = piclet; |
|
playerHpPercentage = piclet.currentHp / piclet.maxHp; |
|
currentMessage = `Go, ${piclet.nickname}!`; |
|
|
|
setTimeout(() => { |
|
currentMessage = `What will ${piclet.nickname} do?`; |
|
}, 1500); |
|
} catch (error) { |
|
console.error('Switch error:', error); |
|
currentMessage = 'Unable to switch Piclets!'; |
|
} |
|
}, 1500); |
|
} |
|
|
|
function handleBack() { |
|
battlePhase = 'main'; |
|
} |
|
|
|
async function handleBattleResults(playerWon: boolean) { |
|
if (playerWon) { |
|
// Calculate XP gained from defeating the enemy |
|
const xpGained = calculateBattleXp(currentEnemyPiclet, 1); |
|
|
|
if (xpGained > 0) { |
|
// Animate XP gain by updating UI first |
|
const updatedPlayerPiclet = { |
|
...currentPlayerPiclet, |
|
xp: currentPlayerPiclet.xp + xpGained |
|
}; |
|
currentPlayerPiclet = updatedPlayerPiclet; |
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1500)); |
|
|
|
|
|
const { newInstance, levelUpInfo } = processAllLevelUps(updatedPlayerPiclet); |
|
|
|
|
|
if (newInstance.id) { |
|
await db.picletInstances.update(newInstance.id, newInstance); |
|
} |
|
|
|
|
|
currentPlayerPiclet = newInstance; |
|
|
|
|
|
if (levelUpInfo.length > 0) { |
|
battleResults = { |
|
victory: true, |
|
xpGained, |
|
levelUps: levelUpInfo, |
|
newLevel: newInstance.level |
|
}; |
|
|
|
battleResultsVisible = true; |
|
|
|
|
|
setTimeout(() => { |
|
battleResultsVisible = false; |
|
onBattleEnd(true); |
|
}, 4000); |
|
} else { |
|
// No level up, just end battle |
|
onBattleEnd(true); |
|
} |
|
} else { |
|
onBattleEnd(true); |
|
} |
|
} else { |
|
// Player lost - no XP gained |
|
onBattleEnd(false); |
|
} |
|
} |
|
</script> |
|
|
|
<div class="battle-page" transition:fade={{ duration: 300 }}> |
|
<nav class="battle-nav"> |
|
<button class="back-button" on:click={() => onBattleEnd('cancelled')} style="display: none;"> |
|
← Back |
|
</button> |
|
<h1>{isWildBattle ? 'Wild Battle' : 'Battle'}</h1> |
|
<div class="nav-spacer"></div> |
|
</nav> |
|
|
|
<div class="battle-content"> |
|
<BattleField |
|
playerPiclet={currentPlayerPiclet} |
|
enemyPiclet={currentEnemyPiclet} |
|
{playerHpPercentage} |
|
{enemyHpPercentage} |
|
showIntro={battlePhase === 'intro'} |
|
{battleState} |
|
{playerEffects} |
|
{enemyEffects} |
|
{playerFlash} |
|
{enemyFlash} |
|
{playerFaint} |
|
{enemyFaint} |
|
/> |
|
|
|
<BattleControls |
|
{currentMessage} |
|
{battlePhase} |
|
{processingTurn} |
|
{battleEnded} |
|
{isWildBattle} |
|
playerPiclet={currentPlayerPiclet} |
|
enemyPiclet={currentEnemyPiclet} |
|
{rosterPiclets} |
|
{battleState} |
|
onAction={handleAction} |
|
onMoveSelect={handleMoveSelect} |
|
onPicletSelect={handlePicletSelect} |
|
onBack={handleBack} |
|
/> |
|
</div> |
|
|
|
|
|
{#if battleResultsVisible} |
|
<div class="battle-results-overlay" transition:fade={{ duration: 300 }}> |
|
<div class="battle-results-card"> |
|
<h2>{battleResults.victory ? 'Victory!' : 'Defeat!'}</h2> |
|
|
|
|
|
{#if battleResults.levelUps.length > 0} |
|
{#each battleResults.levelUps as levelUp} |
|
<div class="level-up" transition:fade={{ duration: 500 }}> |
|
<h3>🎉 Level Up! 🎉</h3> |
|
<p><strong>{currentPlayerPiclet.nickname}</strong> grew to level <strong>{levelUp.newLevel}</strong>!</p> |
|
|
|
<div class="stat-changes"> |
|
{#if levelUp.statChanges.hp > 0} |
|
<div class="stat-change">HP +{levelUp.statChanges.hp}</div> |
|
{/if} |
|
{#if levelUp.statChanges.attack > 0} |
|
<div class="stat-change">Attack +{levelUp.statChanges.attack}</div> |
|
{/if} |
|
{#if levelUp.statChanges.defense > 0} |
|
<div class="stat-change">Defense +{levelUp.statChanges.defense}</div> |
|
{/if} |
|
{#if levelUp.statChanges.speed > 0} |
|
<div class="stat-change">Speed +{levelUp.statChanges.speed}</div> |
|
{/if} |
|
</div> |
|
</div> |
|
{/each} |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.battle-page { |
|
position: fixed; |
|
inset: 0; |
|
z-index: 1000; |
|
height: 100vh; |
|
display: flex; |
|
flex-direction: column; |
|
background: #f8f9fa; |
|
overflow: hidden; |
|
padding-top: env(safe-area-inset-top); |
|
} |
|
|
|
@media (max-width: 768px) { |
|
.battle-page { |
|
background: white; |
|
} |
|
|
|
.battle-page::before { |
|
content: ''; |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
height: env(safe-area-inset-top); |
|
background: white; |
|
z-index: 1; |
|
} |
|
} |
|
|
|
.battle-nav { |
|
display: none; /* Hide navigation in battle */ |
|
} |
|
|
|
.back-button { |
|
background: none; |
|
border: none; |
|
color: #007bff; |
|
font-size: 1rem; |
|
cursor: pointer; |
|
padding: 0.5rem; |
|
} |
|
|
|
.battle-nav h1 { |
|
margin: 0; |
|
font-size: 1.25rem; |
|
font-weight: 600; |
|
color: #1a1a1a; |
|
position: absolute; |
|
left: 50%; |
|
transform: translateX(-50%); |
|
} |
|
|
|
.nav-spacer { |
|
width: 60px; |
|
} |
|
|
|
.battle-content { |
|
flex: 1; |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
position: relative; |
|
background: #f8f9fa; |
|
} |
|
|
|
|
|
.battle-results-overlay { |
|
position: fixed; |
|
inset: 0; |
|
background: rgba(0, 0, 0, 0.8); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
z-index: 2000; |
|
} |
|
|
|
.battle-results-card { |
|
background: white; |
|
border-radius: 16px; |
|
padding: 2rem; |
|
max-width: 400px; |
|
width: 90%; |
|
text-align: center; |
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); |
|
} |
|
|
|
.battle-results-card h2 { |
|
margin: 0 0 1rem 0; |
|
font-size: 1.8rem; |
|
font-weight: 700; |
|
color: #1a1a1a; |
|
} |
|
|
|
|
|
.level-up { |
|
background: linear-gradient(135deg, #fff3e0 0%, #ffcc02 100%); |
|
border-radius: 12px; |
|
padding: 1.5rem; |
|
margin: 1rem 0; |
|
border: 3px solid #ff6f00; |
|
animation: levelUpPulse 0.6s ease-in-out; |
|
} |
|
|
|
.level-up h3 { |
|
margin: 0 0 0.5rem 0; |
|
font-size: 1.4rem; |
|
color: #e65100; |
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1); |
|
} |
|
|
|
.level-up p { |
|
margin: 0 0 1rem 0; |
|
font-size: 1.2rem; |
|
color: #bf360c; |
|
} |
|
|
|
.stat-changes { |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 0.5rem; |
|
justify-content: center; |
|
} |
|
|
|
.stat-change { |
|
background: rgba(76, 175, 80, 0.2); |
|
border: 1px solid #4caf50; |
|
border-radius: 20px; |
|
padding: 0.25rem 0.75rem; |
|
font-size: 0.9rem; |
|
font-weight: 600; |
|
color: #2e7d32; |
|
} |
|
|
|
@keyframes levelUpPulse { |
|
0% { |
|
transform: scale(0.9); |
|
opacity: 0; |
|
} |
|
50% { |
|
transform: scale(1.05); |
|
} |
|
100% { |
|
transform: scale(1); |
|
opacity: 1; |
|
} |
|
} |
|
</style> |