|
<script lang="ts"> |
|
import type { PicletInstance } from '$lib/db/schema'; |
|
import type { GradioClient } from '$lib/types'; |
|
|
|
interface BattleUpdate { |
|
battle_updates: string[]; |
|
player_pokemon_status: string; |
|
player_pokemon_hp: 'Empty' | 'Very Low' | 'Low' | 'Medium' | 'High' | 'Very High' | 'Full'; |
|
enemy_pokemon_status: string; |
|
enemy_pokemon_hp: 'Empty' | 'Very Low' | 'Low' | 'Medium' | 'High' | 'Very High' | 'Full'; |
|
next_to_act: 'player' | 'enemy'; |
|
available_actions: string[]; |
|
} |
|
|
|
interface Props { |
|
playerPiclet: PicletInstance; |
|
enemyPiclet: PicletInstance; |
|
commandClient: GradioClient; |
|
onBattleEnd: (winner: 'player' | 'enemy') => void; |
|
rosterPiclets?: PicletInstance[]; |
|
} |
|
|
|
let { playerPiclet, enemyPiclet, commandClient, onBattleEnd, rosterPiclets }: Props = $props(); |
|
|
|
|
|
let battleState: BattleUpdate = $state({ |
|
battle_updates: [], |
|
player_pokemon_status: 'Ready for battle', |
|
player_pokemon_hp: 'Full', |
|
enemy_pokemon_status: 'Ready for battle', |
|
enemy_pokemon_hp: 'Full', |
|
next_to_act: 'player', |
|
available_actions: [] |
|
}); |
|
|
|
let battleHistory: string[] = $state([]); |
|
let isProcessing: boolean = $state(false); |
|
let currentPlayerPiclet: PicletInstance = $state(playerPiclet); |
|
let showPicletSelector: boolean = $state(false); |
|
|
|
|
|
function rollDice(): number { |
|
return Math.floor(Math.random() * 20) + 1; |
|
} |
|
|
|
function getActionEffectiveness(roll: number): { success: string; description: string } { |
|
if (roll === 20) return { success: 'Critical Success', description: 'The action succeeds spectacularly!' }; |
|
if (roll >= 15) return { success: 'Success', description: 'The action succeeds well!' }; |
|
if (roll >= 10) return { success: 'Partial Success', description: 'The action has some effect.' }; |
|
if (roll >= 5) return { success: 'Failure', description: 'The action fails but something minor happens.' }; |
|
if (roll === 1) return { success: 'Critical Failure', description: 'The action backfires!' }; |
|
return { success: 'Failure', description: 'The action fails.' }; |
|
} |
|
|
|
|
|
async function generateBattleUpdate(prompt: string): Promise<BattleUpdate> { |
|
const message = { |
|
text: prompt, |
|
files: [] |
|
}; |
|
|
|
const result = await commandClient.predict("/chat", { |
|
message: message, |
|
max_new_tokens: 1000 |
|
}); |
|
|
|
const responseText = result.data || ''; |
|
console.log('LLM Response:', responseText); |
|
|
|
|
|
try { |
|
const jsonMatch = responseText.match(/\{[\s\S]*\}/); |
|
if (!jsonMatch) throw new Error('No JSON found in response'); |
|
|
|
const battleUpdate: BattleUpdate = JSON.parse(jsonMatch[0]); |
|
return battleUpdate; |
|
} catch (error) { |
|
console.error('Failed to parse battle response:', error); |
|
|
|
return { |
|
battle_updates: ['The battle continues...'], |
|
player_pokemon_status: battleState.player_pokemon_status, |
|
player_pokemon_hp: battleState.player_pokemon_hp, |
|
enemy_pokemon_status: battleState.enemy_pokemon_status, |
|
enemy_pokemon_hp: battleState.enemy_pokemon_hp, |
|
next_to_act: battleState.next_to_act === 'player' ? 'enemy' : 'player', |
|
available_actions: ['Attack', 'Defend', 'Special Move'] |
|
}; |
|
} |
|
} |
|
|
|
|
|
async function startBattle() { |
|
isProcessing = true; |
|
|
|
const initialPrompt = `Let's role play a Pokemon game using my custom Pokemon, the player will use ${currentPlayerPiclet.typeId} and the enemy will use ${enemyPiclet.typeId}. |
|
You will return a brief description on what happens because of the action. |
|
I will send you an update of the enemy or players move and the success of the action (in a DnD style). Be sure to be as creative and engaging as possible when defining battle updates and available actions. |
|
|
|
Player Pokemon: ${currentPlayerPiclet.typeId} |
|
${currentPlayerPiclet.description} |
|
|
|
Enemy Pokemon: ${enemyPiclet.typeId} |
|
${enemyPiclet.description} |
|
|
|
Each response should be a json object with fields: |
|
\`\`\`json |
|
{ |
|
"battle_updates": [list with 1 sentence per entry describing what just happened in battle], |
|
"player_pokemon_status": "1 sentence description of how the Pokemon is doing", |
|
"player_pokemon_hp": "enum Empty, Very Low, Low, Medium, High, Very High, Full", |
|
"enemy_pokemon_status": "1 sentence description of how the Pokemon is doing", |
|
"enemy_pokemon_hp": "enum Empty, Very Low, Low, Medium, High, Very High, Full", |
|
"next_to_act": "enum player/enemy", |
|
"available_actions": ["short list of 1 sentence actions of what to have the next_to_act Pokemon do next"] |
|
} |
|
\`\`\` |
|
Start with just some intro updates describing both monsters being on the battlefield.`; |
|
|
|
try { |
|
const update = await generateBattleUpdate(initialPrompt); |
|
battleState = update; |
|
battleHistory.push(initialPrompt); |
|
} catch (error) { |
|
console.error('Failed to start battle:', error); |
|
} |
|
|
|
isProcessing = false; |
|
} |
|
|
|
|
|
async function executeAction(actionDescription: string) { |
|
if (isProcessing) return; |
|
|
|
isProcessing = true; |
|
|
|
const roll = rollDice(); |
|
const effectiveness = getActionEffectiveness(roll); |
|
|
|
const prompt = `Player chooses: "${actionDescription}" |
|
Dice roll: ${roll}/20 (${effectiveness.success}) |
|
Effect: ${effectiveness.description} |
|
|
|
Update the battle state based on this action and its effectiveness. Then have the enemy take their turn if appropriate.`; |
|
|
|
try { |
|
const update = await generateBattleUpdate(prompt); |
|
battleState = update; |
|
battleHistory.push(prompt); |
|
|
|
|
|
if (battleState.player_pokemon_hp === 'Empty') { |
|
onBattleEnd('enemy'); |
|
} else if (battleState.enemy_pokemon_hp === 'Empty') { |
|
onBattleEnd('player'); |
|
} |
|
} catch (error) { |
|
console.error('Failed to execute action:', error); |
|
} |
|
|
|
isProcessing = false; |
|
} |
|
|
|
|
|
async function switchPiclet(newPiclet: PicletInstance) { |
|
if (isProcessing) return; |
|
|
|
isProcessing = true; |
|
showPicletSelector = false; |
|
|
|
const switchPrompt = `Player switches from ${currentPlayerPiclet.typeId} to ${newPiclet.typeId}! |
|
|
|
New Pokemon: ${newPiclet.typeId} |
|
${newPiclet.description} |
|
|
|
Update the battle to show the switch and have the enemy react accordingly.`; |
|
|
|
try { |
|
currentPlayerPiclet = newPiclet; |
|
const update = await generateBattleUpdate(switchPrompt); |
|
battleState = update; |
|
battleHistory.push(switchPrompt); |
|
} catch (error) { |
|
console.error('Failed to switch Piclet:', error); |
|
} |
|
|
|
isProcessing = false; |
|
} |
|
|
|
|
|
$effect(() => { |
|
startBattle(); |
|
}); |
|
|
|
|
|
export { executeAction, switchPiclet }; |
|
</script> |
|
|
|
<div class="llm-battle-engine"> |
|
|
|
<div class="battle-narrative"> |
|
<h3>Battle Progress</h3> |
|
{#each battleState.battle_updates as update} |
|
<div class="battle-update">{update}</div> |
|
{/each} |
|
</div> |
|
|
|
|
|
<div class="pokemon-status"> |
|
<div class="player-status"> |
|
<h4>{currentPlayerPiclet.typeId}</h4> |
|
<div class="hp-indicator hp-{battleState.player_pokemon_hp.toLowerCase().replace(' ', '-')}">{battleState.player_pokemon_hp}</div> |
|
<p>{battleState.player_pokemon_status}</p> |
|
</div> |
|
|
|
<div class="enemy-status"> |
|
<h4>{enemyPiclet.typeId}</h4> |
|
<div class="hp-indicator hp-{battleState.enemy_pokemon_hp.toLowerCase().replace(' ', '-')}">{battleState.enemy_pokemon_hp}</div> |
|
<p>{battleState.enemy_pokemon_status}</p> |
|
</div> |
|
</div> |
|
|
|
|
|
{#if battleState.next_to_act === 'player' && !isProcessing} |
|
<div class="available-actions"> |
|
<h4>Choose Your Action:</h4> |
|
|
|
|
|
{#each battleState.available_actions as action} |
|
<button |
|
class="action-button" |
|
onclick={() => executeAction(action)} |
|
> |
|
{action} |
|
</button> |
|
{/each} |
|
|
|
|
|
{#if rosterPiclets && rosterPiclets.length > 1} |
|
<button |
|
class="switch-button" |
|
onclick={() => showPicletSelector = !showPicletSelector} |
|
> |
|
🔄 Switch Piclet |
|
</button> |
|
{/if} |
|
</div> |
|
|
|
|
|
{#if showPicletSelector && rosterPiclets} |
|
<div class="piclet-selector"> |
|
<h4>Choose Piclet:</h4> |
|
<div class="piclet-grid"> |
|
{#each rosterPiclets as piclet} |
|
{#if piclet.id !== currentPlayerPiclet.id} |
|
<button |
|
class="piclet-option" |
|
onclick={() => switchPiclet(piclet)} |
|
> |
|
<img src={piclet.imageUrl} alt={piclet.typeId} /> |
|
<span>{piclet.typeId}</span> |
|
<span class="tier tier-{piclet.tier}">{piclet.tier}</span> |
|
</button> |
|
{/if} |
|
{/each} |
|
</div> |
|
</div> |
|
{/if} |
|
{:else if isProcessing} |
|
<div class="processing"> |
|
<div class="spinner"></div> |
|
<p>Processing battle turn...</p> |
|
</div> |
|
{:else} |
|
<div class="enemy-turn"> |
|
<p>Enemy is deciding their move...</p> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.llm-battle-engine { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 1rem; |
|
padding: 1rem; |
|
} |
|
|
|
.battle-narrative { |
|
background: #f8f9fa; |
|
border-radius: 8px; |
|
padding: 1rem; |
|
max-height: 200px; |
|
overflow-y: auto; |
|
} |
|
|
|
.battle-update { |
|
margin-bottom: 0.5rem; |
|
padding: 0.5rem; |
|
background: white; |
|
border-radius: 4px; |
|
border-left: 3px solid #007bff; |
|
} |
|
|
|
.pokemon-status { |
|
display: grid; |
|
grid-template-columns: 1fr 1fr; |
|
gap: 1rem; |
|
} |
|
|
|
.player-status, .enemy-status { |
|
padding: 1rem; |
|
border-radius: 8px; |
|
text-align: center; |
|
} |
|
|
|
.player-status { |
|
background: rgba(0, 123, 255, 0.1); |
|
border: 2px solid #007bff; |
|
} |
|
|
|
.enemy-status { |
|
background: rgba(220, 53, 69, 0.1); |
|
border: 2px solid #dc3545; |
|
} |
|
|
|
.hp-indicator { |
|
font-weight: bold; |
|
padding: 0.25rem 0.5rem; |
|
border-radius: 16px; |
|
margin: 0.5rem 0; |
|
display: inline-block; |
|
} |
|
|
|
.hp-full { background: #28a745; color: white; } |
|
.hp-very-high { background: #40c757; color: white; } |
|
.hp-high { background: #6bc267; color: white; } |
|
.hp-medium { background: #ffc107; color: black; } |
|
.hp-low { background: #fd7e14; color: white; } |
|
.hp-very-low { background: #dc3545; color: white; } |
|
.hp-empty { background: #6c757d; color: white; } |
|
|
|
.available-actions { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.5rem; |
|
} |
|
|
|
.action-button { |
|
padding: 0.75rem 1rem; |
|
background: #007bff; |
|
color: white; |
|
border: none; |
|
border-radius: 8px; |
|
cursor: pointer; |
|
font-size: 1rem; |
|
transition: background-color 0.2s; |
|
} |
|
|
|
.action-button:hover { |
|
background: #0056b3; |
|
} |
|
|
|
.switch-button { |
|
padding: 0.75rem 1rem; |
|
background: #28a745; |
|
color: white; |
|
border: none; |
|
border-radius: 8px; |
|
cursor: pointer; |
|
font-size: 1rem; |
|
transition: background-color 0.2s; |
|
margin-top: 0.5rem; |
|
} |
|
|
|
.switch-button:hover { |
|
background: #1e7e34; |
|
} |
|
|
|
.piclet-selector { |
|
background: #f8f9fa; |
|
border-radius: 8px; |
|
padding: 1rem; |
|
margin-top: 1rem; |
|
} |
|
|
|
.piclet-grid { |
|
display: grid; |
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); |
|
gap: 0.5rem; |
|
margin-top: 0.5rem; |
|
} |
|
|
|
.piclet-option { |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
gap: 0.25rem; |
|
padding: 0.5rem; |
|
background: white; |
|
border: 2px solid #dee2e6; |
|
border-radius: 8px; |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
} |
|
|
|
.piclet-option:hover { |
|
border-color: #007bff; |
|
background: #f0f7ff; |
|
} |
|
|
|
.piclet-option img { |
|
width: 40px; |
|
height: 40px; |
|
object-fit: cover; |
|
border-radius: 4px; |
|
} |
|
|
|
.piclet-option span { |
|
font-size: 0.8rem; |
|
text-align: center; |
|
} |
|
|
|
.tier { |
|
padding: 0.1rem 0.3rem; |
|
border-radius: 8px; |
|
font-size: 0.7rem; |
|
font-weight: bold; |
|
text-transform: uppercase; |
|
} |
|
|
|
.tier-low { background: #6c757d; color: white; } |
|
.tier-medium { background: #28a745; color: white; } |
|
.tier-high { background: #fd7e14; color: white; } |
|
.tier-legendary { background: #dc3545; color: white; } |
|
|
|
.processing, .enemy-turn { |
|
text-align: center; |
|
padding: 2rem; |
|
} |
|
|
|
.spinner { |
|
width: 40px; |
|
height: 40px; |
|
border: 4px solid #f3f3f3; |
|
border-top: 4px solid #007bff; |
|
border-radius: 50%; |
|
animation: spin 1s linear infinite; |
|
margin: 0 auto 1rem; |
|
} |
|
|
|
@keyframes spin { |
|
to { transform: rotate(360deg); } |
|
} |
|
</style> |