diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c2ea39b..529f695 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Bash(npm install:*)", "Bash(npm uninstall:*)", "Bash(rm:*)", - "Bash(npx tailwindcss init:*)" + "Bash(npx tailwindcss init:*)", + "WebFetch(domain:pokeapi.co)" ], "deny": [] } diff --git a/POKEMON_FEATURES.md b/POKEMON_FEATURES.md new file mode 100644 index 0000000..f6139cd --- /dev/null +++ b/POKEMON_FEATURES.md @@ -0,0 +1,83 @@ +# Pokémon API Integration Features + +## Overview +The NPC Configuration App now includes comprehensive Pokémon API integration using the [PokéAPI](https://pokeapi.co/) to help you build Pokémon teams for your NPCs. + +## New Features + +### 1. Form-Based Pokémon Selection 🎯 +- **Full Pokémon Dropdown**: Select from all 1010+ Pokémon (Gen 1-9) +- **Level Control**: Set level from 1-100 using slider or number input +- **Individual Move Selection**: Choose up to 4 moves from available movesets +- **Real-time Preview**: See the generated Cobblemon string before adding + +### 2. Team Generation Tools ⚡ +- **Random Team**: Generate 6 random Pokémon with realistic levels (20-80) +- **Type-based Teams**: Create themed teams (Fire, Water, Grass, Electric, etc.) +- **Quick 3**: Generate smaller teams for testing + +### 3. Enhanced UI Components +- **Pokémon Info Display**: Shows artwork, types, stats, and available moves +- **Move Categories**: Includes level-up, TM, and tutor moves +- **Form Validation**: Ensures valid selections before adding +- **Loading States**: Clear feedback during API calls + +## How to Use + +### Adding Individual Pokémon: +1. Navigate to **Pokemon Party** tab +2. Enable **Battle Configuration** first +3. Click **"Add Pokémon"** button (blue button with search icon) +4. In the form modal: + - Select Pokémon from dropdown + - Adjust level (1-100) + - Choose up to 4 moves + - Preview the result + - Click "Add to Team" + +### Quick Team Generation: +- **Random Team**: Click "Random Team" for 6 diverse Pokémon +- **Type Teams**: Use "Generate by Type" dropdown +- **Small Teams**: Click "Quick 3" for testing + +### Manual Editing: +- Each Pokémon slot has a text input for manual editing +- Click the 🔍 icon next to any slot to use the form selector +- Use "Add Empty Slot" for manual text entry + +## Generated Format +The form automatically creates proper Cobblemon format strings: + +``` +gastrodon level=44 moves=hydro-pump,rain-dance,mud-bomb,recover +pikachu level=50 moves=thunderbolt,quick-attack,thunder-wave,agility +charizard level=65 moves=flamethrower,dragon-claw,air-slash,roost +``` + +## API Features +- **Caching**: 10-minute response caching for better performance +- **Error Handling**: Graceful fallbacks for failed requests +- **Move Filtering**: Only shows learnable moves (level-up, TM, tutor) +- **Type Safety**: Full TypeScript support + +## Performance +- Lazy loading of Pokémon data +- Efficient move filtering +- Minimal API calls with intelligent caching +- Hot module reloading in development + +## Technical Details + +### Files Added: +- `src/services/pokemonApi.ts` - API service with caching +- `src/components/PokemonFormSelector.tsx` - Form-based selector +- Enhanced `src/components/NPCPartyBuilder.tsx` - Updated party builder + +### Dependencies: +- Uses the free [PokéAPI](https://pokeapi.co/) (no API key required) +- Built with React hooks and TypeScript +- Integrates seamlessly with existing Cobblemon configuration + +--- + +Ready to test at: http://localhost:5173 \ No newline at end of file diff --git a/src/components/NPCPartyBuilder.tsx b/src/components/NPCPartyBuilder.tsx index 6a9a64e..63fbe05 100644 --- a/src/components/NPCPartyBuilder.tsx +++ b/src/components/NPCPartyBuilder.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; -import { Plus, Trash2 } from 'lucide-react'; +import { Plus, Trash2, Shuffle, Zap, Search } from 'lucide-react'; import type { NPCConfiguration, NPCPartyProvider, SimplePartyProvider, PoolPartyProvider, PoolEntry } from '../types/npc'; +import { pokemonApi } from '../services/pokemonApi'; +import { PokemonFormSelector } from './PokemonFormSelector'; interface NPCPartyBuilderProps { config: NPCConfiguration; @@ -9,6 +11,9 @@ interface NPCPartyBuilderProps { export function NPCPartyBuilder({ config, onChange }: NPCPartyBuilderProps) { const [partyType, setPartyType] = useState<'simple' | 'pool' | 'script'>(config.party?.type || 'simple'); + const [showPokemonFormSelector, setShowPokemonFormSelector] = useState(false); + const [selectedPokemonIndex, setSelectedPokemonIndex] = useState(-1); + const [isGenerating, setIsGenerating] = useState(false); const handlePartyChange = (party: NPCPartyProvider) => { onChange({ ...config, party }); @@ -30,6 +35,66 @@ export function NPCPartyBuilder({ config, onChange }: NPCPartyBuilderProps) { } }; + const generateRandomTeam = async (teamSize: number = 6) => { + setIsGenerating(true); + try { + const team = await pokemonApi.generateRandomTeam(teamSize); + const pokemonStrings = team.map(pokemon => pokemonApi.formatToCobblemonString(pokemon)); + + if (partyType === 'simple') { + handlePartyChange({ + type: 'simple', + pokemon: pokemonStrings, + isStatic: (config.party as SimplePartyProvider)?.isStatic + }); + } + } catch (error) { + console.error('Failed to generate random team:', error); + } finally { + setIsGenerating(false); + } + }; + + const generateTeamByType = async (type: string, teamSize: number = 6) => { + setIsGenerating(true); + try { + const team = await pokemonApi.generateTeamByType(type, teamSize); + const pokemonStrings = team.map(pokemon => pokemonApi.formatToCobblemonString(pokemon)); + + if (partyType === 'simple') { + handlePartyChange({ + type: 'simple', + pokemon: pokemonStrings, + isStatic: (config.party as SimplePartyProvider)?.isStatic + }); + } + } catch (error) { + console.error(`Failed to generate ${type} team:`, error); + } finally { + setIsGenerating(false); + } + }; + + const openPokemonFormSelector = (index: number) => { + setSelectedPokemonIndex(index); + setShowPokemonFormSelector(true); + }; + + const handlePokemonFormSelect = (pokemonString: string) => { + if (partyType === 'simple') { + const party = config.party as SimplePartyProvider; + const newPokemon = [...party.pokemon]; + newPokemon[selectedPokemonIndex] = pokemonString; + + handlePartyChange({ + ...party, + pokemon: newPokemon + }); + } + setShowPokemonFormSelector(false); + setSelectedPokemonIndex(-1); + }; + const renderSimpleParty = () => { const party = config.party as SimplePartyProvider; @@ -61,17 +126,85 @@ export function NPCPartyBuilder({ config, onChange }: NPCPartyBuilderProps) { }; return ( -
+

Pokemon List

- +
+ + +
+
+ + {/* Team Generation Tools */} +
+
Team Generation
+
+ + + + + +
{party.pokemon.map((pokemon, index) => ( @@ -83,6 +216,14 @@ export function NPCPartyBuilder({ config, onChange }: NPCPartyBuilderProps) { className="flex-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm" placeholder="pikachu level=50 moves=thunderbolt,quick-attack" /> + +
+ +
+ {/* Pokemon Selection */} +
+ + +
+ + {/* Pokemon Info */} + {selectedPokemon && ( +
+
+ {selectedPokemon.sprites.other?.['official-artwork']?.front_default && ( + {selectedPokemon.name} + )} +
+

{selectedPokemon.name}

+
+ {selectedPokemon.types.map((type) => ( + + {type.type.name} + + ))} +
+
+
+ + {/* Stats */} +
+
Height: {selectedPokemon.height / 10}m
+
Weight: {selectedPokemon.weight / 10}kg
+
Base Experience: {selectedPokemon.base_experience}
+
Available Moves: {availableMoves.length}
+
+
+ )} + + {/* Level Selection */} +
+ +
+ handleLevelChange(parseInt(e.target.value))} + className="flex-1" + /> + handleLevelChange(parseInt(e.target.value) || 1)} + className="w-16 border border-gray-300 rounded px-2 py-1 text-sm" + /> +
+
+ + {/* Move Selection */} +
+ + + {isLoadingMoves ? ( +
+ + Loading available moves... +
+ ) : ( +
+ {formData.moves.map((selectedMove, index) => ( +
+ + +
+ ))} + + {availableMoves.length === 0 && selectedPokemon && ( +

+ No moves available for this Pokémon +

+ )} +
+ )} +
+ + {/* Preview */} + {isFormValid && ( +
+ +
+ {formData.pokemon} level={formData.level} + {formData.moves.filter(m => m !== '').length > 0 && ( + moves={formData.moves.filter(m => m !== '').join(',')} + )} +
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+ + ); +} \ No newline at end of file diff --git a/src/components/PokemonSelector.tsx b/src/components/PokemonSelector.tsx new file mode 100644 index 0000000..5dbd609 --- /dev/null +++ b/src/components/PokemonSelector.tsx @@ -0,0 +1,265 @@ +import { useState, useEffect, useRef } from 'react'; +import { Search, Loader, X } from 'lucide-react'; +import { pokemonApi, type PokemonListItem, type Pokemon } from '../services/pokemonApi'; + +interface PokemonSelectorProps { + onSelect: (pokemonString: string) => void; + onClose: () => void; + isOpen: boolean; +} + +export function PokemonSelector({ onSelect, onClose, isOpen }: PokemonSelectorProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [selectedPokemon, setSelectedPokemon] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [level, setLevel] = useState(50); + const [selectedMoves, setSelectedMoves] = useState([]); + const searchInputRef = useRef(null); + + useEffect(() => { + if (isOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [isOpen]); + + useEffect(() => { + const searchPokemon = async () => { + if (searchQuery.length < 2) { + setSearchResults([]); + return; + } + + setIsSearching(true); + try { + const results = await pokemonApi.searchPokemon(searchQuery, 10); + setSearchResults(results); + } catch (error) { + console.error('Search failed:', error); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }; + + const timeoutId = setTimeout(searchPokemon, 300); + return () => clearTimeout(timeoutId); + }, [searchQuery]); + + const handlePokemonSelect = async (pokemonItem: PokemonListItem) => { + setIsLoading(true); + try { + const pokemon = await pokemonApi.getPokemon(pokemonItem.name); + setSelectedPokemon(pokemon); + setSearchResults([]); + setSearchQuery(''); + + // Auto-select some random moves + const levelMoves = pokemon.moves + .filter(moveData => + moveData.version_group_details.some(detail => + detail.move_learn_method.name === 'level-up' + ) + ) + .map(moveData => moveData.move.name); + + const randomMoves = levelMoves + .sort(() => Math.random() - 0.5) + .slice(0, 4); + + setSelectedMoves(randomMoves); + } catch (error) { + console.error('Failed to load pokemon:', error); + } finally { + setIsLoading(false); + } + }; + + const handleConfirm = () => { + if (selectedPokemon) { + const moves = selectedMoves.length > 0 ? ` moves=${selectedMoves.join(',')}` : ''; + const pokemonString = `${selectedPokemon.name} level=${level}${moves}`; + onSelect(pokemonString); + handleClose(); + } + }; + + const handleClose = () => { + setSearchQuery(''); + setSearchResults([]); + setSelectedPokemon(null); + setSelectedMoves([]); + setLevel(50); + onClose(); + }; + + const toggleMove = (move: string) => { + if (selectedMoves.includes(move)) { + setSelectedMoves(selectedMoves.filter(m => m !== move)); + } else if (selectedMoves.length < 4) { + setSelectedMoves([...selectedMoves, move]); + } + }; + + const availableMoves = selectedPokemon?.moves + .filter(moveData => + moveData.version_group_details.some(detail => + detail.move_learn_method.name === 'level-up' + ) + ) + .map(moveData => moveData.move.name) + .sort() || []; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

Select a Pokémon

+ +
+ +
+ {!selectedPokemon ? ( + // Search Phase +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search for a Pokémon..." + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> + {isSearching && ( + + )} +
+ + {searchResults.length > 0 && ( +
+ {searchResults.map((pokemon) => ( + + ))} +
+ )} + + {searchQuery.length >= 2 && searchResults.length === 0 && !isSearching && ( +

No Pokémon found

+ )} + + {isLoading && ( +
+ + Loading Pokémon details... +
+ )} +
+ ) : ( + // Configuration Phase +
+
+ {selectedPokemon.sprites.other?.['official-artwork']?.front_default && ( + {selectedPokemon.name} + )} +
+

{selectedPokemon.name}

+
+ {selectedPokemon.types.map((type) => ( + + {type.type.name} + + ))} +
+
+
+ + {/* Level */} +
+ + setLevel(parseInt(e.target.value))} + className="w-full" + /> +
+ + {/* Moves */} +
+ +
+
+ {availableMoves.map((move) => ( + + ))} +
+
+
+
+ )} +
+ + {/* Footer */} + {selectedPokemon && ( +
+ + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/services/pokemonApi.ts b/src/services/pokemonApi.ts new file mode 100644 index 0000000..1b40493 --- /dev/null +++ b/src/services/pokemonApi.ts @@ -0,0 +1,249 @@ +const POKEMON_API_BASE = 'https://pokeapi.co/api/v2'; + +export interface PokemonListItem { + name: string; + url: string; +} + +export interface PokemonListResponse { + count: number; + next: string | null; + previous: string | null; + results: PokemonListItem[]; +} + +export interface PokemonType { + slot: number; + type: { + name: string; + url: string; + }; +} + +export interface PokemonStat { + base_stat: number; + effort: number; + stat: { + name: string; + url: string; + }; +} + +export interface PokemonSprites { + front_default: string | null; + front_shiny: string | null; + other?: { + 'official-artwork'?: { + front_default: string | null; + }; + }; +} + +export interface PokemonMove { + move: { + name: string; + url: string; + }; + version_group_details: { + level_learned_at: number; + move_learn_method: { + name: string; + }; + version_group: { + name: string; + }; + }[]; +} + +export interface Pokemon { + id: number; + name: string; + height: number; + weight: number; + base_experience: number; + types: PokemonType[]; + stats: PokemonStat[]; + sprites: PokemonSprites; + moves: PokemonMove[]; +} + +export interface CobblemonPokemon { + name: string; + level: number; + moves: string[]; + displayName?: string; +} + +class PokemonApiService { + private cache = new Map(); + private readonly CACHE_EXPIRY = 10 * 60 * 1000; // 10 minutes + + private async fetchWithCache(url: string): Promise { + const cacheKey = url; + const cached = this.cache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < this.CACHE_EXPIRY) { + return cached.data; + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + this.cache.set(cacheKey, { data, timestamp: Date.now() }); + return data; + } catch (error) { + console.error('Pokemon API fetch error:', error); + throw error; + } + } + + async getAllPokemon(limit: number = 1000): Promise { + return this.fetchWithCache(`${POKEMON_API_BASE}/pokemon?limit=${limit}`); + } + + async searchPokemon(query: string, limit: number = 20): Promise { + const allPokemon = await this.getAllPokemon(); + const filtered = allPokemon.results + .filter(pokemon => pokemon.name.toLowerCase().includes(query.toLowerCase())) + .slice(0, limit); + + return filtered; + } + + async getPokemon(nameOrId: string | number): Promise { + return this.fetchWithCache(`${POKEMON_API_BASE}/pokemon/${nameOrId}`); + } + + async getRandomPokemon(): Promise { + const randomId = Math.floor(Math.random() * 1010) + 1; // Gen 1-9 Pokemon + return this.getPokemon(randomId); + } + + async generateRandomTeam(teamSize: number = 6): Promise { + const team: CobblemonPokemon[] = []; + const usedPokemon = new Set(); + + for (let i = 0; i < teamSize; i++) { + let pokemon: Pokemon; + let attempts = 0; + + do { + pokemon = await this.getRandomPokemon(); + attempts++; + } while (usedPokemon.has(pokemon.id) && attempts < 50); + + if (attempts >= 50) { + break; // Prevent infinite loop + } + + usedPokemon.add(pokemon.id); + + const level = this.generateRandomLevel(); + const moves = this.selectRandomMoves(pokemon, 4); + + team.push({ + name: pokemon.name, + level, + moves, + displayName: this.formatPokemonName(pokemon.name) + }); + } + + return team; + } + + formatToCobblemonString(pokemon: CobblemonPokemon): string { + const moves = pokemon.moves.length > 0 ? ` moves=${pokemon.moves.join(',')}` : ''; + return `${pokemon.name} level=${pokemon.level}${moves}`; + } + + private generateRandomLevel(): number { + // Generate levels between 20 and 80 with higher probability for mid-range + const min = 20; + const max = 80; + const avg = (min + max) / 2; + const stdDev = (max - min) / 6; + + // Simple normal distribution approximation + let level = Math.round(avg + stdDev * (Math.random() + Math.random() + Math.random() - 1.5)); + return Math.max(min, Math.min(max, level)); + } + + private selectRandomMoves(pokemon: Pokemon, count: number): string[] { + // Filter moves that can be learned by leveling up + const levelMoves = pokemon.moves + .filter(moveData => + moveData.version_group_details.some(detail => + detail.move_learn_method.name === 'level-up' && + detail.level_learned_at > 0 + ) + ) + .map(moveData => moveData.move.name); + + if (levelMoves.length === 0) { + // If no level moves, use any moves + const allMoves = pokemon.moves.map(moveData => moveData.move.name); + return this.shuffleArray(allMoves).slice(0, count); + } + + return this.shuffleArray(levelMoves).slice(0, count); + } + + private shuffleArray(array: T[]): T[] { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; + } + + private formatPokemonName(name: string): string { + return name.charAt(0).toUpperCase() + name.slice(1); + } + + async generateTeamByType(type: string, teamSize: number = 6): Promise { + try { + const typeResponse = await this.fetchWithCache(`${POKEMON_API_BASE}/type/${type.toLowerCase()}`); + const pokemonOfType = typeResponse.pokemon.map((p: any) => p.pokemon); + + if (pokemonOfType.length === 0) { + throw new Error(`No Pokemon found for type: ${type}`); + } + + const team: CobblemonPokemon[] = []; + const selectedPokemon = this.shuffleArray(pokemonOfType).slice(0, teamSize); + + for (const pokemonRef of selectedPokemon) { + try { + const pokemonData = pokemonRef as { name: string; pokemon: { name: string; url: string } }; + const pokemon = await this.getPokemon(pokemonData.pokemon.name); + const level = this.generateRandomLevel(); + const moves = this.selectRandomMoves(pokemon, 4); + + team.push({ + name: pokemon.name, + level, + moves, + displayName: this.formatPokemonName(pokemon.name) + }); + } catch (error) { + const pokemonData = pokemonRef as { pokemon: { name: string } }; + console.warn(`Failed to fetch pokemon ${pokemonData.pokemon.name}:`, error); + } + } + + return team; + } catch (error) { + console.error(`Failed to generate team for type ${type}:`, error); + // Fallback to random team + return this.generateRandomTeam(teamSize); + } + } +} + +export const pokemonApi = new PokemonApiService(); \ No newline at end of file