|
<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 picletEncounters = encounters.filter(e => |
|
(e.type === EncounterType.WILD_PICLET || e.type === EncounterType.FIRST_PICLET) && e.picletTypeId |
|
); |
|
|
|
for (const encounter of picletEncounters) { |
|
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.FIRST_PICLET) { |
|
// First piclet encounter - direct catch |
|
await handleFirstPicletEncounter(encounter); |
|
} else if (encounter.type === EncounterType.WILD_PICLET && encounter.picletTypeId) { |
|
// Regular wild encounter - start battle |
|
await startBattle(encounter); |
|
} |
|
} |
|
|
|
|
|
async function handleFirstPicletEncounter(encounter: Encounter) { |
|
try { |
|
if (!encounter.picletInstanceId) { |
|
throw new Error('No piclet instance specified for first piclet encounter'); |
|
} |
|
|
|
|
|
const picletInstance = await db.picletInstances.get(encounter.picletInstanceId); |
|
if (!picletInstance) { |
|
throw new Error('Piclet instance not found'); |
|
} |
|
|
|
|
|
await db.picletInstances.update(encounter.picletInstanceId, { |
|
caught: true, |
|
caughtAt: new Date(), |
|
isInRoster: true, |
|
rosterPosition: 0, |
|
level: 3 |
|
}); |
|
|
|
|
|
const caughtPiclet = await db.picletInstances.get(encounter.picletInstanceId); |
|
|
|
|
|
newlyCaughtPiclet = caughtPiclet!; |
|
showNewlyCaught = true; |
|
|
|
|
|
incrementCounter('picletsCapured'); |
|
addProgressPoints(100); |
|
|
|
|
|
await forceEncounterRefresh(); |
|
} catch (error) { |
|
console.error('Error handling first piclet encounter:', error); |
|
alert('Something went wrong. Please try again.'); |
|
} |
|
} |
|
|
|
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.FIRST_PICLET: |
|
return '✨'; |
|
case EncounterType.WILD_PICLET: |
|
default: |
|
return '⚔️'; |
|
} |
|
} |
|
|
|
function getEncounterColor(encounter: Encounter): string { |
|
switch (encounter.type) { |
|
case EncounterType.WILD_PICLET: |
|
return '#4caf50'; |
|
case EncounterType.FIRST_PICLET: |
|
return '#ffd700'; |
|
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)); |
|
|
|
if (rosterPiclets.length === 0) { |
|
alert('You need at least one 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 = rosterPiclets[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 enemyPiclet: PicletInstance = { |
|
...templatePiclet, |
|
id: -1, // Temporary ID for enemy |
|
|
|
isInRoster: false, |
|
caughtAt: new Date() |
|
}; |
|
|
|
return enemyPiclet; |
|
} |
|
|
|
async function addCapturedPicletToRoster(capturedPiclet: PicletInstance) { |
|
try { |
|
// Get all roster piclets to find the next available position |
|
const allPiclets = await db.picletInstances.toArray(); |
|
const rosterPiclets = allPiclets.filter(p => |
|
p.rosterPosition !== undefined && |
|
p.rosterPosition !== null && |
|
p.rosterPosition >= 0 && |
|
p.rosterPosition <= 5 |
|
); |
|
|
|
// Find next available roster position (0-5) |
|
let nextPosition = 0; |
|
const occupiedPositions = new Set(rosterPiclets.map(p => p.rosterPosition)); |
|
while (occupiedPositions.has(nextPosition) && nextPosition <= 5) { |
|
nextPosition++; |
|
} |
|
|
|
if (nextPosition > 5) { |
|
// Roster is full - for now just add to position 5 (could implement storage system later) |
|
console.warn('Roster is full, overriding position 5'); |
|
nextPosition = 5; |
|
} |
|
|
|
console.log('About to create captured Piclet in database:', { |
|
id: capturedPiclet.id, |
|
nickname: capturedPiclet.nickname, |
|
typeId: capturedPiclet.typeId, |
|
level: capturedPiclet.level |
|
}); |
|
|
|
|
|
const newPicletData = { |
|
...capturedPiclet, |
|
// Remove the temporary ID so database auto-generates a real one |
|
id: undefined as any, |
|
caught: true, |
|
caughtAt: new Date(), |
|
isInRoster: true, |
|
rosterPosition: nextPosition |
|
}; |
|
|
|
|
|
const newPicletId = await db.picletInstances.add(newPicletData); |
|
console.log('Created new database record with ID:', newPicletId); |
|
|
|
|
|
const createdPiclet = await db.picletInstances.get(newPicletId); |
|
console.log('Retrieved newly created piclet from database:', createdPiclet); |
|
|
|
if (createdPiclet) { |
|
console.log('Setting newlyCaughtPiclet and showing detail page:', createdPiclet.nickname); |
|
newlyCaughtPiclet = createdPiclet; |
|
showNewlyCaught = true; |
|
console.log('showNewlyCaught is now:', showNewlyCaught); |
|
console.log(`Added captured Piclet ${createdPiclet.nickname} to roster position ${nextPosition}`); |
|
} else { |
|
console.error('Could not retrieve newly created piclet from database'); |
|
// Use fallback data |
|
const fallbackPiclet = { |
|
...capturedPiclet, |
|
id: newPicletId |
|
}; |
|
newlyCaughtPiclet = fallbackPiclet; |
|
showNewlyCaught = true; |
|
} |
|
} catch (error) { |
|
console.error('Error adding captured Piclet to roster:', error); |
|
} |
|
} |
|
|
|
async function handleBattleEnd(result: any) { |
|
showBattle = false; |
|
uiStore.exitBattle(); |
|
|
|
if (result === true) { |
|
// Victory |
|
console.log('Battle won!'); |
|
// Force refresh encounters after battle |
|
forceEncounterRefresh(); |
|
} else if (result === false) { |
|
// Defeat or ran away |
|
console.log('Battle lost or fled'); |
|
// Force refresh encounters after battle |
|
forceEncounterRefresh(); |
|
} else if (result && result.id) { |
|
// Caught a piclet - add to roster |
|
console.log('Piclet caught!', result); |
|
await addCapturedPicletToRoster(result); |
|
incrementCounter('picletsCapured'); |
|
addProgressPoints(100); |
|
// Don't refresh encounters immediately - let user view the caught piclet first |
|
// Refresh will happen when they close the detail dialog |
|
} |
|
} |
|
</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.type === EncounterType.FIRST_PICLET) && encounter.picletTypeId} |
|
{#if monsterImages.has(encounter.picletTypeId)} |
|
<img |
|
src={monsterImages.get(encounter.picletTypeId)} |
|
alt="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; |
|
// Refresh encounters after user closes the detail dialog |
|
forceEncounterRefresh(); |
|
}} |
|
/> |
|
{/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> |