Fraser's picture
better battle
94e4b64
<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, SwitchAction } 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[] = []; // All roster piclets passed from parent
// Initialize battle engine
let battleEngine: BattleEngine;
let battleState: BattleState;
let currentPlayerPiclet = playerPiclet;
let currentEnemyPiclet = enemyPiclet;
// Battle state
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;
// Trainer animation states
let showWhiteFlash = false;
let playerTrainerVisible = false;
let enemyTrainerVisible = false;
let playerTrainerSlideOut = false;
let enemyTrainerSlideOut = false;
// Message progression system
let waitingForContinue = false;
let messageQueue: string[] = [];
let currentMessageIndex = 0;
let continueCallback: (() => void) | null = null;
// 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 playerLunge = false;
let enemyLunge = false;
// Battle results state
let battleResultsVisible = false;
let battleResults = {
victory: false,
xpGained: 0,
levelUps: [],
newLevel: 0
};
onMount(() => {
// Initialize battle engine with converted piclet definitions
// Convert full roster for switching support
const playerRosterDefinitions = rosterPiclets.map(p => picletInstanceToBattleDefinition(p));
const enemyDefinition = picletInstanceToBattleDefinition(enemyPiclet);
// Find the starting player piclet index in the roster
const startingPlayerIndex = rosterPiclets.findIndex(p => p.id === playerPiclet.id);
// Initialize with full rosters (player roster vs single enemy)
battleEngine = new BattleEngine(playerRosterDefinitions, enemyDefinition, playerPiclet.level, enemyPiclet.level);
// If starting piclet is not the first in roster, switch to it
if (startingPlayerIndex > 0) {
const initialSwitchAction: SwitchAction = {
type: 'switch',
piclet: 'player',
newPicletIndex: startingPlayerIndex
};
battleEngine.executeAction(initialSwitchAction, 'player');
}
battleState = battleEngine.getState();
// Start intro sequence
setTimeout(() => {
if (!isWildBattle) {
// Enemy trainer sends out first
currentMessage = `Go, ${enemyPiclet.nickname}!`;
enemyTrainerSlideOut = true;
setTimeout(() => {
currentMessage = `Go, ${playerPiclet.nickname}!`;
playerTrainerSlideOut = true;
setTimeout(() => {
currentMessage = `What will ${playerPiclet.nickname} do?`;
battlePhase = 'main';
}, 2000); // Wait for trainer slide + flash + monster appear
}, 2000); // Wait for enemy trainer sequence
} else {
// Wild battle - player sends out
currentMessage = `Go, ${playerPiclet.nickname}!`;
playerTrainerSlideOut = true;
setTimeout(() => {
currentMessage = `What will ${playerPiclet.nickname} do?`;
battlePhase = 'main';
}, 2000); // Wait for trainer slide + flash + monster appear
}
}, 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
};
// Get log entries before action to track new messages
const logBefore = battleEngine.getLog();
// Execute the turn - battle engine handles priority automatically
battleEngine.executeActions(moveAction, enemyAction);
battleState = battleEngine.getState();
// Get only the new log entries from this turn
const logAfter = battleEngine.getLog();
const newLogEntries = logAfter.slice(logBefore.length);
// Filter out faint messages since we handle them manually for proper sequencing
const filteredLogEntries = newLogEntries.filter(message => !message.includes('fainted'));
const result = { log: filteredLogEntries };
// Show battle messages with tap-to-continue system
if (result.log && result.log.length > 0) {
showMessageSequence(result.log, finalizeTurn);
} else {
finalizeTurn();
}
function finalizeTurn() {
// Update UI state from battle engine
updateUIFromBattleState();
// Check for battle end - only show faint message
if (battleState.winner) {
battleEnded = true;
const defeatedPiclet = battleState.winner === 'player' ? currentEnemyPiclet : currentPlayerPiclet;
// Show the faint message and trigger animation
currentMessage = `${defeatedPiclet.nickname} fainted!`;
// Trigger faint animation for the defeated Piclet
if (battleState.winner === 'player') {
enemyFaint = true;
} else {
playerFaint = true;
}
// Wait for faint message, then process battle results
setTimeout(async () => {
await handleBattleResults(battleState.winner === 'player');
}, 2500); // Wait time for faint message and animation
} else {
// Check if player Piclet switched due to fainting
const newPlayerPiclet = battlePicletToInstance(battleState.playerPiclet, currentPlayerPiclet);
const playerPicletChanged = currentPlayerPiclet.id !== newPlayerPiclet.id;
if (playerPicletChanged) {
// Player Piclet fainted and auto-switched - show faint message first
const faintedPiclet = currentPlayerPiclet;
currentMessage = `${faintedPiclet.nickname} fainted!`;
playerFaint = true;
// Wait for faint message, then show switch and continue
setTimeout(() => {
currentMessage = `Go, ${newPlayerPiclet.nickname}!`;
// updateUIFromBattleState will handle the white flash transition
setTimeout(() => {
currentMessage = `What will ${currentPlayerPiclet.nickname} do?`;
processingTurn = false;
}, 1000);
}, 2500);
} else {
// Normal turn end - no faint or switch
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 stripped names since battle messages no longer have prefixes
const playerName = stripBattlePrefix(battleState?.playerPiclet?.definition?.name || '');
const enemyName = stripBattlePrefix(battleState?.opponentPiclet?.definition?.name || '');
// Attack lunge effects - trigger when a Piclet uses a move
if (message.includes(' used ')) {
if (message.includes(playerName)) {
triggerLungeAnimation('player');
} else if (message.includes(enemyName)) {
triggerLungeAnimation('enemy');
}
}
// Damage effects
if (message.includes('took') && message.includes('damage')) {
if (message.includes(playerName)) {
triggerDamageFlash('player');
updateUIFromBattleState();
} else if (message.includes(enemyName)) {
triggerDamageFlash('enemy');
updateUIFromBattleState();
}
}
// Critical hit effects
if (message.includes('critical hit')) {
triggerEffect('both', 'critical', '💥', 1000);
}
// Effectiveness messages
if (message.includes("It's super effective")) {
triggerEffect('both', 'superEffective', '⚡', 800);
} else if (message.includes("not very effective")) {
triggerEffect('both', 'notVeryEffective', '💨', 800);
}
// Status effects
if (message.includes('was burned')) {
const target = message.includes(playerName) ? 'player' : 'enemy';
triggerEffect(target, 'burn', '🔥', 1200);
} else if (message.includes('was poisoned')) {
const target = message.includes(playerName) ? 'player' : 'enemy';
triggerEffect(target, 'poison', '☠️', 1200);
} else if (message.includes('was paralyzed')) {
const target = message.includes(playerName) ? 'player' : 'enemy';
triggerEffect(target, 'paralyze', '⚡', 1200);
} else if (message.includes('fell asleep')) {
const target = message.includes(playerName) ? 'player' : 'enemy';
triggerEffect(target, 'sleep', '😴', 1200);
} else if (message.includes('was frozen')) {
const target = message.includes(playerName) ? 'player' : 'enemy';
triggerEffect(target, 'freeze', '❄️', 1200);
}
// Stat changes
if (message.includes("'s") && (message.includes('rose') || message.includes('fell'))) {
const target = message.includes(playerName) ? '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);
}
}
// Healing effects
if (message.includes('recovered') && message.includes('HP')) {
const target = message.includes(playerName) ? 'player' : 'enemy';
triggerEffect(target, 'heal', '💚', 1000);
// Update HP bar immediately for healing animation sync
updateUIFromBattleState();
}
// Miss effects
if (message.includes('missed')) {
triggerEffect('both', 'miss', '💫', 800);
}
// Faint effects - only trigger when we see the faint message from battle log
// Don't trigger here since we handle it in finalizeTurn for proper sequencing
}
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 triggerLungeAnimation(target: 'player' | 'enemy') {
if (target === 'player') {
playerLunge = true;
setTimeout(() => playerLunge = false, 600); // Reset after animation
} else {
enemyLunge = true;
setTimeout(() => enemyLunge = false, 600); // Reset after animation
}
}
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 showMessageSequence(messages: string[], callback: () => void) {
if (!messages || messages.length === 0) {
callback();
return;
}
messageQueue = messages;
currentMessageIndex = 0;
continueCallback = callback;
// Show first message
currentMessage = messageQueue[0];
waitingForContinue = true;
}
function handleContinueTap() {
if (!waitingForContinue || !messageQueue.length) return;
// Trigger visual effects for current message
triggerVisualEffectsFromMessage(currentMessage);
currentMessageIndex++;
if (currentMessageIndex >= messageQueue.length) {
// Sequence finished
waitingForContinue = false;
messageQueue = [];
currentMessageIndex = 0;
if (continueCallback) {
continueCallback();
continueCallback = null;
}
} else {
// Show next message
currentMessage = messageQueue[currentMessageIndex];
}
}
function updateUIFromBattleState() {
if (!battleState) return;
// Check if player Piclet has changed (indicating auto-switch due to fainting)
const newPlayerPiclet = battlePicletToInstance(battleState.playerPiclet, currentPlayerPiclet);
const playerPicletChanged = currentPlayerPiclet.id !== newPlayerPiclet.id;
if (playerPicletChanged) {
// Player Piclet auto-switched due to fainting - show transition
showWhiteFlash = true;
setTimeout(() => {
currentPlayerPiclet = newPlayerPiclet;
playerHpPercentage = battleState.playerPiclet.currentHp / battleState.playerPiclet.maxHp;
showWhiteFlash = false;
}, 300);
} else {
// Normal update without switch
currentPlayerPiclet = newPlayerPiclet;
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';
processingTurn = true;
// Find the index of the selected piclet in the roster
const picletIndex = rosterPiclets.findIndex(p => p.id === piclet.id);
if (picletIndex === -1) {
console.error('Selected piclet not found in roster');
processingTurn = false;
return;
}
// Show the switch message and trigger white flash animation
currentMessage = `Go, ${piclet.nickname}!`;
showWhiteFlash = true;
// After flash, update display
setTimeout(() => {
currentPlayerPiclet = piclet;
playerHpPercentage = piclet.currentHp / piclet.maxHp;
showWhiteFlash = false;
}, 300);
const switchAction: SwitchAction = {
type: 'switch',
piclet: 'player',
newPicletIndex: picletIndex
};
try {
// Choose random enemy move (AI continues to act)
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
};
// Allow time for the visual switch to be seen before processing the turn
setTimeout(() => {
// Get log entries before action to track new messages
const logBefore = battleEngine.getLog();
// Execute the turn - switching vs enemy move
battleEngine.executeActions(switchAction, enemyAction);
battleState = battleEngine.getState();
processAfterSwitchTurn(logBefore);
}, 1000); // 1 second delay to show the switch visually
} catch (error) {
console.error('Battle engine error:', error);
currentMessage = 'Unable to switch Piclets!';
processingTurn = false;
}
}
function processAfterSwitchTurn(logBefore: string[]) {
try {
// Get only the new log entries from this turn
const logAfter = battleEngine.getLog();
const newLogEntries = logAfter.slice(logBefore.length);
// Filter out faint messages since we handle them manually for proper sequencing
const filteredLogEntries = newLogEntries.filter(message => !message.includes('fainted'));
const result = { log: filteredLogEntries };
// Show battle messages with tap-to-continue system
if (result.log && result.log.length > 0) {
showMessageSequence(result.log, finalizeSwitchTurn);
} else {
finalizeSwitchTurn();
}
function finalizeSwitchTurn() {
// Update UI state from battle engine
updateUIFromBattleState();
// Check for battle end - only show faint message
if (battleState.winner) {
battleEnded = true;
const defeatedPiclet = battleState.winner === 'player' ? currentEnemyPiclet : currentPlayerPiclet;
// Show the faint message and trigger animation
currentMessage = `${defeatedPiclet.nickname} fainted!`;
// Trigger faint animation for the defeated Piclet
if (battleState.winner === 'player') {
enemyFaint = true;
} else {
playerFaint = true;
}
// Wait for faint message, then process battle results
setTimeout(async () => {
await handleBattleResults(battleState.winner === 'player');
}, 2500); // Wait time for faint message and animation
} else {
setTimeout(() => {
currentMessage = `What will ${currentPlayerPiclet.nickname} do?`;
processingTurn = false;
}, 1000);
}
}
} catch (error) {
console.error('Switch error:', error);
currentMessage = 'Unable to switch Piclets!';
processingTurn = false;
}
}
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;
// Wait a moment for XP bar animation
await new Promise(resolve => setTimeout(resolve, 1500));
// Process any level ups
const { newInstance, levelUpInfo } = processAllLevelUps(updatedPlayerPiclet);
// Save updated Piclet to database
if (newInstance.id) {
await db.picletInstances.update(newInstance.id, newInstance);
}
// Update local state with final leveled instance
currentPlayerPiclet = newInstance;
// Show level up results if any occurred
if (levelUpInfo.length > 0) {
battleResults = {
victory: true,
xpGained,
levelUps: levelUpInfo,
newLevel: newInstance.level
};
battleResultsVisible = true;
// Auto-dismiss after showing level up
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}
{playerLunge}
{enemyLunge}
{isWildBattle}
{showWhiteFlash}
{playerTrainerVisible}
{enemyTrainerVisible}
{playerTrainerSlideOut}
{enemyTrainerSlideOut}
/>
<BattleControls
{currentMessage}
{battlePhase}
{processingTurn}
{battleEnded}
{isWildBattle}
playerPiclet={currentPlayerPiclet}
enemyPiclet={currentEnemyPiclet}
{rosterPiclets}
{battleState}
{waitingForContinue}
onAction={handleAction}
onMoveSelect={handleMoveSelect}
onPicletSelect={handlePicletSelect}
onBack={handleBack}
onContinueTap={handleContinueTap}
/>
</div>
<!-- Battle Results Overlay -->
{#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 */
.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>