| <script lang="ts"> |
| import { onMount } from 'svelte'; |
| import { fade, fly } from 'svelte/transition'; |
| import type { Encounter, GameState, PicletInstance } from '$lib/db/schema'; |
| import { EncounterType } from '$lib/db/schema'; |
| import { EncounterService } from '$lib/db/encounterService'; |
| import { getOrCreateGameState, incrementCounter, addProgressPoints } from '$lib/db/gameState'; |
| import { db } from '$lib/db'; |
| import { uiStore } from '$lib/stores/ui'; |
| import Battle from './Battle.svelte'; |
| import PullToRefresh from '../UI/PullToRefresh.svelte'; |
| import NewlyCaughtPicletDetail from '../Piclets/NewlyCaughtPicletDetail.svelte'; |
| |
| let encounters: Encounter[] = []; |
| let isLoading = true; |
| let isRefreshing = false; |
| let monsterImages: Map<string, string> = new Map(); |
| |
| |
| let showBattle = false; |
| let battlePlayerPiclet: PicletInstance | null = null; |
| let battleEnemyPiclet: PicletInstance | null = null; |
| let battleIsWild = true; |
| let battleRosterPiclets: PicletInstance[] = []; |
| |
| |
| let showNewlyCaught = false; |
| let newlyCaughtPiclet: PicletInstance | null = null; |
| |
| onMount(async () => { |
| await loadEncounters(); |
| }); |
| |
| async function loadEncounters() { |
| isLoading = true; |
| try { |
| // Check if we have any piclet instances |
| const playerPiclets = await db.picletInstances.toArray(); |
| |
| if (playerPiclets.length === 0) { |
| // No piclets discovered/caught - show empty state |
| encounters = []; |
| isLoading = false; |
| return; |
| } |
| |
| |
| console.log('Player has piclets - generating fresh encounters with wild piclets'); |
| await EncounterService.forceEncounterRefresh(); |
| encounters = await EncounterService.generateEncounters(); |
| |
| console.log('Final encounters:', encounters.map(e => ({ type: e.type, title: e.title }))); |
| |
| |
| await loadPicletImages(); |
| } catch (error) { |
| console.error('Error loading encounters:', error); |
| } |
| isLoading = false; |
| } |
| |
| async function loadPicletImages() { |
| const wildEncounters = encounters.filter(e => |
| e.type === EncounterType.WILD_PICLET && e.picletTypeId |
| ); |
| |
| for (const encounter of wildEncounters) { |
| if (!encounter.picletTypeId) continue; |
| |
| // Find a piclet instance with this typeId |
| const piclet = await db.picletInstances |
| .where('typeId') |
| .equals(encounter.picletTypeId) |
| .first(); |
| |
| if (piclet && piclet.imageData) { |
| monsterImages.set(encounter.picletTypeId, piclet.imageData); |
| } |
| } |
| |
| monsterImages = monsterImages; |
| } |
| |
| async function handleRefresh() { |
| isRefreshing = true; |
| try { |
| // Force refresh encounters |
| console.log('Force refreshing encounters...'); |
| encounters = await EncounterService.generateEncounters(); |
| |
| // Load piclet images for new encounters |
| await loadPicletImages(); |
| |
| // Update game state with new refresh time |
| const gameState = await getOrCreateGameState(); |
| await db.gameState.update(gameState.id!, { |
| lastEncounterRefresh: new Date() |
| }); |
| } catch (error) { |
| console.error('Error refreshing encounters:', error); |
| } |
| isRefreshing = false; |
| } |
| |
| async function handleEncounterTap(encounter: Encounter) { |
| if (encounter.type === EncounterType.WILD_PICLET && encounter.picletTypeId) { |
| // Regular wild encounter - start battle |
| await startBattle(encounter); |
| } else if (encounter.type === EncounterType.SHOP) { |
| await handleShopEncounter(); |
| } else if (encounter.type === EncounterType.HEALTH_CENTER) { |
| await handleHealthCenterEncounter(); |
| } else if (encounter.type === EncounterType.TRAINER_BATTLE) { |
| alert('Trainer battles coming soon!'); |
| } |
| } |
| |
| async function handleShopEncounter() { |
| alert('Shop features coming soon!'); |
| await forceEncounterRefresh(); |
| } |
| |
| async function handleHealthCenterEncounter() { |
| try { |
| // Heal all piclets |
| const piclets = await db.picletInstances.toArray(); |
| for (const piclet of piclets) { |
| await db.picletInstances.update(piclet.id!, { |
| currentHp: piclet.maxHp |
| }); |
| } |
| |
| alert('All your piclets have been healed to full health!'); |
| await forceEncounterRefresh(); |
| } catch (error) { |
| console.error('Error at health center:', error); |
| } |
| } |
| |
| async function forceEncounterRefresh() { |
| isRefreshing = true; |
| try { |
| await EncounterService.forceEncounterRefresh(); |
| encounters = await EncounterService.generateEncounters(); |
| await loadPicletImages(); |
| } catch (error) { |
| console.error('Error refreshing encounters:', error); |
| } |
| isRefreshing = false; |
| } |
| |
| function getEncounterIcon(encounter: Encounter): string { |
| switch (encounter.type) { |
| case EncounterType.SHOP: |
| return '🛍️'; |
| case EncounterType.HEALTH_CENTER: |
| return '❤️'; |
| case EncounterType.TRAINER_BATTLE: |
| return '🏆'; |
| case EncounterType.WILD_PICLET: |
| default: |
| return '⚔️'; |
| } |
| } |
| |
| function getEncounterColor(encounter: Encounter): string { |
| switch (encounter.type) { |
| case EncounterType.WILD_PICLET: |
| return '#4caf50'; |
| case EncounterType.TRAINER_BATTLE: |
| return '#ff9800'; |
| case EncounterType.SHOP: |
| return '#2196f3'; |
| case EncounterType.HEALTH_CENTER: |
| return '#9c27b0'; |
| default: |
| return '#607d8b'; |
| } |
| } |
| |
| async function startBattle(encounter: Encounter) { |
| try { |
| // Get all piclet instances |
| const allPiclets = await db.picletInstances.toArray(); |
| |
| // Filter piclets that have a roster position (0-5) |
| const rosterPiclets = allPiclets.filter(p => |
| p.rosterPosition !== undefined && |
| p.rosterPosition !== null && |
| p.rosterPosition >= 0 && |
| p.rosterPosition <= 5 |
| ); |
| |
| // Sort by roster position |
| rosterPiclets.sort((a, b) => (a.rosterPosition ?? 0) - (b.rosterPosition ?? 0)); |
| |
| // Get healthy piclets |
| const healthyPiclets = rosterPiclets.filter(p => p.currentHp > 0); |
| |
| if (healthyPiclets.length === 0) { |
| alert('You need at least one healthy piclet in your roster to battle!'); |
| return; |
| } |
| |
| |
| const hasPosition0 = rosterPiclets.some(p => p.rosterPosition === 0); |
| if (!hasPosition0) { |
| alert('You need a piclet in the first roster slot (position 0) to battle!'); |
| return; |
| } |
| |
| |
| const enemyPiclet = await generateEnemyPiclet(encounter); |
| if (!enemyPiclet) return; |
| |
| |
| battlePlayerPiclet = healthyPiclets[0]; |
| battleEnemyPiclet = enemyPiclet; |
| battleIsWild = true; |
| battleRosterPiclets = rosterPiclets; |
| showBattle = true; |
| uiStore.enterBattle(); |
| } catch (error) { |
| console.error('Error starting battle:', error); |
| } |
| } |
| |
| async function generateEnemyPiclet(encounter: Encounter): Promise<PicletInstance | null> { |
| if (!encounter.picletTypeId || !encounter.enemyLevel) return null; |
| |
| // Get a piclet instance with this typeId to use as a template |
| const templatePiclet = await db.picletInstances |
| .where('typeId') |
| .equals(encounter.picletTypeId) |
| .first(); |
| |
| if (!templatePiclet) { |
| console.error('Piclet template not found for typeId:', encounter.picletTypeId); |
| return null; |
| } |
| |
| |
| const level = encounter.enemyLevel; |
| |
| |
| const calculateStat = (base: number, level: number) => Math.floor((base * level) / 50 + 5); |
| const calculateHp = (base: number, level: number) => Math.floor((base * level) / 50 + level + 10); |
| |
| const maxHp = calculateHp(templatePiclet.baseHp, level); |
| |
| |
| const enemyPiclet: PicletInstance = { |
| ...templatePiclet, |
| id: -1, // Temporary ID for enemy |
| |
| level: level, |
| xp: 0, |
| currentHp: maxHp, |
| maxHp: maxHp, |
| attack: calculateStat(templatePiclet.baseAttack, level), |
| defense: calculateStat(templatePiclet.baseDefense, level), |
| fieldAttack: calculateStat(templatePiclet.baseFieldAttack, level), |
| fieldDefense: calculateStat(templatePiclet.baseFieldDefense, level), |
| speed: calculateStat(templatePiclet.baseSpeed, level), |
| |
| // Reset move PP to full |
| moves: templatePiclet.moves.map(move => ({ |
| ...move, |
| currentPp: move.pp |
| })), |
| |
| isInRoster: false, |
| caughtAt: new Date() |
| }; |
| |
| return enemyPiclet; |
| } |
| |
| function handleBattleEnd(result: any) { |
| showBattle = false; |
| uiStore.exitBattle(); |
| |
| if (result === true) { |
| // Victory |
| console.log('Battle won!'); |
| } else if (result === false) { |
| // Defeat or ran away |
| console.log('Battle lost or fled'); |
| } else if (result && result.id) { |
| // Caught a piclet |
| console.log('Piclet caught!', result); |
| incrementCounter('picletsCapured'); |
| addProgressPoints(100); |
| } |
| |
| |
| forceEncounterRefresh(); |
| } |
| </script> |
|
|
| {#if showBattle && battlePlayerPiclet && battleEnemyPiclet} |
| <Battle |
| playerPiclet={battlePlayerPiclet} |
| enemyPiclet={battleEnemyPiclet} |
| isWildBattle={battleIsWild} |
| rosterPiclets={battleRosterPiclets} |
| onBattleEnd={handleBattleEnd} |
| /> |
| {:else} |
| <div class="encounters-page"> |
| <PullToRefresh onRefresh={handleRefresh}> |
| {#if isLoading} |
| <div class="loading"> |
| <div class="spinner"></div> |
| <p>Loading encounters...</p> |
| </div> |
| {:else if encounters.length === 0} |
| <div class="empty-state"> |
| <div class="empty-icon">📸</div> |
| <h2>No Piclets Discovered</h2> |
| <p>To start your adventure, select the Snap logo image:</p> |
| <div class="logo-instruction"> |
| <img src="/assets/snap_logo.png" alt="Snap Logo" class="snap-logo-preview" /> |
| <p class="instruction-text">↑ Select this image in the scanner</p> |
| </div> |
| </div> |
| {:else} |
| <div class="encounters-list"> |
| {#each encounters as encounter, index (encounter.id)} |
| <button |
| class="encounter-card" |
| style="border-color: {getEncounterColor(encounter)}30" |
| on:click={() => handleEncounterTap(encounter)} |
| in:fly={{ y: 20, delay: index * 50 }} |
| disabled={isRefreshing} |
| > |
| <div class="encounter-icon"> |
| {#if encounter.type === EncounterType.WILD_PICLET && encounter.picletTypeId} |
| {#if monsterImages.has(encounter.picletTypeId)} |
| <img |
| src={monsterImages.get(encounter.picletTypeId)} |
| alt="Wild Piclet" |
| /> |
| {:else} |
| <div class="fallback-icon">{getEncounterIcon(encounter)}</div> |
| {/if} |
| {:else} |
| <span class="type-icon">{getEncounterIcon(encounter)}</span> |
| {/if} |
| </div> |
| |
| <div class="encounter-info"> |
| <h3>{encounter.title}</h3> |
| <p>{encounter.description}</p> |
| </div> |
| |
| <div class="encounter-arrow">›</div> |
| </button> |
| {/each} |
| </div> |
| {/if} |
| </PullToRefresh> |
| </div> |
| {/if} |
|
|
| <!-- Newly Caught Piclet Dialog --> |
| {#if showNewlyCaught && newlyCaughtPiclet} |
| <NewlyCaughtPicletDetail |
| instance={newlyCaughtPiclet} |
| onClose={() => { |
| showNewlyCaught = false; |
| newlyCaughtPiclet = null; |
| }} |
| /> |
| {/if} |
|
|
| <style> |
| .encounters-page { |
| height: 100%; |
| overflow: hidden; /* PullToRefresh handles scrolling */ |
| } |
| |
| .loading, .empty-state { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| height: 60vh; |
| text-align: center; |
| padding: 1rem; |
| } |
| |
| .spinner { |
| width: 48px; |
| height: 48px; |
| border: 4px solid #f0f0f0; |
| border-top-color: #4caf50; |
| border-radius: 50%; |
| animation: spin 1s linear infinite; |
| margin-bottom: 1rem; |
| } |
| |
| @keyframes spin { |
| to { transform: rotate(360deg); } |
| } |
| |
| .empty-icon { |
| font-size: 4rem; |
| margin-bottom: 1rem; |
| } |
| |
| .empty-state h2 { |
| margin: 0 0 0.5rem; |
| font-size: 1.25rem; |
| color: #333; |
| } |
| |
| .empty-state p { |
| color: #666; |
| font-size: 0.9rem; |
| } |
| |
| .encounters-list { |
| display: flex; |
| flex-direction: column; |
| gap: 1rem; |
| padding: 1rem; |
| padding-bottom: 5rem; |
| } |
| |
| .encounter-card { |
| display: flex; |
| align-items: center; |
| gap: 1rem; |
| background: #fff; |
| border: 2px solid; |
| border-radius: 12px; |
| padding: 1rem; |
| box-shadow: 0 2px 8px rgba(0,0,0,0.08); |
| transition: all 0.2s ease; |
| cursor: pointer; |
| width: 100%; |
| text-align: left; |
| } |
| |
| .encounter-card:hover:not(:disabled) { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(0,0,0,0.12); |
| } |
| |
| .encounter-card:disabled { |
| opacity: 0.6; |
| cursor: not-allowed; |
| } |
| |
| .encounter-icon { |
| width: 60px; |
| height: 60px; |
| flex-shrink: 0; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .encounter-icon img { |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| border-radius: 8px; |
| } |
| |
| |
| |
| |
| .type-icon, .fallback-icon { |
| font-size: 2rem; |
| } |
| |
| .encounter-info { |
| flex: 1; |
| } |
| |
| .encounter-info h3 { |
| margin: 0 0 0.25rem; |
| font-size: 1.1rem; |
| font-weight: 600; |
| color: #1a1a1a; |
| } |
| |
| .encounter-info p { |
| margin: 0; |
| font-size: 0.875rem; |
| color: #666; |
| } |
| |
| .encounter-arrow { |
| font-size: 1.5rem; |
| color: #999; |
| } |
| |
| .logo-instruction { |
| margin-top: 1.5rem; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 0.5rem; |
| } |
| |
| .snap-logo-preview { |
| width: 120px; |
| height: 120px; |
| object-fit: contain; |
| border: 2px dashed #007bff; |
| border-radius: 12px; |
| padding: 1rem; |
| background: #f0f7ff; |
| } |
| |
| .instruction-text { |
| font-size: 0.875rem; |
| color: #007bff; |
| font-weight: 500; |
| margin: 0; |
| } |
| |
| </style> |