diff --git a/.gitignore b/.gitignore index 67b6b0ad44d..606d999384c 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ npm-debug.log /teams.pokemonshowdown.com/caches/ /teams.pokemonshowdown.com/index.html /teams.pokemonshowdown.com/ads.txt + +# auto-generated trainer avatars manifest +/play.pokemonshowdown.com/src/trainer-avatars.ts +/play.pokemonshowdown.com/js/trainer-avatars.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f7fb45821b..48c8ecc167e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,3 +88,29 @@ We have polyfills for: These polyfills are optimized for speed, not spec-compliance. As long as you don't write very nonstandard code, you won't have a problem. `Array#includes` is put directly on the `Array` prototype, so you can't use `for-in` on Arrays. Fortunately, TypeScript will complain if you try. + +Trainer Avatars +--------------- + +The trainer avatar manifest is auto-generated from the sprite files. + +**Source location:** `play.pokemonshowdown.com/sprites/trainers/index.php` + +**To regenerate the manifest:** +```bash +node build-tools/build-trainers +``` + +This generates: +- `play.pokemonshowdown.com/src/trainer-avatars.ts` - TypeScript data file with all avatar metadata +- `play.pokemonshowdown.com/data/trainers-manifest.json` - JSON manifest (not committed) + +The generated TypeScript file contains: +- `TrainerAvatarData` interface - type for avatar entries +- `TrainerAvatarManifest` - metadata about the manifest (count, dimensions) +- `TrainerAvatars` - array of all available avatars + +**Adding new avatars:** +1. Add the sprite PNG to `sprites/trainers/` +2. Add artist credit to `sprites/trainers/index.php` +3. Run `node build-tools/build-trainers` diff --git a/build b/build index b9a05fdb2ef..6230a1e0837 100755 --- a/build +++ b/build @@ -37,6 +37,7 @@ case 'full': execSync(`node ./build-tools/build-indexes`, options); execSync(`node ./build-tools/build-learnsets`, options); execSync(`node ./build-tools/build-minidex`, options); + execSync(`node ./build-tools/build-trainers`, options); full = ' full'; break; case 'indexes': @@ -49,6 +50,9 @@ case 'minidex': case 'sprites': execSync(`node ./build-tools/build-minidex`, options); break; +case 'trainers': + execSync(`node ./build-tools/build-trainers`, options); + break; case 'commands': execSync(`node ./build-tools/build-commands`, options); break; diff --git a/build-tools/build-trainers b/build-tools/build-trainers new file mode 100755 index 00000000000..e6e3d8f401f --- /dev/null +++ b/build-tools/build-trainers @@ -0,0 +1,227 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const rootDir = path.resolve(__dirname, '..'); +const spritesDir = path.join(rootDir, 'play.pokemonshowdown.com/sprites/trainers'); +const outputDir = path.join(rootDir, 'play.pokemonshowdown.com/data'); +const srcDir = path.join(rootDir, 'play.pokemonshowdown.com/src'); + +const AVATAR_WIDTH = 80; +const AVATAR_HEIGHT = 80; +const COLUMNS = 16; + +function parseCreditedSprites(indexPath) { + const content = fs.readFileSync(indexPath, 'utf8'); + const matches = content.match(/"([a-zA-Z0-9_-]+)\.png"/g) || []; + const sprites = new Set(); + + for (const match of matches) { + const name = match.replace(/"/g, '').replace('.png', ''); + sprites.add(name); + } + + return Array.from(sprites).sort(); +} + +function getExistingAvatarNumbers(srcPath) { + const dexDataPath = path.join(srcPath, 'battle-dex-data.ts'); + const content = fs.readFileSync(dexDataPath, 'utf8'); + + const startIdx = content.indexOf('export const BattleAvatarNumbers'); + if (startIdx === -1) return {}; + + let braceStart = content.indexOf('{', startIdx); + if (braceStart === -1) return {}; + braceStart = content.indexOf('{', braceStart + 1); + if (braceStart === -1) return {}; + + let depth = 0; + let braceEnd = -1; + for (let i = braceStart; i < content.length; i++) { + if (content[i] === '{') depth++; + if (content[i] === '}') depth--; + if (depth === 0) { + braceEnd = i; + break; + } + } + if (braceEnd === -1) return {}; + + const objContent = content.slice(braceStart + 1, braceEnd); + const entries = {}; + + const numRegex = /(\d+):\s*'([^']+)'/g; + let m; + while ((m = numRegex.exec(objContent)) !== null) { + entries[m[1]] = m[2]; + } + + const strRegex = /'([^']+)':\s*'([^']+)'/g; + while ((m = strRegex.exec(objContent)) !== null) { + entries[m[1]] = m[2]; + } + + return entries; +} + +function buildAvatarList(creditedSprites, existingNumbers) { + const avatars = []; + const seen = new Set(); + + for (let i = 1; i <= 293; i++) { + if (i === 162 || i === 168) continue; + const name = existingNumbers[i] || `${i}`; + if (!seen.has(name)) { + avatars.push({ + id: name, + num: i, + category: 'default', + }); + seen.add(name); + } + } + + for (const name of creditedSprites) { + if (!seen.has(name)) { + avatars.push({ + id: name, + num: null, + category: 'extra', + }); + seen.add(name); + } + } + + return avatars; +} + +function generateManifest(avatars) { + const manifest = { + version: 1, + avatarWidth: AVATAR_WIDTH, + avatarHeight: AVATAR_HEIGHT, + columns: COLUMNS, + generated: new Date().toISOString(), + avatars: [], + }; + + const defaultAvatars = avatars.filter(a => a.category === 'default'); + const extraAvatars = avatars.filter(a => a.category === 'extra'); + + for (const avatar of defaultAvatars) { + const col = (avatar.num - 1) % COLUMNS; + const row = Math.floor((avatar.num - 1) / COLUMNS); + manifest.avatars.push({ + id: avatar.id, + num: avatar.num, + sheet: 'trainers-sheet', + x: col * AVATAR_WIDTH, + y: row * AVATAR_HEIGHT, + category: 'default', + }); + } + + for (const avatar of extraAvatars) { + manifest.avatars.push({ + id: avatar.id, + num: null, + sheet: null, + category: 'extra', + }); + } + + return manifest; +} + +function generateTsData(manifest) { + const lines = [ + '// auto-generated by build-tools/build-trainers', + '', + 'export interface TrainerAvatarData {', + '\tid: string;', + '\tnum: number | null;', + '\tsheet: string | null;', + '\tx?: number;', + '\ty?: number;', + '\tcategory: "default" | "extra";', + '}', + '', + 'export const TrainerAvatarManifest = {', + `\tversion: ${manifest.version},`, + `\tavatarWidth: ${manifest.avatarWidth},`, + `\tavatarHeight: ${manifest.avatarHeight},`, + `\tcolumns: ${manifest.columns},`, + `\tgenerated: "${manifest.generated}",`, + `\tcount: ${manifest.avatars.length},`, + `\tdefaultCount: ${manifest.avatars.filter(a => a.category === 'default').length},`, + `\textraCount: ${manifest.avatars.filter(a => a.category === 'extra').length},`, + '} as const;', + '', + 'export const TrainerAvatars: TrainerAvatarData[] = [', + ]; + + for (const avatar of manifest.avatars) { + if (avatar.sheet) { + lines.push(`\t{ id: "${avatar.id}", num: ${avatar.num}, sheet: "${avatar.sheet}", x: ${avatar.x}, y: ${avatar.y}, category: "${avatar.category}" },`); + } else { + lines.push(`\t{ id: "${avatar.id}", num: null, sheet: null, category: "${avatar.category}" },`); + } + } + + lines.push('];'); + lines.push(''); + + return lines.join('\n'); +} + +function main() { + console.log('Building trainer avatar manifest...'); + + const indexPath = path.join(spritesDir, 'index.php'); + if (!fs.existsSync(indexPath)) { + console.error('Error: sprites/trainers/index.php not found'); + process.exit(1); + } + + const creditedSprites = parseCreditedSprites(indexPath); + console.log(`Found ${creditedSprites.length} credited sprites`); + + const existingNumbers = getExistingAvatarNumbers(srcDir); + console.log(`Found ${Object.keys(existingNumbers).length} existing avatar mappings`); + + const avatars = buildAvatarList(creditedSprites, existingNumbers); + console.log(`Total avatars: ${avatars.length}`); + + const manifest = generateManifest(avatars); + + const MIN_AVATARS = 500; + if (manifest.avatars.length < MIN_AVATARS) { + console.error(`Error: expected at least ${MIN_AVATARS} avatars, got ${manifest.avatars.length}`); + process.exit(1); + } + + const defaultCount = manifest.avatars.filter(a => a.category === 'default').length; + if (defaultCount < 290) { + console.error(`Error: expected at least 290 default avatars, got ${defaultCount}`); + process.exit(1); + } + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const jsonPath = path.join(outputDir, 'trainers-manifest.json'); + fs.writeFileSync(jsonPath, JSON.stringify(manifest, null, 2)); + console.log(`Written: ${jsonPath}`); + + const tsPath = path.join(srcDir, 'trainer-avatars.ts'); + fs.writeFileSync(tsPath, generateTsData(manifest)); + console.log(`Written: ${tsPath}`); + + console.log('Done!'); +} + +main(); diff --git a/play.pokemonshowdown.com/src/panel-popups.tsx b/play.pokemonshowdown.com/src/panel-popups.tsx index c5ad0c9a03b..41c3a887abd 100644 --- a/play.pokemonshowdown.com/src/panel-popups.tsx +++ b/play.pokemonshowdown.com/src/panel-popups.tsx @@ -11,6 +11,7 @@ import { type BattleRoom } from "./panel-battle"; import { ChatUserList, type ChatRoom } from "./panel-chat"; import { PSRoomPanel, PSPanelWrapper, PSView } from "./panels"; import { PSHeader } from "./panel-topbar"; +import { TrainerAvatars, TrainerAvatarManifest, type TrainerAvatarData } from "./trainer-avatars"; /** * User popup @@ -960,33 +961,127 @@ class LoginPanel extends PSRoomPanel { } } +const AVATARS_PER_PAGE = 120; + class AvatarsPanel extends PSRoomPanel { static readonly id = 'avatars'; static readonly routes = ['avatars']; static readonly location = 'semimodal-popup'; - override render() { - const room = this.props.room; + search = ''; + page = 0; + category: 'all' | 'default' | 'extra' = 'all'; + + handleSearch = (ev: Event) => { + const input = ev.currentTarget as HTMLInputElement; + this.search = input.value.toLowerCase(); + this.page = 0; + this.forceUpdate(); + }; + + handleCategoryChange = (ev: Event) => { + const select = ev.currentTarget as HTMLSelectElement; + this.category = select.value as any; + this.page = 0; + this.forceUpdate(); + }; - const avatars: [number, string][] = []; - for (let i = 1; i <= 293; i++) { - if (i === 162 || i === 168) continue; - avatars.push([i, window.BattleAvatarNumbers?.[i] || `${i}`]); + handlePageChange = (delta: number) => { + this.page = Math.max(0, this.page + delta); + this.forceUpdate(); + }; + + getFilteredAvatars(): TrainerAvatarData[] { + let avatars = TrainerAvatars; + + if (this.category !== 'all') { + avatars = avatars.filter(a => a.category === this.category); } + if (this.search) { + avatars = avatars.filter(a => a.id.includes(this.search)); + } + + return avatars; + } + + renderAvatar(avatar: TrainerAvatarData) { + const isCurrent = avatar.id === PS.user.avatar; + + if (avatar.sheet && avatar.x !== undefined && avatar.y !== undefined) { + return ; + } + + return ; + } + + override render() { + const room = this.props.room; + const filteredAvatars = this.getFilteredAvatars(); + const totalPages = Math.ceil(filteredAvatars.length / AVATARS_PER_PAGE); + const page = Math.min(this.page, Math.max(0, totalPages - 1)); + const startIdx = page * AVATARS_PER_PAGE; + const pageAvatars = filteredAvatars.slice(startIdx, startIdx + AVATARS_PER_PAGE); + return
- +
+ + + + +
+ +
+ + Use /avatar [name] in chat to change your avatar. {} + Use /avatars to open this panel. {} + View all trainer sprites + +
+ + {totalPages > 1 &&
+ + + Page {page + 1} of {totalPages} ({filteredAvatars.length} avatars) + + +
} +
- {avatars.map(([i, avatar]) => ( - - ))} + {pageAvatars.map(avatar => this.renderAvatar(avatar))}
+

; diff --git a/play.pokemonshowdown.com/style/client2.css b/play.pokemonshowdown.com/style/client2.css index 5e509057f6b..96c4e4967c4 100644 --- a/play.pokemonshowdown.com/style/client2.css +++ b/play.pokemonshowdown.com/style/client2.css @@ -2265,6 +2265,26 @@ pre.textbox.textbox-empty[placeholder]:before { background-color: #F1F4F9; box-shadow: 1px 1px 1px #D5D5D5; } +.avatarlist button.extra-avatar { + background: transparent no-repeat center center; + background-size: contain; +} +.avatar-controls { + margin-bottom: 8px; +} +.avatar-help { + margin-bottom: 8px; + color: #555555; +} +.avatar-help code { + background: #f0f0f0; + padding: 2px 4px; + border-radius: 2px; +} +.avatar-pagination { + margin-bottom: 8px; + text-align: center; +} .volume { min-height: 34px;