piclets / src /lib /components /Pages /Encounters.svelte
Fraser's picture
BIG CHANGE
c703ea3
<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();
// Battle state
let showBattle = false;
let battlePlayerPiclet: PicletInstance | null = null;
let battleEnemyPiclet: PicletInstance | null = null;
let battleIsWild = true;
let battleRosterPiclets: PicletInstance[] = [];
// Newly caught Piclet state
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;
}
// Player has piclets - always generate fresh encounters with wild piclets
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 })));
// Load piclet images for wild encounters
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);
}
}
// Trigger reactive update
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');
}
// Get the specific piclet instance
const picletInstance = await db.picletInstances.get(encounter.picletInstanceId);
if (!picletInstance) {
throw new Error('Piclet instance not found');
}
// Mark the piclet as caught and set it as the first roster piclet
await db.picletInstances.update(encounter.picletInstanceId, {
caught: true,
caughtAt: new Date(),
isInRoster: true,
rosterPosition: 0,
level: 3
});
// Get the updated piclet instance
const caughtPiclet = await db.picletInstances.get(encounter.picletInstanceId);
// Show the newly caught piclet detail page
newlyCaughtPiclet = caughtPiclet!;
showNewlyCaught = true;
// Update counters and progress
incrementCounter('picletsCapured');
addProgressPoints(100);
// Force refresh encounters to remove the first piclet encounter
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;
}
// Check if there's at least one piclet in position 0
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;
}
// Generate enemy piclet for battle
const enemyPiclet = await generateEnemyPiclet(encounter);
if (!enemyPiclet) return;
// Set up battle
battlePlayerPiclet = rosterPiclets[0];
battleEnemyPiclet = enemyPiclet;
battleIsWild = true;
battleRosterPiclets = rosterPiclets; // Pass all roster piclets
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;
}
// Calculate stats based on template's base stats and encounter level
const level = encounter.enemyLevel;
// Create enemy piclet instance based on template (simplified)
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
});
// Create a new database record for the captured Piclet (enemy has temporary ID -1)
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
};
// Add the captured piclet to the database
const newPicletId = await db.picletInstances.add(newPicletData);
console.log('Created new database record with ID:', newPicletId);
// Get the newly created piclet instance and show detail page
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>