|
<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'; |
|
|
|
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[] = []; |
|
|
|
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) { |
|
if (encounter.title === 'Your First Piclet!') { |
|
// First catch - auto catch without battle |
|
try { |
|
isLoading = true; |
|
const caughtPiclet = await EncounterService.catchWildPiclet(encounter); |
|
await incrementCounter('picletsCapured'); |
|
await addProgressPoints(100); |
|
|
|
// Show success message |
|
alert(`You caught ${caughtPiclet.nickname}!`); |
|
|
|
// Force refresh encounters |
|
await forceEncounterRefresh(); |
|
} catch (error) { |
|
console.error('Error catching piclet:', error); |
|
} |
|
isLoading = false; |
|
} else { |
|
// 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 encounter.title === 'Your First Piclet!'} |
|
<div class="piclet-silhouette"> |
|
{#if monsterImages.has(encounter.picletTypeId)} |
|
<img |
|
src={monsterImages.get(encounter.picletTypeId)} |
|
alt="Mystery Piclet" |
|
class="silhouette-img" |
|
/> |
|
{:else} |
|
<div class="silhouette-fallback">?</div> |
|
{/if} |
|
</div> |
|
{:else} |
|
{#if monsterImages.has(encounter.picletTypeId)} |
|
<img |
|
src={monsterImages.get(encounter.picletTypeId)} |
|
alt="Wild Piclet" |
|
/> |
|
{:else} |
|
<div class="fallback-icon">{getEncounterIcon(encounter)}</div> |
|
{/if} |
|
{/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} |
|
|
|
<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; |
|
} |
|
|
|
.piclet-silhouette { |
|
width: 100%; |
|
height: 100%; |
|
border-radius: 8px; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
position: relative; |
|
overflow: hidden; |
|
} |
|
|
|
.silhouette-img { |
|
width: 100%; |
|
height: 100%; |
|
object-fit: cover; |
|
filter: grayscale(100%) brightness(0.3) contrast(0.5); |
|
opacity: 0.8; |
|
} |
|
|
|
.silhouette-fallback { |
|
font-size: 2rem; |
|
font-weight: bold; |
|
color: #999; |
|
background: #e0e0e0; |
|
width: 100%; |
|
height: 100%; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
.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> |