A pixel Pokémon that lives at the right edge of the band above the prompt

A pixel Pokémon that lives above your Claude Code prompt and reacts to everything Claude does.

claude plugin marketplace add dgokcin/claude-pokemon-mod
claude plugin install pokemon@claude-pokemon
Then pick your partner with /pokemon <mon>.
[!TIP] It works best in iTerm2 or another terminal that draws images, like kitty or Ghostty, where the small band keeps every pixel. Turn images on in the
envblock of~/.claude/settings.json:{ "env": { "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" } }
>_ for shell commands, a TM disc for skills./pokemon catch fills your Pokédex.Watch it follow Claude's tools. A magnifier for reads, a pencil for writes, and a level-up when the answer lands.

Run subagents and watch the balls drop.

Evolve it. At the right level, or with a stone.

Make it attack. Magikarp uses Splash. Nothing happens. Gengar uses Night Shade.

| Command | What it does |
|---|---|
/pokemon <mon> | Pick a mon, like /pokemon charmander |
/pokemon pet | Pet it |
/pokemon feed | Toss it a berry |
/pokemon attack [move] | Use a random move, or a named one like thunderbolt |
/pokemon evolve | Evolve with a stone or a trade |
/pokemon stats | Level, XP, evolution, and meters |
/pokemon box | Every mon you've raised |
/pokemon catch | Throw a Poké Ball at a wild mon |
/pokemon dex | Every wild mon you've seen and caught |
/pokemon new <mon> | Fetch a mon from gen 2 to 5, like /pokemon new mudkip, or a random one with /pokemon new gen3 |
| Command | Effect |
|---|---|
/pokemon | Show the active mon, its variant, level, meters, and the options |
/pokemon <mon> | Pick the mon for this session, or for every open session with /pokemon sync on. A new session starts with the one picked last |
/pokemon list | List every mon name |
/pokemon shiny, /pokemon default | Pick a variant, saved across sessions |
/pokemon wander | Toggle idle wandering (on by default) |
/pokemon pet | Pet the mon. A sleeping mon stays asleep. Counts pets across sessions |
/pokemon feed | Toss it a random oran 🫐, pecha 🍑, razz 🍓, or sitrus 🍋 berry |
/pokemon attack | Use a random move from the mon's moveset |
/pokemon attack <move> | Use a named move, ignoring case, spaces, and dashes |
/pokemon moves, /pokemon attack list | List the active mon's moves |
/pokemon moves <mon> | List another mon's moves |
/pokemon evolve | Evolve with a stone or a trade. Also evolves a mon at its level with autoevolve off, and restarts an evolution you cancelled |
/pokemon evolve <mon> | Pick what Eevee becomes, like /pokemon evolve jolteon |
/pokemon stop | Cancel an evolution |
/pokemon sleep | Tuck the mon in, so its meters drain slower until Claude starts working in a session showing it. Run it again to wake it |
/pokemon needs | Toggle food and happiness (on by default) |
/pokemon sync | Toggle syncing the mon across sessions (off by default, saved across sessions). On, every open session switches to the mon picked last in any of them, fetched mons included. Off, each session keeps its own |
/pokemon autoevolve | Toggle level evolutions starting on their own once idle (on by default). Off, the mon waits for /pokemon evolve |
/pokemon emoji | Toggle emoji meters, 🍓 and 💗 instead of ● and ♥ (off by default, saved across sessions) |
/pokemon size small, /pokemon size large | Draw the band at half size or full size (large by default, saved across sessions). A short pane still shrinks it further. See How it works for crisp small mons in iTerm2 |
/pokemon stats | Show the level, XP to the next one, how it evolves, the meters, and your pets and feeds |
/pokemon box | List every mon you've raised, highest level first |
/pokemon new <mon> | Fetch a mon from any generation in vscode-pokemon (gen 1 to 5) and show it. It's saved in the mod's store, so it stays through plugin updates and shows up in every session. Moves come from its types, using gen 1 animations |
/pokemon new random, /pokemon new gen<N> | Fetch a random mon you don't have yet, from gen 2 to 5 or from gen N |
/pokemon nickname <name> | Nickname the mon, up to 12 characters. It keeps the name when it evolves, and its species name takes the nickname away |
/pokemon release <mon> | Release a mon from your box. It starts over at its first level with fresh meters |
/pokemon catch | Throw a Poké Ball at the wild mon. The odds rise as its HP falls |
/pokemon run | Send the wild mon away |
/pokemon dex | Count the wild mons you've seen and caught, and list them in Pokédex order. ✦ marks a shiny catch |
/pokemon dex <mon> | Show one mon's Pokédex number, how rare it is, and whether you've seen or caught it |
| Ctrl+X Ctrl+A | Collapse or expand the band (Claude Code's own binding) |
| When | The mon | Driven by |
|---|---|---|
| Claude works | Paces back and forth across the strip | band isWorking |
| Claude thinks | Shows a thought bubble with animated dots | spinner mode === 'thinking' |
| Claude runs a tool | Shows the tool in its bubble: a pencil for edits, a magnifier for reads and searches, >_ for shell, a Poké Ball for subagents, a wrench for the rest | tool.call (main agent only) |
| A skill is used | Shows a TM disc in its bubble while a Skill call runs in any agent, and for 3 s when you type a skill as /name. Skills from your own files, plugins, and MCP servers count; Claude Code's built-in commands don't | tool.call (any agent), command.run, skill.prompt |
| A tool call fails | Shakes, then holds still with a blue sweat drop. Calls you deny or interrupt don't count. It skips the flinch while a move or a "!" is showing | tool.call (main agent only), tool.check |
| Claude needs you | Stops, faces you, and shows a red "!" until you answer. If you don't reply within a minute of a turn ending, it shows the "!" for 2 minutes | AskUserQuestion, ExitPlanMode, turn.complete, classic.PermissionRequest, classic.Notification |
| A subagent runs | Drops a Poké Ball that wobbles while the subagent works and pops when it ends, or when the agent list stops reporting it at work, as for a teammate in its own pane. Past six, the last slot shows +n | agent.spawn, turn.complete, $.agent.list() |
| A turn ends | Hops twice, unless you interrupted it | turn.complete (main agent only) |
| A turn ends with an answer | Earns XP, and may level up or evolve | turn.complete (main agent only) |
| A wild mon is due | Walks home, and the strip widens as the foe walks in from the left | tool.call (main agent only), the shared wildAt store key |
| Claude works during a battle | Banks a move per tool call and answered turn, and fires one at the foe every 6 to 8 s | tool.call (any agent), turn.complete |
| You're idle | Strolls to random spots, resting 3 to 8 s between walks. With wandering off, it walks home and bobs | |
| 5 minutes with no activity | Falls asleep with pixel Zs | prompt.edit, prompt.submit, turns |
| Food or happiness under 30% | Thinks of a berry (hungry) or a heart (lonely). Walks slower when hungry | |
| Happiness at 80% or more | Hops for joy every 20 to 40 s |
Some organizations run a policy plugin that blocks classic.* events from user plugins. Under one, the "!" still shows for questions, plan approval, and the idle minute, but not for permission dialogs.
Two meters sit at the bottom right of the band. Each icon is 20%.
| Meter | Icons | Empties from full in | Filled by |
|---|---|---|---|
| Food | ●●●○○ | 8 hours | /pokemon feed: +20 |
| Happiness | ♥♥♥♥♥, the last two dimmed | 12 hours | /pokemon pet: +25, /pokemon feed: +5 |
The icons are plain text, one column each. Empty hearts are a dimmed ♥, because many fonts lack ♡ and borrow it from another font at another size. If your terminal draws emoji well, /pokemon emoji swaps them for 🍓🍓🍓○ ○ and 💗💗💗♡ ♡, two columns each. Run it again to switch back.
The meters drain at a quarter speed while Claude Code is closed. Only the mons on show drain, so the others in your box keep their meters until you pick them again. Each open session shows its own mon, but level, meters, nickname, and sleep belong to the mon, so sessions showing the same one share them. Asleep with /pokemon sleep, food drains at half speed and happiness at a quarter. A new mon starts at 80%. The meters scale the XP a turn earns, from half when both are empty to one and a half when both are full. /pokemon needs turns them off, and XP then ignores them.
Each answered turn of the main agent earns 10 + 2 × level XP, doubled for turns of two minutes or more and scaled by the meters. Levels follow the games' medium fast curve, where level L takes L³ XP. A new mon starts at 5, or at the level it evolves at, so Charmeleon starts at 16. With 30 s turns and full meters, 5 to 16 takes about 75 turns.
When the mon reaches its evolution level from Red and Blue, it evolves once idle. It glows, flickers between its two shapes, and flashes into its new form. /pokemon stop cancels, and the next level-up tries again. Level, XP, and meters carry over. With /pokemon autoevolve off, reaching the level only makes the mon ready, and it evolves when you run /pokemon evolve.
The 19 mons that evolve with a stone or a trade, like Pikachu, Eevee, and Kadabra, use /pokemon evolve. Eevee needs a pick, like /pokemon evolve jolteon.
A move takes over the mon's body. For a move aimed ahead, the mon turns toward the roomier side, backs up to the far edge, and then winds up, lunges, or leaps as the effect plays across the strip. A move on itself, like Harden or Recover, plays in place, and the mon braces, shrinks, glows, or falls asleep. Teleport moves it to a random spot, Dig tunnels it forward, Transform borrows another mon's sprite, and Splash does nothing at all.
hooks/moves.js holds two tables. MOVE_FX maps each move to an effect, with an optional color, a power of 1 or 2, and a text line. MOVES lists each mon's moves. An entry can be { name, from: 'top' } to launch from the top of the sprite, like Vine Whip from Bulbasaur's bulb, or { name, from: 'body' } to pour from the whole body, like Koffing's Smog. An unknown effect plays impact.
File in hooks/effects/ | Effects |
|---|---|
beams.js | hyperbeam, solarbeam, psybeam, aurorabeam, icebeam, bubblebeam |
electric.js | thundershock, thunderbolt, thunder, thunderwave |
fire.js | ember, flamethrower, firespin, dragonrage |
water.js | watergun, hydropump, surf, waterfall, bubble, clamp |
nature.js | vinewhip, razorleaf, petaldance, leechseed, drain, leechlife, powder, stringshot |
poison.js | poisonsting, twineedle, acid, sludge, gas, pinmissile, spikecannon |
mind.js | confusion, psychic, kinesis, hypnosis, nightshade, lick, confuseray, dreameater |
sound.js | screech, supersonic, growl, roar, sing, lovelykiss |
charge.js | tackle, quickattack, bodyslam, headbutt, skullbash, takedown, rage, thrash, outrage, hornattack, horndrill, stomp, slam |
strikes.js | pound, doubleslap, megapunch, cometpunch, firepunch, icepunch, thunderpunch, megakick, lowkick, doublekick, hijumpkick, seismictoss, submission, karatechop |
weapons.js | scratch, slash, furyswipes, cut, bite, hyperfang, crabhammer, vicegrip, guillotine, peck, drillpeck, furyattack, boneclub, bonemerang, wrap |
sky.js | gust, wingattack, fly, skyattack, agility, doubleteam |
earth.js | earthquake, dig, rockthrow, rockslide, sandattack, blizzard, mist |
guard.js | harden, withdraw, defensecurl, minimize, focusenergy, meditate, amnesia, barrier, reflect, lightscreen, acidarmor |
self.js | recover, softboiled, rest, splash, teleport, transform, explosion |
special.js | swift, payday, triattack, eggbomb |
evolve.js | evolve, the evolution sequence |
basic.js | impact, the fallback |
hooks/effects/draw.js holds the shared drawing helpers.
About every 20 to 40 minutes of work, a wild mon walks in from the left. Open sessions share one timer, so running several brings no more, and the first one comes about 5 minutes into your work. Base forms turn up most. Evolved mons, fossils, and the likes of Lapras and Snorlax are rarer, the five legendaries rarest, and 1 in 128 is shiny.
Your work does the fighting. Each tool call and each answered turn banks a move, two at most, and the mon spends one every 6 to 8 s on a random move aimed at the foe. Each hit shrinks the HP bar over the foe's head. A common foe takes about 3 hits, a legendary about 10.
/pokemon catch throws a Poké Ball. The odds rise as HP falls. At full HP the ball always breaks free, and a worn out foe at 0 HP is a sure catch. The ball rocks once per shake before it clicks shut or the foe breaks out.
The foe leaves after 4 minutes without a hit, 90 s after it's worn out, after 4 turns end without a catch, when the mon falls asleep, or on /pokemon run. /pokemon feed waits until it's gone, and so does an evolution that comes due.
The Pokédex counts wild mons only, seen when one shows up and caught when you catch it. A caught species joins your box at its first level, unless it's there already, and its line in /pokemon box shows ◓. Releasing a mon keeps it in the dex. /pokemon <mon> still picks any mon, and a mon evolved from a caught one shows no ◓ until you catch its evolved form too.
The battle needs a wider strip than the mon alone. A band 80 columns wide fits every pair, a narrower one only the smaller foes, and under about 40 columns no encounter comes.
All 151 gen 1 Pokémon are included, plus pikachu_female and venusaur_female. Names match the source folders, so use mrmime, farfetchd, nidoran_female, and nidoran_male.
Each terminal cell holds two pixels with ▀/▄ half blocks, and the mon paces inside a 40 column strip. The Desktop app has no cell grid to paint, so it draws the same pixels as an SVG, 4 px per pixel, with the level and meters beside it as text. The build crops each 32x32 GIF to the smallest box that fits all its frames, so the band height depends on the mon. Diglett is the smallest at 7 rows. Fearow, Gyarados, and Pidgeot are the tallest at 17.
/pokemon size small draws the band at half size, laid out just like the full-size one. kitty and Ghostty draw the small band as a real image, so every pixel stays. iTerm2 3.7 and older can't, because of an iTerm2 bug that freezes the image on its first frame. Newer iTerm2 builds, nightlies from 2026-09-18 on included, draw the image once you set CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1, for example in the env block of ~/.claude/settings.json. VS Code's terminal, Cursor's included, and macOS Terminal can't show the image even with that set. In those terminals the small band is drawn in half blocks and shrinks no shorter than a full-size Diglett, since a half-size mon in half blocks is too small to read. In a small band a wild mon taller than yours shrinks to your mon's height, so it doesn't make the band taller and shrink yours with it, and a short one grows to look as tall as a large Diglett, up to your mon's height.
When the pane is too short, the whole scene shrinks to fit in half blocks, and so does the small band in other terminals. Each shrunk pixel takes the most common color of its block. Ties go to the color at the middle of the block, so lines stay unbroken, and then to the darker color, so outlines survive. Each eye shrinks to one dot of its pupil, its darkest color or on a dark face its brightest, with the rest of the eye painted over as skin, so a shrunk mon never loses its eyes and both come out alike. An eye drawn as a line keeps the middle of the line. The thought bubble, the Zs, and the berry aren't shrunk with the scene, as they'd break into specks: a shrunk band draws them from smaller art of its own: a smaller round bubble with 3 px icons and a pixel of room around them, 3 px Zs, and a 3 px berry. Below 4 rows it shows a line of text instead, like Pikachu Lv 12 ●●●●○ ♥♥♥♥♡.
git clone https://github.com/dgokcin/claude-pokemon-mod
claude plugin marketplace add ./claude-pokemon-mod
claude plugin install pokemon@claude-pokemon
A local marketplace loads the mod in place, so your edits apply on /reload-plugins.
To call a wild mon without waiting, turn on debug mode:
~/.claude/plugins/store/pokemon_<marketplace>-<hash>.json. For the local marketplace above it's pokemon_claude-pokemon-<hash>.json."debug": true to the top-level object./reload-plugins.Then /pokemon wild, /pokemon wild <mon>, or /pokemon wild <mon> shiny calls one in without waiting for the shared timer. Without debug, wild replies as an unknown option.
To try a mon without changing the repo, /pokemon new <mon> fetches it at runtime instead. The steps below bundle it, with a hand-picked moveset, evolutions, and eyes that close when it sleeps.
media/gen<N>/<mon>/ in vscode-pokemon into sprites/<mon>/: default_idle_8fps.gif, default_walk_8fps.gif, shiny_idle_8fps.gif, and shiny_walk_8fps.gif.node scripts/build-frames.mjs.MOVES in hooks/moves.js. Every mon needs at least one move.hooks/evolutions.js. If its name isn't just the key capitalized, add it to hooks/names.js.EYES in hooks/eyes.js, and check them asleep with node scripts/preview-sleep.mjs <mon>.node scripts/check-data.mjs, then /reload-plugins.node scripts/build-frames.mjs # after changing sprites/
node scripts/preview-attack.mjs <mon> <move> # contact sheet in /tmp/<mon>-<move>.png
node scripts/preview-attack.mjs <mon> <move> --at 2 # start from the left edge
node scripts/preview-sleep.mjs <mon>... # awake and asleep frames in /tmp/sleep.png
node scripts/preview-wild.mjs <mon> <foe> # a battle in /tmp/<mon>-vs-<foe>.png
node scripts/preview-wild.mjs <mon> <foe> --move ember # one move at the foe
node scripts/preview-wild.mjs <mon> <foe> --throw catch # a ball that holds, or --throw fail
node scripts/check-data.mjs # moves, evolutions, and sprites agree
npx @biomejs/biome@2.5.15 ci --error-on-warnings . # lint, same as CI
claude plugin validate .
claude plugin test
claude --plugin-dir . # live-reloading session
| Path | Contents |
|---|---|
hooks/register.js | Band renderer, animation timer, /pokemon command |
hooks/attacks.js | Attack registry: each move's pose, aim, and frame |
hooks/effects/*.js | Attack animations, one file per family |
hooks/moves.js | Each move's effect, and each mon's moveset |
hooks/names.js | Display names, like Nidoran♀ and Mr. Mime |
hooks/levels.js | The XP curve, and the XP a turn earns |
hooks/evolutions.js | Gen 1 evolutions, and each mon's start level |
hooks/party.js | The Poké Balls of running subagents |
hooks/dex.js | The Pokédex, and how rare each mon is in the wild |
hooks/wild.js | A wild mon's visit: walking in, battle, throws, and fleeing |
hooks/zoom.js | Shrinks the band to its size and to fit a short pane |
hooks/png.js | Encodes the small band's frames as PNGs for terminals that draw images |
hooks/eyes.js | Each mon's eyes on every frame, shut while it sleeps |
hooks/frames.js | Generated pixel frames. Don't edit by hand |
sprites/<mon>/*.gif | Source GIFs, 32x32 |
scripts/ | Frame builder, attack, sleep, and battle previewers on a stand-in host, data check |
tests/pokemon.test.ts | claude plugin test suite |
CI runs the lint, the data check, and the tests on every pull request and push to main. Commits follow Conventional Commits. When main gains a feat, fix, or perf commit, CI tags it with the next version from git-cliff and publishes a GitHub release with the notes. plugin.json carries no version, so Claude Code versions the plugin by commit and every merge reaches users.
The sprites come from vscode-pokemon by Jakob Hoeg Mørk, the extension that puts Pokémon in your VS Code window. Those GIFs took slow, manual work to make, and this mod would not exist without them. If you like the mod, give the original a star. vscode-pokemon builds on vscode-pets by Anthony Shaw.
sprites/ holds unmodified copies of the gen 1 GIFs from vscode-pokemon's
hooks/register.js 2781 lines1import { aimsAhead, attackFrame, attackPose, prepareAttack } from './attacks.js'
2import { CUSTOM_PREFIX, fetchMon, monsOfGen } from './custom.js'
3import { DEX, dexKey, dexNumber, isCaught, mergedDexEntry, pickWild, tierOf } from './dex.js'
4import { evolutionsOf, levelEvolutionOf, startLevel } from './evolutions.js'
5import { closedEyes, eyesOf, keptEyesOf, pupilsOf } from './eyes.js'
6import { SPRITES } from './frames.js'
7import { MAX_LEVEL, careOf, levelAt, turnXp, xpAt } from './levels.js'
8import { MOVES, movesOf } from './moves.js'
9import { displayName } from './names.js'
10import { encodePng } from './png.js'
11import { BALL, BALL_COLORS, FLASH, LEANS, LIGHT_COLORS, OPEN, joinParty, leaveParty, partyStamps, prunedParty } from './party.js'
12import {
13 ATTACK_GAP_MS, ATTACK_JITTER_MS, THROW_TICKS, WILD_STAY_MS, WILD_TURNS, WORN_OUT_MS, battleColumns, chargedWild, damageOf,
14 foeBlinks, foeJolt, foeSpotX, foeX, fleeingWild, hpBar, isHere, isOnStage, newWild, stepWild, thrownWild, throwStage,
15} from './wild.js'
16import { SIZES, zoomFor, zoomedAt, zoomedPixel } from './zoom.js'
17
18// One pixel Pokémon lives in a strip at the right of the band above the prompt.
19// While Claude works it paces, with a thought bubble while Claude thinks. It
20// hops when a turn ends, then wanders while idle (or stays at the right edge,
21// after /pokemon wander) and falls asleep after five quiet minutes.
22// /pokemon pet makes pixel hearts float up, and /pokemon feed drops a random pixel
23// berry for it to walk over and eat. Food and happiness drain over time, slower while Claude Code is closed,
24// shown as ●●●○○ and ♥♥♥♡♡ at the right edge, so it needs both now and then.
25// /pokemon emoji swaps the meters' icons for 🍓 and 💗.
26// /pokemon sleep tucks it in, so both meters drain slower until Claude starts working.
27// /pokemon attack plays one of its moves, with the animations in attacks.js.
28// While a tool runs, the bubble shows it, with a TM disc for a skill from any agent, and
29// when Claude waits on you the mon stops and shows a "!". Each running subagent is a Poké Ball along the left of the strip.
30// A main-loop tool call that fails makes it flinch, with a sweat drop by its head.
31// Answered turns earn XP, and the mon evolves at the levels from the games.
32// Now and then a wild mon walks in from the left, and the strip widens to fit both.
33// Each open session shows its own mon, starting with the one last picked anywhere. XP,
34// meters, nickname, and sleep belong to the mon, so sessions showing the same mon share them.
35// The mon, hearts, berries, bubbles, balls, and Zs are pixels in one Raster, two per cell.
36// The desktop app has no Raster, so there the same pixels are one Svg.
37
38const TICK_MS = 50
39// CSS pixels per sprite pixel on the desktop
40const PIXEL_PX = 4
41// Image pixels per sprite pixel. The terminal scales the Image to its box, so sending
42// each pixel as a block keeps its edges sharp.
43const IMAGE_UP = 4
44// How long a terminal that drew an Image's alt gets half blocks before the Image is asked
45// for again, from the first wait to the longest
46const IMAGE_WAIT_TICKS = [(2 * 1000) / TICK_MS, (5 * 60 * 1000) / TICK_MS]
47const MOVE_TICKS = 3
48const HOP_TICKS = 12
49const SLEEP_AFTER_MS = 5 * 60 * 1000
50const REST_TICKS = [60, 160]
51const STRIP_COLUMNS = 40
52const HEAD_ROWS = 1
53const HEAD_PIXELS = 8
54const DEFAULT_COLOR = 0x01000000
55const HEART_COLORS = [0xff4f8b, 0xff9ec4]
56const HEART_COUNT = 7
57const HEART_GAP_TICKS = 7
58const HEART_RISE_TICKS = 3
59// Pixel hearts, big and small in turn: p body, h highlight
60const HEARTS = [['pp.pp', 'phppp', '.ppp.', '..p..'], ['p.p', 'ppp', '.p.']]
61const HEART_SHINE = 0xffe0ec
62const PET_TICKS = 60
63const FALL_TICKS = 2
64const EAT_TICKS = 30
65const YUM_TICKS = 30
66const FLINCH_TICKS = 24
67// How long the TM disc stays up after a skill is typed as /name
68const DISC_TICKS = 60
69const SHAKE_TICKS = 12
70const DRIP_TICKS = 8
71// A 4x4 pixel berry: k leaf, b rim, B body, h highlight. Each kind recolors it.
72const BERRY = ['.kk.', 'bBBb', 'bBhb', '.bb.']
73const BERRY_SIZE = 4
74const LEAF_COLOR = 0x3a7a2a
75const BERRIES = [
76 { name: 'oran berry', emoji: '🫐', colors: { k: LEAF_COLOR, b: 0x2c3f9e, B: 0x4a63d8, h: 0x9fb4ff } },
77 { name: 'pecha berry', emoji: '🍑', colors: { k: LEAF_COLOR, b: 0xc2477a, B: 0xf48fb1, h: 0xffd1e3 } },
78 { name: 'razz berry', emoji: '🍓', colors: { k: LEAF_COLOR, b: 0x8e1240, B: 0xd81b60, h: 0xff7aa8 } },
79 { name: 'sitrus berry', emoji: '🍋', colors: { k: LEAF_COLOR, b: 0xc9a400, B: 0xfdd835, h: 0xfff59d } },
80]
81// Colors the band draws straight on the terminal background, one set per theme
82const INKS = {
83 dark: {
84 bubble: 0xf0f0f0, dots: 0x9a9ab0, z: 0xe8e8ff, star: 0xffd54f, silhouette: 0xf8f8ff,
85 pencil: 0xffc83d, lens: 0x6cc4ff, shell: 0x5fe08a, ball: 0xeeeef6, disc: 0xd4d4e0, wrench: 0xb8b8c8, party: 0xb4b4c8,
86 discShade: 0x8e8ea0, sweat: 0x6ec8ff, sweatShine: 0xe6f7ff, hpEmpty: 0x404048,
87 },
88 light: {
89 bubble: 0x4a4a58, dots: 0x70708a, z: 0x5a5aa8, star: 0xe0a000, silhouette: 0x2e2e3a,
90 pencil: 0xe08a00, lens: 0x2a7ad0, shell: 0x1f9a4a, ball: 0xa8a8b4, disc: 0x6e6e88, wrench: 0x6a6a80, party: 0x5c5c74,
91 discShade: 0x44445a, sweat: 0x2b8fe0, sweatShine: 0xbfe4ff, hpEmpty: 0xc8c8d0,
92 },
93}
94const UPPER_HALF = 0x2580
95const LOWER_HALF = 0x2584
96const SPACE = 0x20
97// Every mon that can be shown: the bundled ones, then any fetched with /pokemon new
98const MONS = []
99const VARIANTS = ['default', 'shiny']
100
101let mon = 'abra'
102let variant = 'default'
103let wander = true
104let working = false
105// Whether a model turn of the main agent is running, from the turn events alone. The
106// band's isWorking can read true while no turn runs, as when a slash command runs.
107let turnRunning = false
108let thinking = false
109const runningTools = new Map()
110// Skill calls in flight from any agent, main loop or subagent, by tool_use_id
111const runningSkills = new Set()
112// The tick a typed skill's TM disc shows until
113let discUntil = 0
114let toolCalls = 0
115const verdicts = new Map()
116let flinchStart = -FLINCH_TICKS
117const alertTools = new Set()
118let alertUntil = 0
119let idleAlertAt = null
120let party = []
121let bandId = null
122// The surface the band is drawn on, terminal or desktop
123let bandSurface = 'terminal'
124let columns = STRIP_COLUMNS
125// The columns behind the level and meters at the strip's right, 0 when they sit beside
126// a band shrunk to fit instead. The scene runs on under them, so a bubble, hearts, and Zs
127// can float over the meters, but the mon stays in the strip.
128let panel = 0
129// Empty columns drawn left of the strip, so a bubble always fits on the left of the head.
130// They take only band width that's free, and none in a band shrunk to fit.
131let margin = 0
132let tick = 0
133let x = STRIP_COLUMNS
134let facing = 'left'
135let lastActive = 0
136let hopStart = -HOP_TICKS
137let wanderTarget = null
138let restUntil = 0
139let petUntil = 0
140let hearts = []
141let food = null
142// Meter gains a berry or a pet has earned, paid once its animation ends
143const owed = []
144// The pet whose hearts are still floating, while it has a gain to pay
145let pettingFills = null
146let yumUntil = 0
147let attack = null
148let drawnRows = null
149let drawnColumns = null
150// The band's width as last drawn, for whether a battle fits
151let lastBodyColumns = 0
152// The wild mon visiting this session, as in wild.js
153let wild = null
154// Marks this session's claims on the shared encounter gate
155let sessionToken = null
156let lastSpawnCheck = -Infinity
157// The mon this session claimed the gate for, spawned once the claim proves to have stuck
158let claimedPick = null
159let nowMs = 0
160let stats = {}
161let needsOn = true
162let emojiOn = false
163// The band's size from /pokemon size, a key of SIZES
164let size = 'large'
165let autoEvolve = true
166// With /pokemon sync on, every open session follows the last mon picked in any of them
167let syncOn = false
168// The saved pick this session last read or wrote, so a sync follows only a newer pick
169let seenPick = null
170let evolving = null
171let evolveDue = null
172let joyHopAt = null
173let sentCells = null
174let sentImage = null
175// Set once a blit swaps the Image's source, so the terminal draws Images and a redraw
176// needn't ask again
177let imagesDrawn = false
178// Set on a terminal that can't show the small band's Image, which gets it in half blocks
179let noImages = false
180// After a terminal draws an Image's alt, the small band is half blocks until this tick.
181// Each deny in a row doubles the wait, since a terminal without kitty graphics never
182// draws one, while one whose graphics aren't ready yet soon does.
183let imageRetryAt = null
184let imageWait = IMAGE_WAIT_TICKS[0]
185// Whether the band as last drawn holds the Image, or else Raster cells
186let drawnImage = false
187let sentSvg = null
188// Counts the distinct frames drawn on the desktop. Each one goes under a new key, since
189// the desktop keeps showing an Svg's first frame while its place in the tree holds.
190let svgFrame = 0
191let maxRows = Infinity
192let ink = INKS.dark
193
194// Palette letter to 0xRRGGBB, per mon and variant
195const COLORS = {}
196const spriteRows = (name) => Math.ceil(SPRITES[name].height / 2)
197
198// The empty pixel rows under the feet in a frame, and the fewest across an animation's frames
199const gapOf = (rows) => rows.length - 1 - rows.findLastIndex((row) => /[^.]/.test(row))
200const gapUnder = (anim) => Math.min(...anim.map(({ rows }) => gapOf(rows)))
201// How far to lower the walk frames so the feet stand where the idle frames' do,
202// per mon and variant. Some sheets leave more empty rows under a walking mon.
203const WALK_DROP = {}
204// The empty columns right of an idle mon, the fewest across its idle frames, per variant.
205// Many sheets draw the idle mon right up to the frame's edge.
206const IDLE_RIGHT_GAP = {}
207// The rightmost head column of a mon walking right, across its walk frames, per variant
208const WALK_HEAD_RIGHT = {}
209
210// Measure a mon's sprite into the tables above, and list it unless it's a resized foe.
211// Fetched mons join here too.
212function addSprite(name, sprite, listed = true) {
213 SPRITES[name] = sprite
214 if (listed && !MONS.includes(name)) MONS.push(name)
215 const perVariant = (fn) => Object.fromEntries(VARIANTS.map((v) => [v, fn(sprite.variants[v])]))
216 COLORS[name] = perVariant(({ palette }) =>
217 Object.fromEntries(palette.map((hex, i) => [String.fromCharCode(97 + i), parseInt(hex.slice(1), 16)])),
218 )
219 WALK_DROP[name] = perVariant(({ walk, idle }) => gapUnder(walk) - gapUnder(idle))
220 IDLE_RIGHT_GAP[name] = perVariant(({ idle }) => {
221 const rights = idle.flatMap(({ rows }) => rows.map((row) => row.search(/[^.]\.*$/)))
222 return sprite.width - 1 - Math.max(...rights)
223 })
224 WALK_HEAD_RIGHT[name] = perVariant(({ walk }) =>
225 Math.max(...walk.flatMap(({ rows }) => rows.slice(0, HEAD_PIXELS).map((row) => row.search(/[^.]\.*$/)))),
226 )
227}
228for (const name of Object.keys(SPRITES)) addSprite(name, SPRITES[name])
229
230// Along one axis of size pixels resized to count, the pixels that full-size pixels lo up
231// to hi land on: blocks voted down to one when shrinking, copies spread out when growing
232function resizedSpan(lo, hi, size, count) {
233 if (count >= size) return [Math.ceil((lo * count) / size - 0.5), Math.ceil(((hi + 1) * count) / size - 0.5) - 1]
234 const cell = (v) => {
235 let t = 0
236 while (t + 1 < count && Math.ceil(((t + 1) * size) / count) <= v) t++
237 return t
238 }
239 return [cell(lo), cell(hi)]
240}
241
242// A mon's sprite resized to height pixels, the same scale both ways. A shrinking one takes
243// the band's votes, eyes kept, and a growing one copies each pixel into a block. Each frame
244// carries its pupils, moved with it, as eyes.js has no boxes for it.
245function resizedSprite(name, height) {
246 const sprite = SPRITES[name]
247 const scale = height / sprite.height
248 const width = Math.max(1, Math.round(sprite.width * scale))
249 const at = (v, size, count) => (count >= size ? Math.floor(((v + 0.5) * count) / size) : resizedSpan(v, v, size, count)[0])
250 const variants = {}
251 for (const v of VARIANTS) {
252 const sheet = sprite.variants[v]
253 const colors = COLORS[name][v]
254 const letters = new Map(Object.entries(colors).map(([l, c]) => [c, l]))
255 const resize = (view) =>
256 sheet[view].map((frame, index) => {
257 const source = frame.rows
258 const pupils = pupilsOf(source, sheet.palette, keptEyesOf(name, v, view, index))
259 const colorAt = (x, y) => colors[source[y]?.[x]] ?? null
260 const kept = pupils.map(([x, y, box, skin, inked]) => [x, y, colorAt(x, y), box && [...box, colors[skin], [...inked].map((l) => colors[l])]])
261 const shrunk = scale < 1 && zoomedPixel(colorAt, { columns: sprite.width, pixels: sprite.height, scale, pupils: kept })
262 const pixel = shrunk
263 ? (x, y) => letters.get(shrunk(x, y)) ?? '.'
264 : (x, y) => source[Math.floor(((y + 0.5) * sprite.height) / height)]?.[Math.floor(((x + 0.5) * sprite.width) / width)] ?? '.'
265 const rows = Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => pixel(x, y)).join(''))
266 const moved = pupils.map(([x, y, box, skin, inked]) => {
267 const point = [at(x, sprite.width, width), at(y, sprite.height, height)]
268 if (!box) return point
269 const [left, right] = resizedSpan(box[0], box[0] + box[2] - 1, sprite.width, width)
270 const [top, bottom] = resizedSpan(box[1], box[1] + box[3] - 1, sprite.height, height)
271 return [...point, [left, top, right - left + 1, bottom - top + 1], skin, inked]
272 })
273 return { ...frame, rows, pupils: moved }
274 })
275 variants[v] = { ...sheet, idle: resize('idle'), walk: resize('walk') }
276 }
277 return { ...sprite, width, height, variants }
278}
279
280// A wild mon in a small band stands no shorter on screen than a large Diglett
281const FOE_MIN_PIXELS = SPRITES.diglett.height
282
283// The foe as drawn. In a small band a foe taller than the home mon shrinks to its height,
284// so it doesn't grow the band and shrink the home mon with it, and a short one grows to
285// look as tall as a large Diglett, up to the home mon's height. Each size is resized once
286// and kept under its own name, unlisted. It goes by the size picked and the home mon's
287// scale alone, as the band's scale depends on the foe.
288function foeAs(name) {
289 if (SIZES[size] >= 1) return name
290 const own = SPRITES[name].height
291 const home = SPRITES[mon].height
292 const least = Math.min(home, Math.ceil(FOE_MIN_PIXELS / bandScale(rowsOf(mon)) - 1e-9))
293 const height = own > home ? home : Math.max(own, least)
294 if (height === own) return name
295 const sized = name + '@' + height
296 if (!SPRITES[sized]) addSprite(sized, resizedSprite(name, height), false)
297 return sized
298}
299const foeName = () => foeAs(wild.mon)
300
301const rowsOf = (name) => HEAD_ROWS + spriteRows(name)
302// While a mon evolves or transforms, the band is tall enough for both shapes
303const bandRows = () => {
304 if (evolving) return Math.max(rowsOf(evolving.from), rowsOf(evolving.into))
305 if (attack?.move.effect === 'transform') return Math.max(rowsOf(mon), rowsOf(attack.swap))
306 if (isOnStage(wild)) return Math.max(rowsOf(mon), rowsOf(foeName()))
307 return rowsOf(mon)
308}
309// The band's size on screen, shrunk to fit a short pane, or null when only the badge fits
310// A terminal known to lack Images draws the small band in half blocks, where a mon at
311// half size loses too much to read. It shrinks no shorter than a full-size Diglett.
312const HALF_BLOCK_MIN_ROWS = rowsOf('diglett')
313const bandScale = (rows = bandRows()) => {
314 const scale = SIZES[size]
315 if (scale >= 1 || !noImages || bandSurface !== 'terminal') return scale
316 return Math.min(1, Math.max(scale, HALF_BLOCK_MIN_ROWS / rows))
317}
318const bandZoom = () => zoomFor({ columns: sceneColumns(), rows: bandRows(), maxRows, size: bandScale() })
319// The strip and the panel, in the strip's own columns, and the whole scene with the margin
320const stripColumns = () => columns + panel
321const sceneColumns = () => margin + columns + panel
322// The rows the level and meters take at the bottom of the panel
323const panelRows = () => (needsOn ? 3 : 1)
324
325// The fewest empty columns between a mon at home and the level and meters beside it
326const HOME_GAP = 3
327// The right edge of the strip, where the sprite idles, kept HOME_GAP columns clear of the
328// meters whatever empty space the sprite's frame leaves on its right
329const homeXOf = (name) => Math.max(0, columns - SPRITES[name].width - Math.max(0, HOME_GAP - IDLE_RIGHT_GAP[name][variant]))
330const homeX = () => homeXOf(mon)
331const isHome = () => x >= homeX()
332
333const isHopping = () => tick - hopStart < HOP_TICKS
334const isPetted = () => tick < petUntil
335const isFlinching = () => tick - flinchStart < FLINCH_TICKS
336// Put to bed with /pokemon sleep, saved with the mon's meters so it stays asleep across sessions.
337const isTuckedIn = () => stats[mon]?.asleep === true
338// Five quiet minutes make it drowsy, in this session only
339const isDrowsy = () => (tick - lastActive) * TICK_MS >= SLEEP_AFTER_MS
340// Tucked in or drowsy, it walks home first and falls asleep once it gets there
341const isAsleep = () => !working && (isTuckedIn() || isDrowsy()) && isHome()
342
343// How far right of the sprite's left edge a berry lies when it sits under the middle of
344// the body, which can be off the middle of the frame. An eating mon stands unmirrored.
345function berryUnderBody() {
346 const box = boxOf(SPRITES[mon].variants[variant].idle[0].rows, COLORS[mon][variant])
347 return Math.round((box.left + box.right + 1 - BERRY_SIZE) / 2)
348}
349
350// Where the sprite stands to eat: with the berry in front of the middle of its body,
351// kept inside the strip
352function foodTarget() {
353 return Math.max(0, Math.min(homeX(), food.x - berryUnderBody()))
354}
355
356// Where the idle sprite is headed. Pacing while working is handled in step().
357function idleTarget() {
358 if (food) return food.eatStart === null ? foodTarget() : x
359 if (isAsleep() || isHopping() || isPetted()) return x
360 if (wild || !wander || isTuckedIn() || isDrowsy()) return homeX()
361 return wanderTarget ?? x
362}
363
364function isWalking() {
365 if (attack || isAlerted() || isFlinching()) return false
366 return food ? idleTarget() !== x : (working && !wild) || idleTarget() !== x
367}
368
369function markActive() {
370 lastActive = tick
371}
372
373// The frame showing after elapsed milliseconds, looping over each frame's own duration
374function frameAt(anim, elapsed) {
375 const total = anim.reduce((sum, f) => sum + f.ms, 0)
376 let t = elapsed % total
377 for (const f of anim) {
378 if (t < f.ms) return f.rows
379 t -= f.ms
380 }
381 return anim[0].rows
382}
383
384// Pixel art stamps: rows of letters, each letter a color, '.' transparent
385// The thought bubble is an outline with no fill, drawn in the theme's bubble ink
386const BUBBLE = ['.OOOOOOO.', 'O.......O', 'O.......O', 'O.......O', 'O.......O', 'O.......O', 'O.......O', '.OOOOOOO.']
387const BUBBLE_WIDTH = BUBBLE[0].length
388// The columns a bubble and its tail take beside the head
389const BUBBLE_SPAN = BUBBLE_WIDTH + 3
390const BUBBLE_TAIL_ROW = 2
391// Each icon has small art too, for a shrunk band's bubble
392const BUBBLE_ICONS = {
393 food: { rows: ['.rgr.', 'rrrrr', '.rrr.', '..r..'], small: ['.g.', 'rrr', '.r.'], colors: { r: 0xd84040, g: 0x58a040 } },
394 happiness: { rows: ['pp.pp', 'ppppp', '.ppp.', '..p..'], small: ['p.p', 'ppp', '.p.'], colors: { p: 0xf0609a } },
395}
396const STAR = ['..s..', 'sssss', '.sss.', '.s.s.']
397const SMALL_STAR = ['.s.', 'sss', 's.s']
398const SMALL_Z = ['zzz', '.z.', 'zzz']
399const BIG_Z = ['zzzz', '..z.', '.z..', 'zzzz']
400const ZZZ_EVERY_MS = 1100
401const ZZZ_LIFE_MS = 2200
402// The gap between a new Z and the side of the body, and how far out it drifts from there, in pixels
403const ZZZ_GAP = 0
404const ZZZ_DRIFT = 4
405// A shrunk band draws the bubble, Zs, and berry at its own pixel size, from art made for
406// it, instead of shrinking the full-size art down to specks. Its bubble is the full-size
407// one's shape a size down, holding the icons' small art with a pixel of room around it.
408const SMALL_ZS = { glyphs: [SMALL_Z, SMALL_Z], drift: 3 }
409const SMALL_BERRY = ['.k.', 'BhB', '.b.']
410// The bubble drawn: the full-size one, with the icon's columns and rows in from its corner
411const FULL_BUBBLE = { frame: BUBBLE, inset: [2, 2], small: false }
412// A shrunk band's bubble, with the icon's small art in the middle of it
413const SMALL_BUBBLE = ['.OOOOO.', 'O.....O', 'O.....O', 'O.....O', 'O.....O', 'O.....O', '.OOOOO.']
414function shrunkBubble(icon) {
415 const room = SMALL_BUBBLE.length - 2
416 const inset = [1 + Math.floor((room - icon.small[0].length) / 2), 1 + Math.floor((room - icon.small.length) / 2)]
417 return { frame: SMALL_BUBBLE, inset, small: true }
418}
419// The columns a bubble and its tail take beside the head
420const bubbleSpan = (look) => look.frame[0].length + 3
421// A sweat drop on the head when a tool call fails: d drop, h shine
422const SWEAT = ['..d.', '.dd.', 'dhdd', 'dddd', '.dd.']
423// The "!" of a trainer who spots you, shown while Claude waits on you
424const ALERT_ICON = { rows: ['..a..', '..a..', '.....', '..a..'], small: ['a', 'a', '.', 'a'], colors: { a: 0xff3d3d } }
425// What the bubble shows while a tool runs: a pencil (p body, e eraser, t wood, g lead),
426// a magnifier (l rim, h handle), a shell prompt (s), a Poké Ball (r top, k band,
427// w button, b bottom), a TM disc (c disc, d shade, n shine) for a skill, or a wrench (m) for
428// every other tool, MCP tools included
429const TOOL_ICONS = {
430 pencil: ['...pe', '..pp.', '.pp..', 'gt...'],
431 lens: ['.ll..', 'l..l.', '.llh.', '....h'],
432 shell: ['s....', '.s...', 's....', '..sss'],
433 ball: ['.rrr.', 'rrrrr', 'kkwkk', '.bbb.'],
434 disc: ['.ncc.', 'nc.cc', 'ccccd', '.cdd.'],
435 wrench: ['..m.m', '..mmm', '.m...', 'm....'],
436}
437const SMALL_TOOL_ICONS = {
438 pencil: ['..e', '.p.', 'g..'],
439 lens: ['.l.', 'l.l', '.lh'],
440 shell: ['s..', '.s.', 's..'],
441 ball: ['rrr', 'kwk', 'bbb'],
442 disc: ['ncc', 'c.c', 'ccd'],
443 wrench: ['m.m', 'mmm', '.m.'],
444}
445const TOOL_KINDS = {
446 pencil: ['Edit', 'Write', 'NotebookEdit', 'MultiEdit'],
447 lens: ['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'ToolSearch', 'LSP'],
448 shell: ['Bash', 'PowerShell', 'Monitor', 'BashOutput', 'KillShell'],
449 ball: ['Agent', 'Task', 'SendMessage', 'Workflow'],
450 disc: ['Skill'],
451}
452// Tools that ask you something, and notifications that wait on you
453const QUESTION_TOOLS = ['AskUserQuestion', 'ExitPlanMode']
454const ALERT_NOTIFICATIONS = ['permission_prompt', 'worker_permission_prompt', 'agent_needs_input', 'elicitation_dialog']
455// A minute after a turn with no word from you, the "!" shows for two
456const IDLE_WAIT_MS = 60 * 1000
457const IDLE_ALERT_MS = 2 * 60 * 1000
458// What core tells the model when you turn a call down at its dialog or interrupt it.
459// Nothing else marks a refusal there, so the wording is the one sign.
460const REFUSED = /The user doesn't want to (proceed with this tool use|take this action)|\[Request interrupted by user/
461
462const stamp = (x, y, rows, colors) => ({ x, y, rows, colors })
463
464// A stamp's color at a strip column and pixel row, or null
465function stampPixel(s, cx, py) {
466 const row = s.rows[py - s.y]
467 if (!row) return null
468 const ch = row[cx - s.x]
469 return ch === undefined ? null : (s.colors[ch] ?? null)
470}
471
472// The thinking dots: one to three grey pixels, growing in turn
473function dotsIcon() {
474 const dots = 1 + (Math.floor((tick * TICK_MS) / 400) % 3)
475 const row = ['d', '.', 'd', '.', 'd'].map((ch, i) => (i / 2 < dots ? ch : '.')).join('')
476 return { rows: ['.....', '.....', row, '.....'], small: [row], colors: { d: ink.dots } }
477}
478
479// A skill is in use: a Skill call runs in some agent, or a skill was typed as /name
480const skillInUse = () => runningSkills.size > 0 || tick < discUntil
481
482// Hold the disc when a command that ran is a skill, by its source in the command list
483async function markSkillCommand($, command) {
484 const found = (await $.command.list()).find((c) => c.name === command)
485 if (found && found.source !== 'builtin') discUntil = tick + DISC_TICKS
486}
487
488// The icon of the main loop's most recently started tool that is still running. A
489// skill in use anywhere, in the main loop or a subagent, shows as a TM disc instead.
490function toolIcon() {
491 const name = [...runningTools.values()].pop()
492 const kind = skillInUse() ? 'disc' : (Object.keys(TOOL_KINDS).find((k) => TOOL_KINDS[k].includes(name)) ?? 'wrench')
493 const colors = {
494 p: ink.pencil, e: 0xe06888, t: 0xb88048, g: 0x767688,
495 l: ink.lens, h: 0xb07040,
496 s: ink.shell,
497 r: 0xee4444, k: 0x60606c, w: 0xc0c0cc, b: ink.ball,
498 c: ink.disc, d: ink.discShade, n: 0xf6f6fc,
499 m: ink.wrench,
500 }
501 return { rows: TOOL_ICONS[kind], small: SMALL_TOOL_ICONS[kind], colors }
502}
503
504// What the bubble beside the head shows, if anything
505function bubbleIcon() {
506 if (attack) return null
507 if (isAlerted()) return ALERT_ICON
508 if (tick < yumUntil) return { rows: STAR, small: SMALL_STAR, colors: { s: ink.star } }
509 if (skillInUse()) return toolIcon()
510 if (working && runningTools.size > 0) return toolIcon()
511 if (working && thinking) return dotsIcon()
512 const need = needsOn && needNow()
513 return need ? BUBBLE_ICONS[need] : null
514}
515
516// The mon calls you over while a dialog waits on a call to one of alertTools,
517// or until a notification's alert runs out at alertUntil
518const isAlerted = () => alertTools.size > 0 || tick < alertUntil
519
520// Wake up, stand still facing you, and show the "!" until a call to the tool
521// resolves or the ticks run out, or until you answer first
522function raiseAlert({ tool, ticks }) {
523 markActive()
524 if (tool !== undefined) alertTools.add(tool)
525 if (ticks !== undefined) alertUntil = Math.max(alertUntil, tick + ticks)
526}
527
528// Typing, sending, any /pokemon command, or the end of Claude's turn answers the "!",
529// and the idle "!" waiting to show
530function clearAlert() {
531 alertTools.clear()
532 alertUntil = 0
533 idleAlertAt = null
534}
535
536// Raise the idle "!" once its minute is up, unless a turn is running by then
537function stepIdleAlert() {
538 if (idleAlertAt === null || tick < idleAlertAt) return
539 idleAlertAt = null
540 if (!working) raiseAlert({ ticks: IDLE_ALERT_MS / TICK_MS })
541}
542
543// A framed thought bubble beside the head, with a single pixel of tail between it and
544// the head. It leads on the side a walking mon heads for, and sits on the left of a
545// standing one, unless that side has no room for it. room is the columns it may take,
546// the margin and the strip, or a shrunk band's own columns, and look the bubble drawn.
547function bubbleOnLeft(head, side, room = [-margin, stripColumns()], look = FULL_BUBBLE) {
548 const span = bubbleSpan(look)
549 const fitsLeft = head[0] - span >= room[0]
550 const fitsRight = head[1] + span + 1 <= room[1]
551 return side === 'left' ? fitsLeft : fitsLeft && !fitsRight
552}
553
554function bubbleStamps(icon, head, side, room, look = FULL_BUBBLE) {
555 const { frame, inset, small } = look
556 const onLeft = bubbleOnLeft(head, side, room, look)
557 const left = onLeft ? head[0] - bubbleSpan(look) : head[1] + 4
558 const tail = onLeft ? head[0] - 2 : head[1] + 2
559 // The first stamp with a pixel wins, so the icon goes before the bubble
560 return [
561 stamp(left + inset[0], inset[1], small ? icon.small : icon.rows, icon.colors),
562 stamp(left, 0, frame, { O: ink.bubble }),
563 stamp(tail, BUBBLE_TAIL_ROW, ['O'], { O: ink.bubble }),
564 ]
565}
566
567// Zs rising from just left of the middle of a sleeping body and drifting out and up until
568// they float off the top of the band, small and big in turn, a new one every ZZZ_EVERY_MS.
569// body is the sprite's drawn pixels in strip columns and pixel rows, so every mon's Zs
570// start at the same spot on it whatever its shape. With no room on the left, they start
571// from the right edge instead. look is the Zs drawn, and a shrunk band passes its own.
572function sleepStamps(body, { glyphs, drift: reach } = { glyphs: [SMALL_Z, BIG_Z], drift: ZZZ_DRIFT }) {
573 const elapsed = tick * TICK_MS
574 const middleY = Math.floor((body.top + body.bottom) / 2)
575 const onLeft = body.left - ZZZ_GAP - glyphs[1][0].length - reach >= 0
576 const newest = Math.floor(elapsed / ZZZ_EVERY_MS)
577 const zs = []
578 for (let i = Math.max(0, newest - 2); i <= newest; i++) {
579 const p = (elapsed - i * ZZZ_EVERY_MS) / ZZZ_LIFE_MS
580 if (p >= 1) continue
581 const glyph = glyphs[i % 2]
582 const drift = Math.round(p * reach)
583 const x = onLeft ? body.left - ZZZ_GAP - glyph[0].length - drift : body.right + 1 + ZZZ_GAP + drift
584 const startY = middleY - Math.floor(glyph.length / 2)
585 zs.push(stamp(x, startY - Math.round(p * (startY + glyph.length)), glyph, { z: ink.z }))
586 }
587 return zs
588}
589
590// A tool call that failed makes the mon wince: it shakes in place, then holds still
591// with a sweat drop. Another failure starts it over, and a move or evolution ignores it.
592function flinch() {
593 if (attack) return
594 markActive()
595 flinchStart = tick
596}
597
598// The flinch shows unless a move plays or the mon is calling you over
599const isWincing = () => isFlinching() && !attack && !isAlerted()
600const isShaking = () => isWincing() && tick - flinchStart < SHAKE_TICKS
601
602// A pixel to either side in turn while the flinch shakes, kept inside the strip
603function flinchJolt(baseX, width) {
604 if (!isShaking()) return 0
605 const side = Math.floor((tick - flinchStart) / 2) % 2 === 0 ? -1 : 1
606 return Math.max(-baseX, Math.min(columns - width - baseX, side))
607}
608
609// The sweat drop sliding down the corner of the head away from the bubble, which leads
610// on side when there's room
611function sweatStamps(head, y, side) {
612 const width = SWEAT[0].length
613 const sx = bubbleOnLeft(head, side) ? Math.min(columns - width, head[1] - 1) : Math.max(0, head[0] - 2)
614 const drip = Math.floor((tick - flinchStart) / DRIP_TICKS)
615 return [stamp(sx, y + drip, SWEAT, { d: ink.sweat, h: ink.sweatShine })]
616}
617
618// Stop to enjoy it, hop, and send a stream of hearts up around the sprite
619// Asleep, it stays asleep: the hearts still float up, but it doesn't hop
620function pet() {
621 if (!isAsleep()) {
622 markActive()
623 hopStart = tick
624 }
625 petUntil = tick + PET_TICKS
626 const width = SPRITES[mon].width
627 const lowest = bandRows() * 2 - 6
628 for (let k = 0; k < HEART_COUNT; k++) {
629 hearts.push({
630 x: x - 2 + Math.floor(Math.random() * (width + 2)),
631 y: lowest - Math.floor(Math.random() * 6),
632 born: tick + k * HEART_GAP_TICKS,
633 rows: HEARTS[k % HEARTS.length],
634 colors: { p: HEART_COLORS[k % HEART_COLORS.length], h: HEART_SHINE },
635 })
636 }
637}
638
639// Drop a random berry a few columns away, on whichever side has room, for the sprite to
640// walk to. It lands where the sprite can stand over it, not so near an edge that the
641// body would leave the strip.
642function feed() {
643 markActive()
644 const width = SPRITES[mon].width
645 const gap = 3 + Math.floor(Math.random() * 6)
646 const under = berryUnderBody()
647 const lowest = Math.max(0, under)
648 const highest = Math.min(columns - BERRY_SIZE, homeX() + under)
649 const right = x + width + gap
650 const fx = Math.max(lowest, Math.min(highest, right <= highest ? right : x - gap - BERRY_SIZE))
651 const berry = BERRIES[Math.floor(Math.random() * BERRIES.length)]
652 food = { x: fx, born: tick, eatStart: null, berry, fills: null }
653 wanderTarget = null
654 return berry
655}
656
657// The berry's top pixel row: falling a pixel every few ticks until it rests on the ground
658function foodTop() {
659 const ground = bandRows() * 2 - BERRY_SIZE
660 return Math.min(ground, -BERRY_SIZE + Math.floor((tick - food.born) / FALL_TICKS))
661}
662
663// Whether column fx of a berry size pixels wide is eaten yet. Bites take a column at a
664// time from either edge in turn, until the middle is gone too.
665function bitten(fx, size) {
666 if (food.eatStart === null) return false
667 const bites = Math.floor(((tick - food.eatStart) / EAT_TICKS) * size)
668 return fx < Math.ceil(bites / 2) || size - 1 - fx < Math.floor(bites / 2)
669}
670
671// The berry's color at a strip column and pixel row, or null
672function foodPixel(cx, py) {
673 if (!food) return null
674 const fx = cx - food.x
675 const fy = py - foodTop()
676 if (fx < 0 || fx >= BERRY_SIZE || fy < 0 || fy >= BERRY_SIZE || bitten(fx, BERRY_SIZE)) return null
677 return food.berry.colors[BERRY[fy][fx]] ?? null
678}
679
680// The berry as a shrunk band draws it, in its own pixels: centred where the full-size one
681// is, a scene column offset from the strip's, and falling and bitten as it is
682function smallBerryStamp(scale, offset) {
683 const size = SMALL_BERRY.length
684 const left = Math.round((offset + food.x + BERRY_SIZE / 2) * scale - size / 2)
685 const top = Math.round((foodTop() + BERRY_SIZE) * scale) - size
686 const rows = SMALL_BERRY.map((row) => [...row].map((ch, fx) => (bitten(fx, size) ? '.' : ch)).join(''))
687 return stamp(left, top, rows, food.berry.colors)
688}
689
690// Walk to the berry once it lands, eat it, then hop with a "yum!"
691function stepFood() {
692 markActive()
693 const landed = foodTop() === bandRows() * 2 - BERRY_SIZE
694 if (food.eatStart !== null) {
695 if (tick - food.eatStart < EAT_TICKS) return
696 if (food.fills) owed.push({ name: food.fills, food: FEED_FOOD, happiness: FEED_HAPPINESS })
697 food = null
698 yumUntil = tick + YUM_TICKS
699 hopStart = tick
700 return
701 }
702 if (!landed || tick % MOVE_TICKS !== 0) return
703 const target = foodTarget()
704 if (target === x) {
705 food.eatStart = tick
706 return
707 }
708 facing = target < x ? 'left' : 'right'
709 x += facing === 'left' ? -1 : 1
710 clampX()
711}
712
713// Each heart where it is now: rising a pixel every few ticks with a slight sway
714function heartStamps() {
715 const out = []
716 for (const heart of hearts) {
717 const age = tick - heart.born
718 if (age < 0) continue
719 const sway = Math.floor(age / (HEART_RISE_TICKS * 3)) % 2
720 out.push(stamp(heart.x + sway, heart.y - Math.floor(age / HEART_RISE_TICKS), heart.rows, heart.colors))
721 }
722 return out
723}
724
725// The strip edge behind a mon aiming at side, where it backs up to before the move
726const edgeBehind = (side) => (side === 'left' ? homeX() : 0)
727
728const isBackingUp = () => attack !== null && attack.backing === true
729
730// Start one of the mon's moves, aimed at the roomier side. A move aimed ahead first
731// backs the mon up to the edge behind it, so it plays with the whole strip ahead, the
732// way it was tuned. A move on itself plays where the mon stands. The move takes over
733// the body, so a hop in progress stops. A move at a foe plays from home, facing left at it.
734function startAttack(move, { foe = false } = {}) {
735 markActive()
736 clampX()
737 wanderTarget = null
738 hopStart = -HOP_TICKS
739 const side = foe || x >= homeX() - x ? 'left' : 'right'
740 const seed = Math.floor(Math.random() * 0x7fffffff)
741 const others = MONS.filter((name) => name !== mon)
742 const aimed = attackPose(prepareAttack(move, { side, seed, x, home: homeX() }), 0).view === 'side'
743 attack = { move, side, seed, start: tick, backing: !foe, swap: others[Math.floor(Math.random() * others.length)], atFoe: foe, landed: false }
744 if (foe) wild = { ...wild, nextAttackAt: tick + Math.round((ATTACK_GAP_MS + Math.random() * ATTACK_JITTER_MS) / TICK_MS) }
745 if (foe || !aimed || x === edgeBehind(side)) playMove()
746}
747
748// Settle the move from where the mon stands now, and start it
749function playMove() {
750 const { move, side, seed } = attack
751 Object.assign(attack, prepareAttack(move, { side, seed, x, home: homeX() }), { start: tick, backing: false })
752}
753
754// Back up a pixel a tick and start the move at the edge, hold still while it plays,
755// then land wherever it left the mon
756function stepAttack() {
757 markActive()
758 if (isBackingUp()) {
759 x += Math.sign(edgeBehind(attack.side) - x)
760 if (x === edgeBehind(attack.side)) playMove()
761 return
762 }
763 if (tick - attack.start < attack.ticks) return
764 if (attack.toX !== null) x = attack.toX
765 facing = attack.side
766 attack = null
767 clampX()
768}
769
770// A due evolution waits until the mon is idle, awake, not calling you over, done with
771// any move or berry, and rid of any wild mon
772const isFree = () => !working && !attack && !food && !wild && !isAsleep() && !isAlerted()
773
774// Petting, feeding, sleeping, attacking, releasing, and switching mons wait while it evolves
775const WAITS_FOR_EVOLUTION = ['pet', 'feed', 'sleep', 'attack', 'release', 'catch', 'new']
776// While it eats a berry or enjoys a pet, only the commands that just show something run,
777// so nothing changes the record the berry or pet is about to fill
778const SHOWS_ONLY = ['', 'list', 'stats', 'box', 'moves', 'dex']
779const waitsForCare = (asked) => (food !== null || isPetted()) && !SHOWS_ONLY.includes(asked.split(' ')[0]) && asked !== 'attack list'
780const waitsForEvolution = (asked) => evolving !== null && (MONS.includes(asked) || WAITS_FOR_EVOLUTION.includes(asked.split(' ')[0]))
781
782// Play the evolve effect with the evolved mon swapped in, in a band grown to fit both
783function startEvolution($, into) {
784 $.ui.toast('What? ' + nameOf(mon) + ' is evolving!')
785 startAttack({ effect: 'evolve', color: ink.silhouette })
786 attack.swap = into
787 evolving = { from: mon, into }
788 evolveDue = null
789 $.ui.invalidate('ui.render')
790}
791
792// Start a due evolution once the mon is free, and finish one whose effect is over
793function stepEvolution($) {
794 if (evolving && !attack) finishEvolution($)
795 else if (evolveDue && isFree()) startEvolution($, evolveDue)
796}
797
798// The evolved mon takes over the record as last saved, so XP earned elsewhere comes along.
799// 'evolved' tells another session showing the old mon that it evolved rather than being released.
800async function moveRecord($, from, into) {
801 await freshen($, [from])
802 stats[into] = { ...stats[from], xp: xpOf(from) }
803 delete stats[from]
804 await saveStats($, [from, into])
805 const evolved = (await $.store.get('evolved')) ?? {}
806 await $.store.set('evolved', { ...evolved, [from]: into })
807}
808
809// The mon becomes the evolved one. Its record moves along, so level, XP, and meters carry over.
810function finishEvolution($) {
811 const { from, into } = evolving
812 const wasHome = isHome()
813 evolving = null
814 mon = into
815 if (wasHome) x = homeX()
816 clampX()
817 const name = nameOf(from)
818 saveMon($).catch((err) => logOnce($, err))
819 moveRecord($, from, into).catch((err) => logOnce($, err))
820 $.ui.toast('Congratulations! Your ' + name + ' evolved into ' + displayName(into) + '!')
821 $.ui.invalidate('ui.render')
822}
823
824// Call off an evolution under way or due. The mon stays as it is.
825function stopEvolution($) {
826 const name = nameOf(mon)
827 if (!evolving && !evolveDue) return { text: name + ' isn\'t evolving.' }
828 if (evolving) attack = null
829 evolving = null
830 evolveDue = null
831 $.ui.invalidate('ui.render')
832 return { text: 'Huh? ' + name + ' stopped evolving!' }
833}
834
835// Joins items as "A", "A or B", or "A, B, or C"
836const orList = (items) => (items.length < 3 ? items.join(' or ') : items.slice(0, -1).join(', ') + ', or ' + items.at(-1))
837
838// How one entry evolves the mon, like "Jolteon with a Thunder Stone"
839function evolutionText(entry) {
840 const into = displayName(entry.into)
841 if (entry.item) return into + ' with a ' + entry.item
842 return entry.trade ? into + ' by trade' : into + ' at Lv. ' + entry.level
843}
844
845// A stone, a trade, or a level the mon has reached starts its evolution now. Several
846// ways and none named get a list.
847function evolveCommand($, wanted) {
848 const name = nameOf(mon)
849 const entries = evolutionsOf(mon)
850 if (entries.length === 0) return { text: name + ' doesn\'t evolve.' }
851 if (attack || food) return { text: name + ' is busy right now.' }
852 const picked = wanted ? entries.filter((entry) => moveKey(entry.into) === moveKey(wanted)) : entries
853 if (picked.length !== 1) {
854 const example = entries[Math.floor(Math.random() * entries.length)].into
855 return { text: name + ' can become ' + orList(entries.map(evolutionText)) + '. Try /pokemon evolve ' + example + '.' }
856 }
857 const [entry] = picked
858 const level = levelOf(mon)
859 if (entry.level && level < entry.level) {
860 return { text: name + ' evolves into ' + displayName(entry.into) + ' at Lv. ' + entry.level + '. It\'s Lv. ' + level + '.' }
861 }
862 startEvolution($, entry.into)
863 if (entry.item) return { text: 'You use a ' + entry.item + ' on ' + name + '.' }
864 if (entry.trade) return { text: 'You trade ' + name + ' to a friend, and they trade it right back.' }
865 return { text: 'You let ' + name + ' evolve.' }
866}
867
868// The first and last opaque pixel columns and rows of a frame, cached per frame
869const boxes = new WeakMap()
870function boxOf(rows, colors) {
871 let box = boxes.get(rows)
872 if (box) return box
873 box = { left: Infinity, right: -1, top: Infinity, bottom: -1 }
874 rows.forEach((row, py) => {
875 for (let px = 0; px < row.length; px++) {
876 if (colors[row[px]] === undefined) continue
877 box.left = Math.min(box.left, px)
878 box.right = Math.max(box.right, px)
879 box.top = Math.min(box.top, py)
880 box.bottom = Math.max(box.bottom, py)
881 }
882 })
883 if (box.right < 0) box = { left: 0, right: rows[0].length - 1, top: 0, bottom: rows.length - 1 }
884 boxes.set(rows, box)
885 return box
886}
887
888// The strip columns the top of the sprite covers, so a bubble can sit right beside it
889function headColumns(pixel, width, x) {
890 let left = width
891 let right = -1
892 for (let py = 0; py < HEAD_PIXELS; py++) {
893 for (let px = 0; px < width; px++) {
894 if (pixel(px, py) === null) continue
895 left = Math.min(left, px)
896 right = Math.max(right, px)
897 }
898 }
899 return right < 0 ? [x, x + width - 1] : [x + left, x + right]
900}
901
902// What the idle mon is asking for, if anything. With both low it takes turns.
903function needNow() {
904 if (working || food || attack || isPetted() || isAsleep() || tick < yumUntil) return null
905 const needs = Object.keys(STATS).filter((key) => statNow(mon, key) < NEEDY_BELOW)
906 if (needs.length === 0) return null
907 return needs[Math.floor((tick * TICK_MS) / 3000) % needs.length]
908}
909
910// Where a frame's pupils land in strip pixels, as [x, y, color, eye], for a sprite drawn
911// unscaled at left and top, mirrored or not, with eye the box around it as [x, y, width,
912// height, skin color, the box's other colors], or null for a line. Each frame's pupils
913// are cached.
914const framePupils = new WeakMap()
915function pupilsAt(name, shade, view, rows, { left, top, mirrored }) {
916 const sheet = SPRITES[name].variants[shade]
917 if (!framePupils.has(rows)) {
918 const index = Math.max(0, sheet[view].findIndex((f) => f.rows === rows))
919 framePupils.set(rows, sheet[view][index].pupils ?? pupilsOf(rows, sheet.palette, keptEyesOf(name, shade, view, index)))
920 }
921 const { width } = SPRITES[name]
922 const colors = COLORS[name][shade]
923 return framePupils.get(rows).map(([px, py, box, skin, inked]) => {
924 const eye = box && [left + (mirrored ? width - box[0] - box[2] : box[0]), top + box[1], box[2], box[3], colors[skin], [...inked].map((l) => colors[l])]
925 return [left + (mirrored ? width - 1 - px : px), top + py, colors[rows[py][px]], eye]
926 })
927}
928
929// A sleeping mon's frame with its eyes shut, cached per frame
930const sleepingFrames = new Map()
931function sleepingFrame(shown, view) {
932 const sheet = SPRITES[shown].variants[variant]
933 const rows = sheet[view][0].rows
934 if (!sleepingFrames.has(rows)) sleepingFrames.set(rows, closedEyes(rows, sheet.palette, eyesOf(shown, variant, view, 0)))
935 return sleepingFrames.get(rows)
936}
937
938const NO_POSE = { view: 'front', stride: false, swap: false, asleep: false }
939// Backing up for a move: side on and striding, facing the target
940const BACKING_POSE = { ...NO_POSE, view: 'side', stride: true }
941const NO_EFFECT = {
942 dots: [], under: [], chars: [], ghosts: [], hidden: false,
943 dx: 0, dy: 0, sx: 1, sy: 1, flipX: false, flipY: false, skew: null, shade: null, shake: [0, 0],
944}
945const STRIDE_TICKS = 2
946
947// The most common color of a frame, leaving out dark outlines and shading, cached per frame
948const bodies = new WeakMap()
949function bodyOf(rows, colors) {
950 if (bodies.has(rows)) return bodies.get(rows)
951 const counts = new Map()
952 for (const row of rows) {
953 for (const ch of row) if (colors[ch] !== undefined) counts.set(ch, (counts.get(ch) ?? 0) + 1)
954 }
955 let body = 0xffffff
956 let most = 0
957 for (const [ch, n] of counts) {
958 const c = colors[ch]
959 const luma = 0.3 * (c >> 16) + 0.59 * ((c >> 8) & 255) + 0.11 * (c & 255)
960 if (luma >= 72 && n > most) [body, most] = [c, n]
961 }
962 bodies.set(rows, body)
963 return body
964}
965
966// Where the sprite stands in strip pixels, for aiming an attack
967function attackGeometry(sprite, frame, colors, flip, at, top, rows, pixel) {
968 const box = boxOf(frame, colors)
969 return {
970 x: at,
971 left: at + (flip ? sprite.width - 1 - box.right : box.left),
972 right: at + (flip ? sprite.width - 1 - box.left : box.right),
973 top: top + box.top,
974 bottom: top + box.bottom,
975 columns,
976 pixels: rows * 2,
977 side: attack.side === 'left' ? -1 : 1,
978 body: bodyOf(frame, colors),
979 solid: (cx, py) => pixel(cx - at, py - top) !== null,
980 foe: attack.atFoe && isHere(wild) ? foeBox(rows) : null,
981 }
982}
983
984// The attack's pixels as one color per strip pixel, -1 where it draws nothing
985function overlayOf(dots, rows) {
986 if (dots.length === 0) return null
987 const pixels = rows * 2
988 const width = stripColumns()
989 const overlay = new Int32Array(width * pixels).fill(-1)
990 for (let k = 0; k < dots.length; k += 3) {
991 const cx = dots[k]
992 const py = dots[k + 1]
993 if (cx >= 0 && cx < width && py >= 0 && py < pixels) overlay[py * width + cx] = dots[k + 2]
994 }
995 return overlay
996}
997
998// The sprite as the effect left it: moved to left, top, each row slid by skew, then
999// scaled around the middle of its feet, turned upside down, and recolored. A color
1000// per strip pixel, or null.
1001function bodyPixel(effect, pixel, box, width, mirrored, left, top) {
1002 const scaled = effect.sx !== 1 || effect.sy !== 1
1003 const sx = Math.max(0.01, effect.sx)
1004 const sy = Math.max(0.01, effect.sy)
1005 const ax = mirrored ? width - (box.left + box.right + 1) / 2 : (box.left + box.right + 1) / 2
1006 const ay = box.bottom + 1
1007 return (cx, py) => {
1008 let px = cx - left - (effect.skew ? Math.round(effect.skew(py)) : 0)
1009 let qy = py - top
1010 if (scaled) {
1011 px = Math.floor(ax + (px + 0.5 - ax) / sx)
1012 qy = Math.floor(ay + (qy + 0.5 - ay) / sy)
1013 }
1014 if (effect.flipY) qy = box.top + box.bottom - qy
1015 const c = pixel(px, qy)
1016 return c === null || !effect.shade ? c : effect.shade(cx, py, c)
1017 }
1018}
1019
1020// The effect's afterimages, a color per strip pixel or null. A flipped ghost mirrors
1021// within the sprite's opaque box, so at dx 0 it covers the body.
1022function ghostPixel(ghosts, pixel, box, width, flip, baseX, baseTop) {
1023 const span = flip ? 2 * width - 2 - box.left - box.right : box.left + box.right
1024 return (cx, py) => {
1025 for (const g of ghosts) {
1026 const px = cx - baseX - g.dx
1027 const c = pixel(g.flip ? span - px : px, py - baseTop - g.dy)
1028 const k = c === null || !g.shade ? c : g.shade(c, cx, py)
1029 if (k !== null) return k
1030 }
1031 return null
1032 }
1033}
1034
1035const idleBox = (name, v) => boxOf(SPRITES[name].variants[v].idle[0].rows, COLORS[name][v])
1036
1037// The strip columns a battle with this foe takes, beside the home mon at the strip's right
1038function battleNeeds(foe, foeVariant) {
1039 const homeSpan = SPRITES[mon].width + Math.max(0, HOME_GAP - IDLE_RIGHT_GAP[mon][variant]) - idleBox(mon, variant).left
1040 return battleColumns(homeSpan, idleBox(foe, foeVariant))
1041}
1042
1043// The scene columns left of the panel in a band bodyColumns wide. The small band shows
1044// the scene at its scale, so each band column holds more than one of the scene's.
1045function sceneRoom(bodyColumns) {
1046 const small = smallZoom()
1047 return (small ? Math.floor(bodyColumns / small.scale) : bodyColumns) - panel
1048}
1049
1050const battleFits = (foe, foeVariant) => battleNeeds(foeAs(foe), foeVariant) <= sceneRoom(lastBodyColumns)
1051
1052// The strip's width in a band bodyColumns wide, grown on the left while a foe is on stage
1053function stripTarget(bodyColumns) {
1054 const room = sceneRoom(bodyColumns)
1055 const strip = Math.max(SPRITES[mon].width, Math.min(STRIP_COLUMNS, room))
1056 return isOnStage(wild) ? Math.max(strip, Math.min(battleNeeds(foeName(), wild.variant), room)) : strip
1057}
1058
1059// The foe's spot facing the home mon, and the column just out of sight past the scene's left edge
1060const wildAt = () => ({
1061 spot: foeSpotX(homeX() + idleBox(mon, variant).left, idleBox(foeName(), wild.variant)),
1062 offLeft: -margin - SPRITES[foeName()].width,
1063})
1064
1065// The foe's opaque box in strip pixels, as it stands in battle
1066function foeBox(rows) {
1067 const foe = foeName()
1068 const box = idleBox(foe, wild.variant)
1069 const left = foeX(wild, wildAt())
1070 const top = rows * 2 - SPRITES[foe].height
1071 return { left: left + box.left, right: left + box.right, top: top + box.top, bottom: top + box.bottom }
1072}
1073
1074// The HP bar, a pixel row centred over the standing foe with a row of space between
1075function hpStamps(rows) {
1076 if (wild.phase !== 'battle') return []
1077 const box = foeBox(rows)
1078 const bar = hpBar(wild, ink.hpEmpty)
1079 const left = Math.round((box.left + box.right + 1 - bar.width) / 2)
1080 return [stamp(left, Math.max(0, box.top - 2), bar.rows, bar.colors)]
1081}
1082
1083// The foe as a color per strip pixel or null: walk frames on the way in, idle frames in
1084// battle, and walk frames mirrored on the way out. A hit jolts it and makes it blink.
1085// A ball that opens on it shades it red and shrinks it into the ball, and it stays
1086// inside until it breaks out.
1087function foePixel(rows) {
1088 if (foeBlinks(wild, tick)) return () => null
1089 const held = wild.phase === 'throw' ? throwStage(wild.throw, tick) : null
1090 if (held && !['arc', 'open'].includes(held.stage) && !(held.stage === 'breakout' && held.t >= THROW_TICKS.open)) return () => null
1091 const foe = foeName()
1092 const sprite = SPRITES[foe]
1093 const sheet = sprite.variants[wild.variant]
1094 const walking = wild.phase === 'entering' || wild.phase === 'fleeing'
1095 const frame = frameAt(walking ? sheet.walk : sheet.idle, tick * TICK_MS)
1096 const colors = COLORS[foe][wild.variant]
1097 const flip = wild.phase === 'fleeing'
1098 const top = rows * 2 - sprite.height + (walking ? WALK_DROP[foe][wild.variant] : 0)
1099 const left = foeX(wild, wildAt()) + foeJolt(wild, tick)
1100 const pixel = (cx, py) => {
1101 const px = cx - left
1102 const row = frame[py - top]
1103 if (!row || px < 0 || px >= sprite.width) return null
1104 return colors[row[flip ? sprite.width - 1 - px : px]] ?? null
1105 }
1106 if (held?.stage !== 'open') {
1107 pixel.pupils = pupilsAt(foe, wild.variant, walking ? 'walk' : 'idle', frame, { left, top, mirrored: flip })
1108 return pixel
1109 }
1110 const scale = 1 - (held.t + 1) / (THROW_TICKS.open + 1)
1111 const { x: bx, y: by } = foeMiddle(rows)
1112 return (cx, py) => {
1113 const c = pixel(Math.floor(bx + (cx + 0.5 - bx) / scale), Math.floor(by + (py + 0.5 - by) / scale))
1114 return c === null ? null : reddened(c)
1115 }
1116}
1117
1118// A color pulled most of the way to red, as a ball's beam takes the foe in
1119const reddened = (c) => (((c >> 16) * 0.3 + 0xff * 0.7) << 16) | (((c >> 8) & 255) * 0.3 + 0x30 * 0.7) << 8 | ((c & 255) * 0.3 + 0x30 * 0.7)
1120
1121function foeMiddle(rows) {
1122 const box = foeBox(rows)
1123 return { x: Math.round((box.left + box.right) / 2), y: Math.round((box.top + box.bottom) / 2) }
1124}
1125
1126const BALL_HALF = Math.floor(BALL[0].length / 2)
1127const ARC_PIXELS = 8
1128const SPARKLE = { s: 0xfff2a0, w: 0xffffff }
1129const SPARKLES = [[-2, -2], [8, -1], [-3, 3], [9, 4], [3, -3]]
1130
1131// The ball's stamps this tick of a throw: flying in an arc from
1132// the home mon's head to the foe, opening on it, dropping to the ground with a bounce,
1133// rocking a shake at a time, then still with sparkles or bursting open in a flash.
1134// The ball in the air goes over the scene, and on the ground under it.
1135function throwStamps(rows, from) {
1136 if (wild?.phase !== 'throw') return { over: [], under: [] }
1137 const { stage, t } = throwStage(wild.throw, tick)
1138 const mid = foeMiddle(rows)
1139 const rest = rows * 2 - BALL.length
1140 const top = mid.y - Math.floor(BALL.length / 2)
1141 const at = (y, art, colors = BALL_COLORS) => stamp(mid.x - BALL_HALF, y, art, colors)
1142 if (stage === 'arc') {
1143 const u = (t + 1) / THROW_TICKS.arc
1144 const x = Math.round(from.x + (mid.x - from.x) * u)
1145 const y = Math.round(from.y + (top - from.y) * u - ARC_PIXELS * 4 * u * (1 - u))
1146 return { over: [stamp(x - BALL_HALF, Math.max(0, y), BALL, BALL_COLORS)], under: [] }
1147 }
1148 if (stage === 'open') {
1149 const art = OPEN[Math.min(t, OPEN.length - 1)]
1150 return { over: [at(top - (art.length - BALL.length), art, LIGHT_COLORS)], under: [] }
1151 }
1152 if (stage === 'drop') {
1153 const since = t - wild.throw.drop
1154 const y = since < 0 ? top + t : since >= 1 && since < 3 ? rest - 1 : rest
1155 return { over: [], under: [at(Math.min(rest, y), BALL)] }
1156 }
1157 if (stage === 'wobble') {
1158 const k = Math.floor((t % THROW_TICKS.shake) / 3)
1159 return { over: [], under: [at(rest, LEANS[[0, 1, 2, 1][k]])] }
1160 }
1161 if (stage === 'caught') {
1162 const twinkle = t < 12 ? SPARKLES.filter((_, i) => (i + Math.floor(t / 2)) % 2 === 0) : []
1163 const sparks = twinkle.map(([dx, dy]) => stamp(mid.x - BALL_HALF + dx, rest + dy, ['.s.', 'sws', '.s.'], SPARKLE))
1164 return { over: sparks, under: [at(rest, BALL)] }
1165 }
1166 if (stage === 'breakout') {
1167 if (t < THROW_TICKS.open) {
1168 const art = OPEN[Math.min(t, OPEN.length - 1)]
1169 return { over: [at(rest - (art.length - BALL.length), art, LIGHT_COLORS)], under: [] }
1170 }
1171 const flash = FLASH[Math.min(FLASH.length - 1, Math.floor((t - THROW_TICKS.open) / 2))]
1172 return { over: [at(rest - (flash.length - BALL.length), flash, LIGHT_COLORS)], under: [] }
1173 }
1174 return { over: [], under: [] }
1175}
1176
1177const wildName = () => (wild.variant === 'shiny' ? 'shiny wild ' : 'wild ') + displayName(wild.mon)
1178
1179// Send the foe off, with a toast unless quiet. One still calling just doesn't come.
1180function fleeWild($, quiet = false) {
1181 const shown = isOnStage(wild) && wild.phase !== 'fleeing'
1182 if (shown && !quiet) $.ui.toast('Wild ' + displayName(wild.mon) + ' fled!')
1183 wild = fleeingWild(wild, wildAt())
1184}
1185
1186// Mark a mon in the Pokédex as last saved by any session, so two sessions marking at once
1187// lose a mark at worst
1188async function markDex($, key, patch) {
1189 const saved = await $.store.get('dex')
1190 const dex = saved && typeof saved === 'object' ? { ...saved } : {}
1191 dex[key] = mergedDexEntry(dex[key], patch)
1192 await $.store.set('dex', dex)
1193}
1194
1195// Move the visit on a tick. Asleep, the home mon can't fight, so the foe leaves.
1196function stepWildPhase($) {
1197 if (!wild) return
1198 if (wild.phase !== 'fleeing' && (isTuckedIn() || isAsleep())) return fleeWild($)
1199 const result = stepWild(wild, { tick, atHome: isHome() && !attack, at: wildAt(), stayTicks: WILD_STAY_MS / TICK_MS })
1200 if (result.event === 'timeout') fleeWild($)hooks/attacks.js 123 lines1// Attack animations for /pokemon attack. Each effect is a pure function of the
2// attack, its age in ticks, and where the mon stands. It paints pixels over and
3// under the sprite, and can move, squash, turn, recolor, or hide the sprite itself.
4// The effects live in effects/, one file per family, on the helpers in effects/draw.js.
5//
6// Coordinates are strip pixels: x is a column, y a half row from the top.
7// `side` is 1 when the attack goes right and -1 when it goes left.
8
9import { BASIC } from './effects/basic.js'
10import { BEAMS } from './effects/beams.js'
11import { CHARGES } from './effects/charge.js'
12import { EARTH } from './effects/earth.js'
13import { ELECTRIC } from './effects/electric.js'
14import { EVOLVE } from './effects/evolve.js'
15import { FIRE } from './effects/fire.js'
16import { GUARD } from './effects/guard.js'
17import { MIND } from './effects/mind.js'
18import { NATURE } from './effects/nature.js'
19import { POISON } from './effects/poison.js'
20import { SELF } from './effects/self.js'
21import { SKY } from './effects/sky.js'
22import { SOUND } from './effects/sound.js'
23import { SPECIAL } from './effects/special.js'
24import { STRIKES } from './effects/strikes.js'
25import { WATER } from './effects/water.js'
26import { WEAPONS } from './effects/weapons.js'
27import { clamp } from './effects/draw.js'
28
29const EFFECTS = {
30 ...BASIC, ...BEAMS, ...CHARGES, ...EARTH, ...ELECTRIC, ...EVOLVE, ...FIRE, ...GUARD, ...MIND,
31 ...NATURE, ...POISON, ...SELF, ...SKY, ...SOUND, ...SPECIAL, ...STRIKES, ...WATER, ...WEAPONS,
32}
33
34const FALLBACK = 'impact'
35const MOUTH = 0.35
36const POSE = { view: 'side', stride: false, swap: false, asleep: false }
37
38export const effectOf = (id) => (EFFECTS[id] ? id : FALLBACK)
39
40// The parts of an attack its move decides: the effect and its options, how long it
41// plays, and where it leaves the mon (toX, or null to stay put)
42export function prepareAttack(move, { side, seed, x, home }) {
43 const effect = effectOf(move.effect)
44 const spec = EFFECTS[effect]
45 const attack = {
46 effect,
47 color: move.color ?? null,
48 from: move.from ?? null,
49 mouth: move.mouth ?? MOUTH,
50 power: move.power ?? spec.power ?? 1,
51 ticks: spec.ticks,
52 side,
53 seed,
54 }
55 attack.toX = spec.lands ? clamp(Math.round(spec.lands(attack, x, home)), 0, home) : null
56 return attack
57}
58
59// How the sprite stands this tick, needed before it is drawn: side on or facing out,
60// striding, swapped for another mon, or asleep
61export function attackPose(attack, t) {
62 const pose = EFFECTS[attack.effect].pose
63 return { ...POSE, ...(typeof pose === 'function' ? pose(t, attack) : pose) }
64}
65
66// Whether a move plays side on toward a target, staying where it stands. Only those
67// can hit a foe in front of the mon.
68export function aimsAhead(move) {
69 const effect = effectOf(move.effect)
70 if (EFFECTS[effect].lands || effect === 'transform') return false
71 return attackPose(prepareAttack(move, { side: 'left', seed: 0, x: 0, home: 0 }), 0).view === 'side'
72}
73
74// The attack's drawing this tick, described in effects/draw.js
75export function attackFrame(attack, t, g) {
76 const out = {
77 dots: [], under: [], chars: [], ghosts: [], hidden: false,
78 dx: 0, dy: 0, sx: 1, sy: 1, flipX: false, flipY: false, skew: null, shade: null, shake: [0, 0],
79 }
80 const spec = EFFECTS[attack.effect]
81 spec.draw(out, t, attack, aim(g, attack), attack.color ?? spec.color)
82 return out
83}
84
85// Where the attack starts and lands. frontAt(y) is just in front of the sprite on row y.
86// The mouth is the front a third of the way down, which is the face for nearly every
87// side-on mon. A move can set its own `mouth` height for a mon whose face sits lower.
88// With a foe's box in g.foe, the move aims at the middle of the foe.
89function aim(g, a) {
90 const cx = Math.round((g.left + g.right) / 2)
91 const cy = Math.round((g.top + g.bottom) / 2)
92 const frontAt = (y) => {
93 let x = g.side > 0 ? g.right : g.left
94 while (x !== cx && !g.solid(x, y)) x -= g.side
95 return x + g.side
96 }
97 const my = g.top + Math.round((g.bottom - g.top + 1) * a.mouth)
98 const mouth = { x: frontAt(my), y: my }
99 const ground = g.pixels - 1
100 const ahead = (d) => mouth.x + g.side * d
101 let reach = g.side > 0 ? g.columns - 1 - mouth.x : mouth.x
102 let target = { x: ahead(clamp(reach - 4, 3, 13)), y: clamp(ground - 7, mouth.y, ground - 3) }
103 if (g.foe) {
104 target = { x: Math.round((g.foe.left + g.foe.right) / 2), y: Math.round((g.foe.top + g.foe.bottom) / 2) }
105 reach = Math.abs(target.x - mouth.x)
106 }
107 const geo = { ...g, cx, cy, frontAt, mouth, ground, reach, ahead, target }
108 if (a.from === 'top') geo.crown = crownOf(geo)
109 return geo
110}
111
112// The middle of the sprite's topmost row of pixels, like the tip of a bulb
113function crownOf(g) {
114 let left = g.right
115 let right = g.left
116 for (let x = g.left; x <= g.right; x++) {
117 if (!g.solid(x, g.top)) continue
118 left = Math.min(left, x)
119 right = Math.max(right, x)
120 }
121 return { x: Math.round((left + right) / 2), y: g.top }
122}
123hooks/custom.js 306 lines1// Mons fetched with /pokemon new, from any generation jakobhoeg/vscode-pokemon has.
2// The GIFs come through GitHub's contents API as base64, since $.http.fetch hands back
3// text, and are decoded here the way scripts/build-frames.mjs decodes the bundled ones.
4// Each mon is saved in the store under 'custom:<mon>', so it outlives plugin updates.
5// Every fetch goes through the get passed in, (url, init) => $.http.fetch(url, init).
6
7const REPO = 'https://api.github.com/repos/jakobhoeg/vscode-pokemon/contents/media'
8const GENS = [1, 2, 3, 4, 5]
9const VARIANTS = ['default', 'shiny']
10const ANIMS = ['idle', 'walk']
11const LETTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
12const HEADERS = { Accept: 'application/vnd.github+json', 'User-Agent': 'claude-pokemon' }
13
14export const CUSTOM_PREFIX = 'custom:'
15
16// Moves with animations, picked by the mon's types since later moves have none
17const TYPE_MOVES = {
18 normal: ['Tackle', 'Quick Attack', 'Headbutt', 'Growl'],
19 fire: ['Ember', 'Flamethrower', 'Fire Spin'],
20 water: ['Water Gun', 'Bubble Beam', 'Hydro Pump'],
21 grass: ['Vine Whip', 'Razor Leaf', 'Absorb'],
22 electric: ['Thunder Shock', 'Thunderbolt', 'Thunder Wave'],
23 ice: ['Ice Beam', 'Aurora Beam', 'Blizzard'],
24 fighting: ['Karate Chop', 'Low Kick', 'Double Kick'],
25 poison: ['Poison Sting', 'Acid', 'Sludge'],
26 ground: ['Sand Attack', 'Dig', 'Earthquake'],
27 flying: ['Gust', 'Wing Attack', 'Peck'],
28 psychic: ['Confusion', 'Psybeam', 'Psychic'],
29 bug: ['String Shot', 'Leech Life', 'Pin Missile'],
30 rock: ['Rock Throw', 'Rock Slide', 'Harden'],
31 ghost: ['Lick', 'Night Shade', 'Confuse Ray'],
32 dragon: ['Dragon Rage', 'Outrage', 'Agility'],
33 dark: ['Bite', 'Fury Swipes', 'Screech'],
34 steel: ['Harden', 'Slash', 'Take Down'],
35 fairy: ['Sing', 'Double Slap', 'Swift'],
36}
37
38// Two moves of the first type, one of the second, and Tackle to round out four
39export function movesForTypes(types) {
40 const [first, second] = types.filter((t) => TYPE_MOVES[t])
41 const picked = [...(TYPE_MOVES[first] ?? []).slice(0, 2), ...(TYPE_MOVES[second] ?? []).slice(0, 1), 'Tackle']
42 return [...new Set(picked)].slice(0, 4)
43}
44
45const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
46
47export function fromBase64(text) {
48 const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
49 const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
50 let o = 0
51 for (let i = 0; i < clean.length; i += 4) {
52 const n = (B64.indexOf(clean[i]) << 18) | (B64.indexOf(clean[i + 1]) << 12) | ((B64.indexOf(clean[i + 2]) & 63) << 6) | (B64.indexOf(clean[i + 3]) & 63)
53 out[o++] = n >> 16
54 if (i + 2 < clean.length) out[o++] = (n >> 8) & 255
55 if (i + 3 < clean.length) out[o++] = n & 255
56 }
57 return out.subarray(0, o)
58}
59
60// GIF LZW, least significant bit first, into count palette indices
61function lzw(minSize, data, count) {
62 const clear = 1 << minSize
63 const eoi = clear + 1
64 const prefix = new Int32Array(4096)
65 const suffix = new Uint8Array(4096)
66 const stack = new Uint8Array(4097)
67 for (let i = 0; i < clear; i++) suffix[i] = i
68 const out = new Uint8Array(count)
69 let o = 0
70 let size = minSize + 1
71 let next = eoi + 1
72 let prev = -1
73 let first = 0
74 let cur = 0
75 let bits = 0
76 for (const byte of data) {
77 cur |= byte << bits
78 bits += 8
79 while (bits >= size) {
80 const code = cur & ((1 << size) - 1)
81 cur >>>= size
82 bits -= size
83 if (code === clear) {
84 size = minSize + 1
85 next = eoi + 1
86 prev = -1
87 continue
88 }
89 if (code === eoi) return out
90 if (prev === -1) {
91 if (o < count) out[o++] = suffix[code]
92 prev = code
93 first = code
94 continue
95 }
96 let sp = 0
97 let c = code
98 if (code >= next) {
99 stack[sp++] = first
100 c = prev
101 }
102 while (c > eoi) {
103 stack[sp++] = suffix[c]
104 c = prefix[c]
105 }
106 first = suffix[c]
107 stack[sp++] = first
108 while (sp > 0 && o < count) out[o++] = stack[--sp]
109 if (next < 4096) {
110 prefix[next] = prev
111 suffix[next] = first
112 next++
113 if (next === 1 << size && size < 12) size++
114 }
115 prev = code
116 }
117 }
118 return out
119}
120
121// Every frame composited onto the canvas, as 0xRRGGBB or null where it's transparent
122export function decodeGif(bytes) {
123 const sig = String.fromCharCode(...bytes.subarray(0, 3))
124 if (sig !== 'GIF') throw new Error('not a GIF')
125 const u16 = (p) => bytes[p] | (bytes[p + 1] << 8)
126 const width = u16(6)
127 const height = u16(8)
128 const flags = bytes[10]
129 let p = 13
130 const table = (n) => {
131 const colors = []
132 for (let i = 0; i < n; i++, p += 3) colors.push((bytes[p] << 16) | (bytes[p + 1] << 8) | bytes[p + 2])
133 return colors
134 }
135 const global = flags & 0x80 ? table(2 << (flags & 7)) : []
136 const skipBlocks = () => {
137 while (bytes[p] !== 0 && p < bytes.length) p += bytes[p] + 1
138 p++
139 }
140 let canvas = new Array(width * height).fill(null)
141 let control = { delay: 0, transparent: -1, disposal: 0 }
142 const frames = []
143 while (p < bytes.length) {
144 const block = bytes[p++]
145 if (block === 0x3b) break
146 if (block === 0x21) {
147 const label = bytes[p++]
148 if (label === 0xf9) {
149 const packed = bytes[p + 1]
150 control = { delay: u16(p + 2), transparent: packed & 1 ? bytes[p + 4] : -1, disposal: (packed >> 2) & 7 }
151 }
152 skipBlocks()
153 } else if (block === 0x2c) {
154 const [fx, fy, fw, fh] = [u16(p), u16(p + 2), u16(p + 4), u16(p + 6)]
155 const packed = bytes[p + 8]
156 p += 9
157 const colors = packed & 0x80 ? table(2 << (packed & 7)) : global
158 const interlaced = (packed & 0x40) !== 0
159 const minSize = bytes[p++]
160 const chunks = []
161 while (bytes[p] !== 0 && p < bytes.length) {
162 chunks.push(bytes.subarray(p + 1, p + 1 + bytes[p]))
163 p += bytes[p] + 1
164 }
165 p++
166 const data = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0))
167 let at = 0
168 for (const c of chunks) {
169 data.set(c, at)
170 at += c.length
171 }
172 const indices = lzw(minSize, data, fw * fh)
173 const rowOrder = interlaced ? interlacedRows(fh) : [...Array(fh).keys()]
174 const before = control.disposal === 3 ? canvas.slice() : null
175 rowOrder.forEach((row, k) => {
176 const y = fy + row
177 if (y >= height) return
178 for (let col = 0; col < fw; col++) {
179 const x = fx + col
180 const index = indices[k * fw + col]
181 if (x < width && index !== control.transparent) canvas[y * width + x] = colors[index] ?? null
182 }
183 })
184 frames.push({ ms: control.delay < 2 ? 100 : control.delay * 10, px: canvas.slice() })
185 if (control.disposal === 2) {
186 for (let y = fy; y < Math.min(fy + fh, height); y++) for (let x = fx; x < Math.min(fx + fw, width); x++) canvas[y * width + x] = null
187 } else if (before) {
188 canvas = before
189 }
190 control = { delay: 0, transparent: -1, disposal: 0 }
191 } else {
192 break
193 }
194 }
195 return { width, height, frames }
196}
197
198// The image row each stored row of an interlaced frame lands on
199function interlacedRows(h) {
200 const rows = []
201 for (const [start, step] of [[0, 8], [4, 8], [2, 4], [1, 2]]) for (let y = start; y < h; y += step) rows.push(y)
202 return rows
203}
204
205// The decoded sheets, as sheets[variant][anim], into the shape hooks/frames.js holds:
206// cropped to the box every frame fits in, each variant with its own palette letters
207export function buildSprite(sheets) {
208 const { width: size } = sheets.default.idle
209 let minX = Infinity
210 let minY = Infinity
211 let maxX = -1
212 let maxY = -1
213 for (const v of VARIANTS) {
214 for (const a of ANIMS) {
215 for (const { px } of sheets[v][a].frames) {
216 px.forEach((c, i) => {
217 if (c === null) return
218 const x = i % size
219 const y = Math.floor(i / size)
220 minX = Math.min(minX, x)
221 maxX = Math.max(maxX, x)
222 minY = Math.min(minY, y)
223 maxY = Math.max(maxY, y)
224 })
225 }
226 }
227 }
228 if (maxX < 0) throw new Error('the sprite is empty')
229 const out = { width: maxX - minX + 1, height: maxY - minY + 1, variants: {} }
230 for (const v of VARIANTS) {
231 const palette = []
232 const letter = (c) => {
233 if (c === null) return '.'
234 let i = palette.indexOf(c)
235 if (i < 0) i = palette.push(c) - 1
236 if (i >= LETTERS.length) throw new Error(`more than ${LETTERS.length} colors`)
237 return LETTERS[i]
238 }
239 const anims = {}
240 for (const a of ANIMS) {
241 anims[a] = sheets[v][a].frames.map(({ ms, px }) => {
242 const rows = []
243 for (let y = minY; y <= maxY; y++) {
244 let row = ''
245 for (let x = minX; x <= maxX; x++) row += letter(px[y * size + x])
246 rows.push(row)
247 }
248 return { ms, rows }
249 })
250 }
251 out.variants[v] = { palette: palette.map((c) => '#' + c.toString(16).padStart(6, '0')), ...anims }
252 }
253 return out
254}
255
256// A failed GitHub request as a line for the person
257function githubError(res) {
258 if (res.status === 403 || res.status === 429) return "GitHub's hourly limit for anonymous requests is used up. Try again in a while."
259 return 'GitHub answered ' + res.status + '.'
260}
261
262async function fetchGif(get, gen, name, file) {
263 const res = await get(`${REPO}/gen${gen}/${name}/${file}`, { headers: HEADERS })
264 if (!res.ok) return { status: res.status, error: githubError(res) }
265 const { content } = JSON.parse(res.text)
266 return { gif: decodeGif(fromBase64(content)) }
267}
268
269// Every mon folder in a generation
270export async function monsOfGen(get, gen) {
271 const res = await get(`${REPO}/gen${gen}`, { headers: HEADERS })
272 if (!res.ok) throw new Error(githubError(res))
273 return JSON.parse(res.text).filter((entry) => entry.type === 'dir').map((entry) => entry.name)
274}
275
276// The mon's types from PokéAPI, or normal when it doesn't know the name
277async function typesOf(get, name) {
278 try {
279 const res = await get(`https://pokeapi.co/api/v2/pokemon-form/${name.replace(/_/g, '-')}`)
280 if (!res.ok) return ['normal']
281 return JSON.parse(res.text).types.map((t) => t.type.name)
282 } catch {
283 return ['normal']
284 }
285}
286
287// Fetch a mon's four GIFs, from the generation given or the first that has it, and build
288// the entry the store keeps: { gen, sprite, moves }. Throws with a line for the person.
289export async function fetchMon(get, name, gen) {
290 for (const g of gen ? [gen] : GENS) {
291 const firstFile = `${VARIANTS[0]}_${ANIMS[0]}_8fps.gif`
292 const first = await fetchGif(get, g, name, firstFile)
293 if (first.status === 404) continue
294 if (first.error) throw new Error(first.error)
295 const rest = await Promise.all(
296 VARIANTS.flatMap((v) => ANIMS.map((a) => [v, a])).slice(1).map(([v, a]) => fetchGif(get, g, name, `${v}_${a}_8fps.gif`)),
297 )
298 const failed = rest.find((r) => r.error)
299 if (failed) throw new Error(failed.status === 404 ? `${name} is missing some of its GIFs.` : failed.error)
300 const gifs = [first, ...rest].map((r) => r.gif)
301 const sheets = { default: { idle: gifs[0], walk: gifs[1] }, shiny: { idle: gifs[2], walk: gifs[3] } }
302 return { gen: g, sprite: buildSprite(sheets), moves: movesForTypes(await typesOf(get, name)) }
303 }
304 throw new Error(`No mon named "${name}" in generations ${(gen ? [gen] : GENS).join(', ')}.`)
305}
306hooks/dex.js 93 lines1import { BASE_FORM, evolutionsOf, preEvolutionOf, startLevel } from './evolutions.js'
2
3// The gen 1 Pokédex and how often each mon turns up in the wild. DEX lists the 151
4// sprite keys in national dex order. A female sprite counts as its base form's entry.
5
6export const DEX = [
7 'bulbasaur', 'ivysaur', 'venusaur', 'charmander', 'charmeleon', 'charizard', 'squirtle', 'wartortle', 'blastoise', 'caterpie',
8 'metapod', 'butterfree', 'weedle', 'kakuna', 'beedrill', 'pidgey', 'pidgeotto', 'pidgeot', 'rattata', 'raticate',
9 'spearow', 'fearow', 'ekans', 'arbok', 'pikachu', 'raichu', 'sandshrew', 'sandslash', 'nidoran_female', 'nidorina',
10 'nidoqueen', 'nidoran_male', 'nidorino', 'nidoking', 'clefairy', 'clefable', 'vulpix', 'ninetales', 'jigglypuff', 'wigglytuff',
11 'zubat', 'golbat', 'oddish', 'gloom', 'vileplume', 'paras', 'parasect', 'venonat', 'venomoth', 'diglett',
12 'dugtrio', 'meowth', 'persian', 'psyduck', 'golduck', 'mankey', 'primeape', 'growlithe', 'arcanine', 'poliwag',
13 'poliwhirl', 'poliwrath', 'abra', 'kadabra', 'alakazam', 'machop', 'machoke', 'machamp', 'bellsprout', 'weepinbell',
14 'victreebel', 'tentacool', 'tentacruel', 'geodude', 'graveler', 'golem', 'ponyta', 'rapidash', 'slowpoke', 'slowbro',
15 'magnemite', 'magneton', 'farfetchd', 'doduo', 'dodrio', 'seel', 'dewgong', 'grimer', 'muk', 'shellder',
16 'cloyster', 'gastly', 'haunter', 'gengar', 'onix', 'drowzee', 'hypno', 'krabby', 'kingler', 'voltorb',
17 'electrode', 'exeggcute', 'exeggutor', 'cubone', 'marowak', 'hitmonlee', 'hitmonchan', 'lickitung', 'koffing', 'weezing',
18 'rhyhorn', 'rhydon', 'chansey', 'tangela', 'kangaskhan', 'horsea', 'seadra', 'goldeen', 'seaking', 'staryu',
19 'starmie', 'mrmime', 'scyther', 'jynx', 'electabuzz', 'magmar', 'pinsir', 'tauros', 'magikarp', 'gyarados',
20 'lapras', 'ditto', 'eevee', 'vaporeon', 'jolteon', 'flareon', 'porygon', 'omanyte', 'omastar', 'kabuto',
21 'kabutops', 'aerodactyl', 'snorlax', 'articuno', 'zapdos', 'moltres', 'dratini', 'dragonair', 'dragonite', 'mewtwo',
22 'mew',
23]
24
25export const dexKey = (mon) => BASE_FORM[mon] ?? mon
26
27export const dexNumber = (mon) => DEX.indexOf(dexKey(mon)) + 1
28
29export const LEGENDARY = ['articuno', 'zapdos', 'moltres', 'mewtwo', 'mew']
30
31// Rare mons and everything they evolve into
32export const RARE = [
33 'lapras', 'snorlax', 'eevee', 'dratini', 'omanyte', 'kabuto', 'aerodactyl', 'chansey', 'tauros', 'kangaskhan',
34 'scyther', 'pinsir', 'porygon', 'ditto', 'hitmonlee', 'hitmonchan',
35]
36
37// weight is how often a tier turns up, hits how many attacks wear one out, and catch
38// the best odds of a ball holding it
39export const TIERS = {
40 common: { weight: 60, hits: 3, catch: 0.9 },
41 uncommon: { weight: 25, hits: 5, catch: 0.75 },
42 rare: { weight: 6, hits: 7, catch: 0.55 },
43 legendary: { weight: 1, hits: 10, catch: 0.35 },
44}
45
46const STAGE_TIERS = ['common', 'uncommon', 'rare']
47const SHINY_ODDS = 1 / 128
48const FEMALE_FORM = Object.fromEntries(Object.entries(BASE_FORM).map(([female, base]) => [base, female]))
49
50// Outside the short lists, a family's base form is common and each evolution a tier
51// rarer. A mon with no family is uncommon.
52export function tierOf(mon) {
53 const key = dexKey(mon)
54 if (LEGENDARY.includes(key)) return 'legendary'
55 let base = key
56 let stage = 0
57 for (let parent = preEvolutionOf(base); parent; parent = preEvolutionOf(base)) {
58 base = parent
59 stage++
60 }
61 if (RARE.includes(base)) return 'rare'
62 if (stage === 0 && evolutionsOf(base).length === 0) return 'uncommon'
63 return STAGE_TIERS[stage]
64}
65
66const BY_TIER = Object.fromEntries(Object.keys(TIERS).map((tier) => [tier, DEX.filter((mon) => tierOf(mon) === tier)]))
67
68// A tier by weight, then a mon from it. random() returns a number in [0, 1).
69export function pickWild(random) {
70 const total = Object.values(TIERS).reduce((sum, { weight }) => sum + weight, 0)
71 let roll = random() * total
72 const tier = Object.keys(TIERS).find((name) => (roll -= TIERS[name].weight) < 0) ?? 'common'
73 const pool = BY_TIER[tier]
74 const key = pool[Math.floor(random() * pool.length)]
75 const mon = FEMALE_FORM[key] && random() < 0.5 ? FEMALE_FORM[key] : key
76 const variant = random() < SHINY_ODDS ? 'shiny' : 'default'
77 return { mon, variant, key, tier, level: startLevel(mon) }
78}
79
80// Two marks of one mon as one: the earliest time for each of seen and caught, and shiny
81// if either has it
82export function mergedDexEntry(old, patch) {
83 const entry = old && typeof old === 'object' ? { ...old } : {}
84 for (const key of ['seen', 'caught']) {
85 const times = [entry[key], patch[key]].filter((t) => typeof t === 'number')
86 if (times.length > 0) entry[key] = Math.min(...times)
87 }
88 if (entry.shiny || patch.shiny) entry.shiny = true
89 return entry
90}
91
92export const isCaught = (entry) => typeof entry?.caught === 'number'
93hooks/evolutions.js 113 lines1// The gen 1 evolutions follow the Red and Blue rules and are keyed by sprite key, in
2// Pokédex order. Each entry is one thing the mon can become: { into, level } on level-up,
3// { into, item } with an evolution stone, or { into, trade: true }. A mon that doesn't
4// evolve has no entry.
5
6const FIRST_LEVEL = 5
7
8// Female sprites take their base form's place in the family, so raichu comes from
9// pikachu and venusaur_female from ivysaur
10export const BASE_FORM = { pikachu_female: 'pikachu', venusaur_female: 'venusaur' }
11
12export const EVOLUTIONS = {
13 bulbasaur: [{ into: 'ivysaur', level: 16 }],
14 ivysaur: [{ into: 'venusaur', level: 32 }],
15 charmander: [{ into: 'charmeleon', level: 16 }],
16 charmeleon: [{ into: 'charizard', level: 36 }],
17 squirtle: [{ into: 'wartortle', level: 16 }],
18 wartortle: [{ into: 'blastoise', level: 36 }],
19 caterpie: [{ into: 'metapod', level: 7 }],
20 metapod: [{ into: 'butterfree', level: 10 }],
21 weedle: [{ into: 'kakuna', level: 7 }],
22 kakuna: [{ into: 'beedrill', level: 10 }],
23 pidgey: [{ into: 'pidgeotto', level: 18 }],
24 pidgeotto: [{ into: 'pidgeot', level: 36 }],
25 rattata: [{ into: 'raticate', level: 20 }],
26 spearow: [{ into: 'fearow', level: 20 }],
27 ekans: [{ into: 'arbok', level: 22 }],
28 pikachu: [{ into: 'raichu', item: 'Thunder Stone' }],
29 pikachu_female: [{ into: 'raichu', item: 'Thunder Stone' }],
30 sandshrew: [{ into: 'sandslash', level: 22 }],
31 nidoran_female: [{ into: 'nidorina', level: 16 }],
32 nidorina: [{ into: 'nidoqueen', item: 'Moon Stone' }],
33 nidoran_male: [{ into: 'nidorino', level: 16 }],
34 nidorino: [{ into: 'nidoking', item: 'Moon Stone' }],
35 clefairy: [{ into: 'clefable', item: 'Moon Stone' }],
36 vulpix: [{ into: 'ninetales', item: 'Fire Stone' }],
37 jigglypuff: [{ into: 'wigglytuff', item: 'Moon Stone' }],
38 zubat: [{ into: 'golbat', level: 22 }],
39 oddish: [{ into: 'gloom', level: 21 }],
40 gloom: [{ into: 'vileplume', item: 'Leaf Stone' }],
41 paras: [{ into: 'parasect', level: 24 }],
42 venonat: [{ into: 'venomoth', level: 31 }],
43 diglett: [{ into: 'dugtrio', level: 26 }],
44 meowth: [{ into: 'persian', level: 28 }],
45 psyduck: [{ into: 'golduck', level: 33 }],
46 mankey: [{ into: 'primeape', level: 28 }],
47 growlithe: [{ into: 'arcanine', item: 'Fire Stone' }],
48 poliwag: [{ into: 'poliwhirl', level: 25 }],
49 poliwhirl: [{ into: 'poliwrath', item: 'Water Stone' }],
50 abra: [{ into: 'kadabra', level: 16 }],
51 kadabra: [{ into: 'alakazam', trade: true }],
52 machop: [{ into: 'machoke', level: 28 }],
53 machoke: [{ into: 'machamp', trade: true }],
54 bellsprout: [{ into: 'weepinbell', level: 21 }],
55 weepinbell: [{ into: 'victreebel', item: 'Leaf Stone' }],
56 tentacool: [{ into: 'tentacruel', level: 30 }],
57 geodude: [{ into: 'graveler', level: 25 }],
58 graveler: [{ into: 'golem', trade: true }],
59 ponyta: [{ into: 'rapidash', level: 40 }],
60 slowpoke: [{ into: 'slowbro', level: 37 }],
61 magnemite: [{ into: 'magneton', level: 30 }],
62 doduo: [{ into: 'dodrio', level: 31 }],
63 seel: [{ into: 'dewgong', level: 34 }],
64 grimer: [{ into: 'muk', level: 38 }],
65 shellder: [{ into: 'cloyster', item: 'Water Stone' }],
66 gastly: [{ into: 'haunter', level: 25 }],
67 haunter: [{ into: 'gengar', trade: true }],
68 drowzee: [{ into: 'hypno', level: 26 }],
69 krabby: [{ into: 'kingler', level: 28 }],
70 voltorb: [{ into: 'electrode', level: 30 }],
71 exeggcute: [{ into: 'exeggutor', item: 'Leaf Stone' }],
72 cubone: [{ into: 'marowak', level: 28 }],
73 koffing: [{ into: 'weezing', level: 35 }],
74 rhyhorn: [{ into: 'rhydon', level: 42 }],
75 horsea: [{ into: 'seadra', level: 32 }],
76 goldeen: [{ into: 'seaking', level: 33 }],
77 staryu: [{ into: 'starmie', item: 'Water Stone' }],
78 magikarp: [{ into: 'gyarados', level: 20 }],
79 eevee: [
80 { into: 'vaporeon', item: 'Water Stone' },
81 { into: 'jolteon', item: 'Thunder Stone' },
82 { into: 'flareon', item: 'Fire Stone' },
83 ],
84 omanyte: [{ into: 'omastar', level: 40 }],
85 kabuto: [{ into: 'kabutops', level: 40 }],
86 dratini: [{ into: 'dragonair', level: 30 }],
87 dragonair: [{ into: 'dragonite', level: 55 }],
88}
89
90// PARENTS maps each evolved mon to the mon it comes from and the entry that takes it there
91const PARENTS = new Map()
92for (const [from, entries] of Object.entries(EVOLUTIONS)) {
93 if (BASE_FORM[from]) continue
94 for (const entry of entries) PARENTS.set(entry.into, { from, entry })
95}
96
97const parentOf = (mon) => PARENTS.get(BASE_FORM[mon] ?? mon)
98
99export const evolutionsOf = (mon) => EVOLUTIONS[mon] ?? []
100
101export const levelEvolutionOf = (mon) => evolutionsOf(mon).find((entry) => entry.level) ?? null
102
103export const preEvolutionOf = (mon) => parentOf(mon)?.from ?? null
104
105// A mon reached by level-up starts at that level, one reached by stone or trade starts
106// where its pre-evolution does, and any other mon starts at FIRST_LEVEL, the lowest
107// levels the games allow
108export function startLevel(mon) {
109 const parent = parentOf(mon)
110 if (!parent) return FIRST_LEVEL
111 return parent.entry.level ?? startLevel(parent.from)
112}
113hooks/eyes.js 285 lines1// Closes a sprite's eyes for sleep. The sprites have no sleeping frames, so EYES marks
2// each eye on every frame as a box [x, y, width, height] around its whites, pupil and
3// highlight. Shutting an eye paints its box the skin around it and draws the bottom row
4// of the box as a lid in the sprite's darkest color, or on a face nearly that dark, in the
5// darkest color light enough to show on it.
6
7const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
8const luma = ([r, g, b]) => 0.299 * r + 0.587 * g + 0.114 * b
9// How far a lid's brightness sits from the skin's: darker, or lighter on a face too dark for that
10const DARKER_LID = 48
11const LIGHTER_LID = 40
12const SIDES = [
13 [1, 0],
14 [-1, 0],
15 [0, 1],
16 [0, -1],
17]
18
19// A sprite's palette letters by brightness, darkest first
20function paletteOf(palette) {
21 const lumaOf = (letter) => luma(channels(palette[letter.charCodeAt(0) - 97]))
22 const letters = palette.map((_, i) => String.fromCharCode(97 + i)).sort((a, b) => lumaOf(a) - lumaOf(b))
23 return { lumaOf, letters, darkest: letters[0] }
24}
25
26// The skin around an eye box: the most common color bordering it, passing over the
27// outline unless that's all there is. A tie goes to the lighter color, as shading is
28// darker. Null when nothing opaque borders the box.
29function skinOf(rows, [bx, by, w, h], { lumaOf, darkest }) {
30 const inside = (x, y) => x >= bx && x < bx + w && y >= by && y < by + h
31 const opaque = (x, y) => (rows[y]?.[x] ?? '.') !== '.'
32 const around = new Map()
33 for (let y = by; y < by + h; y++) {
34 for (let x = bx; x < bx + w; x++) {
35 for (const [dx, dy] of SIDES) {
36 if (inside(x + dx, y + dy) || !opaque(x + dx, y + dy)) continue
37 const l = rows[y + dy][x + dx]
38 around.set(l, (around.get(l) ?? 0) + 1)
39 }
40 }
41 }
42 if (around.size === 0) return null
43 return [...around].sort((a, b) => (a[0] === darkest) - (b[0] === darkest) || b[1] - a[1] || lumaOf(b[0]) - lumaOf(a[0]))[0][0]
44}
45
46// Rows of palette letters with the eyes in the boxes shut, or the same rows with no boxes
47export function closedEyes(rows, palette, boxes) {
48 if (!boxes || boxes.length === 0) return rows
49 const grid = rows.map((row) => row.split(''))
50 const opaque = (x, y) => (rows[y]?.[x] ?? '.') !== '.'
51 const shades = paletteOf(palette)
52 const { lumaOf, letters, darkest } = shades
53 for (const box of boxes) {
54 const [bx, by, w, h] = box
55 const skin = skinOf(rows, box, shades)
56 if (skin === null) continue
57 const shows = lumaOf(skin) - lumaOf(darkest) >= DARKER_LID
58 const lid = shows ? darkest : (letters.find((l) => lumaOf(l) - lumaOf(skin) >= LIGHTER_LID) ?? darkest)
59 for (let y = by; y < by + h; y++) {
60 for (let x = bx; x < bx + w; x++) if (opaque(x, y)) grid[y][x] = y === by + h - 1 ? lid : skin
61 }
62 }
63 return grid.map((row) => row.join(''))
64}
65
66// The pupil of each eye as [x, y, box, skin, letters] in the frame: the pixel of its
67// darkest color, or on a face too dark for that the starkest, nearest the box's middle,
68// with the letters the box holds besides the skin. The eye's colors are those standing
69// out from the skin at least half as much as its starkest one, leaving out shading and
70// the skin's own tints, so a glint brighter than the pupil doesn't win. A shrunk band
71// keeps the pupil and paints the rest of the box the skin, so each eye shrinks to the
72// same one dot wherever the blocks fall. An eye drawn as a line is just [x, y] at the
73// middle of the line, and keeps the rest, so it doesn't turn into a dot at one end.
74export function pupilsOf(rows, palette, { boxes, lines = false } = {}) {
75 if (!boxes || boxes.length === 0) return []
76 const shades = paletteOf(palette)
77 const pupils = []
78 for (const box of boxes) {
79 const [bx, by, w, h] = box
80 const skin = skinOf(rows, box, shades)
81 if (skin === null) continue
82 const contrastOf = (l) => Math.abs(shades.lumaOf(l) - shades.lumaOf(skin))
83 const inBox = []
84 for (let y = by; y < by + h; y++) for (let x = bx; x < bx + w; x++) if ((rows[y]?.[x] ?? '.') !== '.') inBox.push(rows[y][x])
85 const starkest = Math.max(0, ...inBox.map(contrastOf))
86 const eye = inBox.filter((l) => contrastOf(l) > 0 && contrastOf(l) * 2 >= starkest)
87 const darkest = eye.reduce((a, l) => (shades.lumaOf(l) < shades.lumaOf(a) ? l : a), eye[0])
88 const pupil = darkest !== undefined && shades.lumaOf(darkest) < shades.lumaOf(skin) ? darkest : eye.find((l) => contrastOf(l) === starkest)
89 const [mx, my] = [bx + (w - 1) / 2, by + (h - 1) / 2]
90 let best = null
91 let bestScore = 0
92 for (let y = by; y < by + h; y++) {
93 for (let x = bx; x < bx + w; x++) {
94 const l = rows[y]?.[x] ?? '.'
95 if (l === '.' || contrastOf(l) === 0) continue
96 const near = 100 - Math.abs(x - mx) - Math.abs(y - my)
97 const score = lines ? near * 1000 + contrastOf(l) : (l === pupil) * 100000 + near
98 if (score > bestScore) [best, bestScore] = [[x, y], score]
99 }
100 }
101 if (!best) continue
102 pupils.push(lines ? best : [...best, box, skin, [...new Set(inBox)].filter((l) => l !== skin).join('')])
103 }
104 return pupils
105}
106
107// Each mon's eye boxes per frame: { idle: [boxes per frame], walk: [...] }, with a
108// variant's own entry where its frames differ from the default ones
109export const EYES = {
110 abra: { idle: [[], []], walk: [[], []] },
111 aerodactyl: { idle: [[[11, 13, 2, 2], [16, 13, 2, 2]], [[11, 12, 2, 2], [16, 12, 2, 2]]], walk: [[[20, 10, 2, 2]], [[20, 9, 2, 2]]] },
112 alakazam: { idle: [[[8, 11, 3, 3], [13, 11, 3, 3]], [[8, 10, 3, 3], [13, 10, 3, 3]]], walk: [[[15, 10, 4, 3]], [[15, 9, 4, 3]]] },
113 arbok: { idle: [[[6, 7, 3, 2], [14, 7, 3, 2]], [[6, 6, 3, 2], [14, 6, 3, 2]]], walk: [[[15, 8, 4, 2]], [[12, 8, 4, 2]]] },
114 arcanine: { idle: [[[10, 19, 3, 2], [16, 19, 3, 2]], [[10, 19, 3, 2], [16, 19, 3, 2]]], walk: [[[22, 15, 2, 3]], [[22, 14, 2, 3]]] },
115 articuno: { idle: [[[11, 16, 2, 2], [16, 16, 2, 2]], [[11, 15, 2, 2], [16, 15, 2, 2]]], walk: [[[19, 13, 2, 2]], [[19, 12, 2, 2]]] },
116 beedrill: { idle: [[[8, 9, 2, 3], [15, 9, 2, 3]], [[8, 9, 2, 3], [15, 9, 2, 3]]], walk: [[[13, 10, 4, 2]], [[13, 9, 4, 2]]] },
117 bellsprout: { idle: [[[3, 7, 2, 1], [8, 7, 2, 1]], [[3, 6, 2, 1], [8, 6, 2, 1]]], walk: [[[8, 9, 2, 1]], [[8, 8, 2, 1]]] },
118 blastoise: { idle: [[[9, 8, 3, 2], [16, 8, 3, 2]], [[9, 7, 3, 2], [16, 7, 3, 2]]], walk: [[[17, 9, 3, 2]], [[17, 8, 3, 2]]] },
119 bulbasaur: { idle: [[[4, 10, 2, 2], [11, 10, 2, 2]], [[4, 10, 2, 2], [11, 10, 2, 2]], [[4, 10, 2, 2], [11, 10, 2, 2]], [[4, 11, 2, 2], [11, 11, 2, 2]], [[4, 11, 2, 2], [11, 11, 2, 2]], [[4, 11, 2, 2], [11, 11, 2, 2]]], walk: [[[14, 8, 2, 2]], [[14, 8, 2, 2]], [[14, 8, 2, 2]], [[14, 9, 2, 2]], [[14, 9, 2, 2]], [[14, 9, 2, 2]]], shiny: { idle: [[[4, 11, 2, 2], [11, 11, 2, 2]], [[4, 10, 2, 2], [11, 10, 2, 2]]], walk: [[[13, 9, 2, 2]], [[13, 8, 2, 2]]] } },
120 butterfree: { idle: [[[9, 6, 3, 5], [16, 6, 3, 5]], [[9, 7, 3, 5], [16, 7, 3, 5]]], walk: [[[13, 8, 4, 5]], [[13, 8, 4, 5]]] },
121 caterpie: { idle: [[[3, 6, 3, 4], [11, 6, 3, 4]], [[3, 6, 3, 4], [11, 6, 3, 4]]], walk: [[[11, 9, 4, 4]], [[11, 7, 4, 4]]] },
122 chansey: { idle: [[[6, 7, 2, 2], [11, 7, 2, 2]], [[6, 6, 2, 2], [11, 6, 2, 2]]], walk: [[[15, 8, 2, 2]], [[15, 7, 2, 2]]] },
123 charizard: { idle: [[[11, 10, 2, 3], [17, 10, 2, 3]], [[11, 9, 2, 3], [17, 9, 2, 3]]], walk: [[[19, 10, 3, 2]], [[19, 9, 3, 2]]] },
124 charmander: { idle: [[[4, 6, 2, 3], [9, 6, 2, 3]], [[4, 5, 2, 3], [9, 5, 2, 3]]], walk: [[[14, 6, 2, 3]], [[14, 5, 2, 3]]] },
125 charmeleon: { idle: [[[8, 10, 2, 3], [15, 10, 2, 3]], [[8, 9, 2, 3], [15, 9, 2, 3]]], walk: [[[15, 10, 2, 3]], [[15, 9, 2, 3]]] },
126 clefable: { idle: [[[8, 8, 2, 2], [13, 8, 2, 2]], [[8, 8, 2, 2], [13, 8, 2, 2]]], walk: [[[12, 8, 2, 2]], [[12, 8, 2, 2]]] },
127 clefairy: { idle: [[[5, 8, 2, 2], [10, 8, 2, 2]], [[5, 7, 2, 2], [10, 7, 2, 2]]], walk: [[[12, 9, 2, 2]], [[12, 9, 2, 2]]] },
128 cloyster: { idle: [[[10, 16, 3, 3], [16, 16, 3, 3]], [[10, 16, 3, 3], [16, 16, 3, 3]]], walk: [[[17, 16, 3, 3]], [[17, 16, 3, 3]]] },
129 cubone: { idle: [[[5, 10, 2, 2], [12, 10, 2, 2]], [[5, 9, 2, 2], [12, 9, 2, 2]]], walk: [[[11, 10, 2, 2]], [[11, 9, 2, 2]]] },
130 dewgong: { idle: [[[10, 12, 2, 2], [17, 12, 2, 2]], [[10, 11, 2, 2], [17, 11, 2, 2]]], walk: [[[20, 11, 2, 2]], [[20, 11, 2, 2]]] },
131 diglett: { idle: [[[5, 5, 2, 2], [8, 5, 2, 2]], [[5, 4, 2, 2], [8, 4, 2, 2]]], walk: [[[7, 5, 2, 2]], [[7, 4, 2, 2]]] },
132 ditto: { idle: [[[5, 5, 2, 1], [9, 5, 2, 1]], [[5, 6, 2, 1], [9, 6, 2, 1]]], walk: [[[10, 5, 2, 1]], [[10, 6, 2, 1]]] },
133 dodrio: { idle: [[[6, 15, 2, 2], [11, 10, 2, 2], [16, 10, 2, 2], [21, 18, 2, 1]], [[6, 13, 2, 2], [11, 9, 2, 2], [16, 9, 2, 2], [21, 16, 2, 1]]], walk: [[[12, 11, 2, 2], [21, 15, 2, 2]], [[13, 10, 2, 2], [21, 13, 2, 2]]] },
134 doduo: { idle: [[[5, 8, 2, 1], [14, 8, 2, 1]], [[6, 8, 2, 1], [13, 8, 2, 1]]], walk: [[], []] },
135 dragonair: { idle: [[[12, 11, 2, 3], [17, 11, 2, 3]], [[12, 10, 2, 3], [17, 10, 2, 3]]], walk: [[[19, 9, 2, 3]], [[18, 9, 2, 3]]] },
136 dragonite: { idle: [[[9, 8, 2, 3], [16, 8, 2, 3]], [[9, 7, 2, 3], [16, 7, 2, 3]]], walk: [[[19, 9, 2, 3]], [[19, 8, 2, 3]]] },
137 dratini: { idle: [[[7, 9, 2, 3], [13, 9, 2, 3]], [[7, 8, 2, 3], [13, 8, 2, 3]]], walk: [[[12, 8, 2, 3]], [[13, 8, 2, 3]]] },
138 drowzee: { idle: [[[3, 9, 2, 1], [11, 9, 2, 1]], [[3, 8, 2, 1], [11, 8, 2, 1]]], walk: [[[10, 9, 2, 1]], [[10, 9, 2, 1]]] },
139 dugtrio: { idle: [[[11, 3, 2, 2], [14, 3, 2, 2], [5, 9, 2, 2], [8, 9, 2, 2], [15, 12, 2, 2], [18, 12, 2, 2]], [[11, 4, 2, 2], [14, 4, 2, 2], [5, 10, 2, 2], [8, 10, 2, 2], [15, 13, 2, 2], [18, 13, 2, 2]]], walk: [[[8, 5, 2, 2], [15, 7, 2, 2], [16, 14, 2, 2]], [[8, 4, 2, 2], [15, 6, 2, 2], [16, 13, 2, 2]]] },
140 eevee: { idle: [[[7, 8, 2, 3], [12, 8, 2, 3]], [[7, 7, 2, 3], [12, 7, 2, 3]]], walk: [[[13, 8, 2, 3]], [[13, 7, 2, 3]]] },
141 ekans: { idle: [[[8, 8, 2, 3], [15, 8, 2, 3]], [[8, 6, 2, 3], [15, 6, 2, 3]]], walk: [[[15, 7, 3, 3]], [[14, 7, 3, 3]]] },
142 electabuzz: { idle: [[[11, 10, 2, 2], [13, 11, 2, 2], [18, 10, 2, 2], [16, 11, 2, 2]], [[11, 9, 2, 2], [13, 10, 2, 2], [18, 9, 2, 2], [16, 10, 2, 2]]], walk: [[[16, 11, 2, 2]], [[16, 10, 2, 2]]] },
143 electrode: { idle: [[[5, 9, 4, 4], [13, 10, 4, 3]], [[5, 8, 4, 4], [13, 9, 4, 3]]], walk: [[[14, 9, 4, 4]], [[14, 8, 4, 4]]] },
144 exeggcute: { idle: [[[12, 3, 2, 2], [8, 13, 2, 2], [13, 13, 2, 2], [20, 12, 2, 2], [4, 18, 2, 2], [15, 19, 2, 2], [18, 19, 2, 2]], [[12, 4, 2, 2], [8, 13, 2, 2], [13, 13, 2, 2], [20, 11, 2, 2], [4, 17, 2, 2], [15, 19, 2, 2], [18, 19, 2, 2]]], walk: [[[11, 6, 2, 1], [14, 14, 2, 2], [22, 10, 2, 2], [8, 19, 2, 2], [19, 18, 2, 1]], [[11, 6, 2, 1], [14, 14, 2, 2], [22, 9, 2, 2], [8, 19, 2, 2], [19, 19, 2, 1]]] },
145 exeggutor: { idle: [[[5, 18, 2, 1], [8, 20, 3, 1], [14, 20, 3, 1], [16, 17, 2, 1]], [[5, 17, 2, 1], [8, 19, 3, 1], [14, 19, 3, 1], [16, 16, 2, 1]]], walk: [[[12, 20, 2, 1], [17, 18, 2, 1]], [[12, 19, 2, 1], [17, 17, 2, 1]]] },
146 farfetchd: { idle: [[[5, 9, 2, 3], [10, 9, 2, 3]], [[5, 8, 2, 3], [10, 8, 2, 3]]], walk: [[[8, 10, 2, 3]], [[8, 10, 2, 3]]] },
147 fearow: { idle: [[[11, 16, 2, 2], [18, 16, 2, 2]], [[11, 16, 2, 2], [18, 16, 2, 2]]], walk: [[[19, 17, 2, 2]], [[19, 17, 2, 2]]] },
148 flareon: { idle: [[[11, 16, 2, 2], [16, 16, 2, 2]], [[11, 15, 2, 2], [16, 15, 2, 2]]], walk: [[[20, 15, 2, 2]], [[20, 14, 2, 2]]] },
149 gastly: { idle: [[[8, 15, 3, 3], [14, 15, 3, 3]], [[8, 14, 3, 3], [14, 14, 3, 3]]], walk: [[[14, 15, 3, 3]], [[14, 14, 3, 3]]] },
150 gengar: { idle: [[[6, 11, 3, 3], [12, 11, 3, 3]], [[6, 10, 3, 3], [12, 10, 3, 3]]], walk: [[[13, 12, 3, 3]], [[13, 11, 3, 3]]] },
151 geodude: { idle: [[[7, 9, 2, 2], [12, 9, 2, 2]], [[7, 9, 2, 2], [12, 9, 2, 2]]], walk: [[[13, 7, 2, 2]], [[13, 6, 2, 2]]] },
152 gloom: { idle: [[], []], walk: [[], []] },
153 golbat: { idle: [[[10, 9, 3, 2], [16, 9, 3, 2]], [[10, 8, 3, 2], [16, 8, 3, 2]]], walk: [[[17, 8, 3, 2]], [[17, 7, 3, 2]]] },
154 goldeen: { idle: [[[10, 16, 2, 1], [17, 16, 2, 1]], [[10, 16, 2, 1], [17, 16, 2, 1]]], walk: [[[16, 15, 2, 1]], [[16, 15, 2, 1]]] },
155 golduck: { idle: [[[4, 9, 3, 3], [10, 9, 3, 3]], [[4, 9, 3, 3], [10, 9, 3, 3]]], walk: [[[9, 9, 4, 3]], [[9, 9, 4, 3]]] },
156 golem: { idle: [[[9, 13, 2, 2], [14, 13, 2, 2]], [[9, 12, 2, 2], [14, 12, 2, 2]]], walk: [[[22, 12, 3, 2]], [[22, 11, 3, 2]]] },
157 graveler: { idle: [[[8, 12, 3, 2], [14, 12, 3, 2]], [[8, 11, 3, 2], [14, 11, 3, 2]]], walk: [[[16, 12, 3, 2]], [[16, 12, 3, 2]]] },
158 grimer: { idle: [[[5, 7, 2, 3], [11, 7, 2, 3]], [[5, 6, 2, 3], [11, 6, 2, 3]]], walk: [[[12, 7, 2, 3]], [[12, 6, 2, 3]]] },
159 growlithe: { idle: [[[5, 13, 2, 2], [11, 13, 2, 2]], [[5, 13, 2, 2], [11, 13, 2, 2]]], walk: [[[13, 11, 2, 3]], [[13, 10, 2, 3]]] },
160 gyarados: { idle: [[[5, 16, 2, 2], [12, 16, 2, 2]], [[5, 17, 2, 2], [12, 17, 2, 2]]], walk: [[[20, 11, 2, 2]], [[20, 9, 2, 2]]] },
161 haunter: { idle: [[[7, 9, 3, 3], [14, 9, 3, 3]], [[7, 8, 3, 3], [14, 8, 3, 3]]], walk: [[[11, 9, 4, 3]], [[11, 8, 4, 3]]] },
162 hitmonchan: { idle: [[[5, 10, 2, 2], [11, 10, 2, 2]], [[5, 9, 2, 2], [11, 9, 2, 2]]], walk: [[[10, 11, 2, 2]], [[10, 10, 2, 2]]] },
163 hitmonlee: { idle: [[[7, 8, 2, 2], [12, 8, 2, 2]], [[7, 7, 2, 2], [12, 7, 2, 2]]], walk: [[[13, 8, 2, 2]], [[13, 7, 2, 2]]] },
164 horsea: { idle: [[[4, 6, 2, 2], [9, 6, 2, 2]], [[4, 5, 2, 2], [9, 5, 2, 2]]], walk: [[[8, 6, 2, 3]], [[7, 5, 2, 3]]] },
165 hypno: { idle: [[[6, 10, 3, 2], [13, 10, 3, 2]], [[6, 9, 3, 2], [13, 9, 3, 2]]], walk: [[[12, 11, 3, 1]], [[12, 10, 3, 1]]] },
166 ivysaur: { idle: [[[7, 14, 2, 2], [14, 14, 2, 2]], [[7, 13, 2, 2], [14, 13, 2, 2]]], walk: [[[14, 11, 2, 2]], [[14, 10, 2, 2]]] },
167 jigglypuff: { idle: [[[2, 7, 4, 4], [9, 7, 4, 4]], [[2, 7, 4, 4], [9, 7, 4, 4]]], walk: [[[10, 7, 4, 4]], [[10, 7, 4, 4]]] },
168 jolteon: { idle: [[[11, 15, 2, 2], [16, 15, 2, 2]], [[11, 14, 2, 2], [16, 14, 2, 2]]], walk: [[[20, 14, 2, 2]], [[19, 13, 3, 2]]] },
169 jynx: { idle: [[[8, 10, 3, 3], [14, 10, 3, 3]], [[8, 10, 3, 3], [14, 10, 3, 3]]], walk: [[[12, 9, 3, 3]], [[10, 9, 3, 3]]] },
170 kabuto: { idle: [[[4, 11, 2, 1], [4, 12, 2, 1], [9, 11, 2, 1], [9, 12, 2, 1]], [[4, 10, 2, 1], [4, 11, 2, 1], [9, 10, 2, 1], [9, 11, 2, 1]]], walk: [[[11, 11, 2, 1]], [[11, 10, 2, 1]]] },
171 kabutops: { idle: [[[9, 9, 2, 3], [15, 9, 2, 3]], [[9, 8, 2, 3], [15, 8, 2, 3]]], walk: [[[18, 10, 2, 3]], [[18, 9, 2, 3]]] },
172 kadabra: { idle: [[[7, 8, 2, 3], [14, 8, 2, 3]], [[7, 7, 2, 3], [14, 7, 2, 3]]], walk: [[[14, 9, 2, 3]], [[14, 9, 2, 3]]] },
173 kakuna: { idle: [[[3, 7, 3, 3], [9, 7, 3, 3]], [[3, 8, 3, 3], [9, 8, 3, 3]]], walk: [[[6, 7, 4, 2]], [[6, 8, 4, 2]]] },
174 kangaskhan: { idle: [[[7, 10, 2, 2], [13, 10, 2, 2]], [[7, 9, 2, 2], [13, 9, 2, 2]]], walk: [[[13, 10, 3, 2]], [[13, 9, 3, 2]]] },
175 kingler: { idle: [[[12, 14, 3, 2]], [[11, 14, 4, 2]]], walk: [[[9, 16, 3, 3], [16, 16, 3, 3]], [[9, 16, 3, 3], [16, 16, 3, 3]]] },
176 koffing: { idle: [[[7, 12, 4, 2], [13, 12, 4, 2]], [[7, 11, 4, 2], [13, 11, 4, 2]]], walk: [[[16, 12, 3, 3]], [[16, 11, 3, 3]]] },
177 krabby: { idle: [[[8, 8, 3, 2]], [[8, 8, 3, 2]]], walk: [[[7, 10, 3, 2], [12, 10, 3, 2]], [[7, 10, 3, 2], [12, 10, 3, 2]]] },
178 lapras: { idle: [[[11, 7, 2, 3], [18, 7, 2, 3]], [[11, 8, 2, 3], [18, 8, 2, 3]]], walk: [[[16, 8, 3, 2]], [[15, 8, 3, 2]]] },
179 lickitung: { idle: [[[8, 8, 2, 2], [15, 8, 2, 2]], [[8, 7, 2, 2], [15, 7, 2, 2]]], walk: [[[14, 8, 2, 2]], [[14, 7, 2, 2]]] },
180 machamp: { idle: [[[9, 9, 2, 2], [16, 9, 2, 2]], [[9, 9, 2, 2], [16, 9, 2, 2]]], walk: [[[15, 11, 2, 2]], [[15, 10, 2, 2]]] },
181 machoke: { idle: [[[7, 8, 3, 3], [13, 8, 3, 3]], [[7, 7, 3, 3], [13, 7, 3, 3]]], walk: [[[13, 9, 3, 2]], [[13, 9, 3, 2]]] },
182 machop: { idle: [[[3, 7, 2, 3], [8, 7, 2, 3]], [[3, 6, 2, 3], [8, 6, 2, 3]]], walk: [[[8, 8, 2, 2]], [[8, 8, 2, 2]]] },
183 magikarp: { idle: [[[6, 9, 4, 3]], [[6, 9, 4, 3]]], walk: [[[11, 4, 3, 3]], [[11, 3, 3, 3]]] },
184 magmar: { idle: [[[8, 12, 3, 2], [14, 12, 3, 2]], [[8, 11, 3, 2], [14, 11, 3, 2]]], walk: [[[14, 12, 3, 2]], [[14, 11, 3, 2]]] },
185 magnemite: { idle: [[[6, 7, 5, 3]], [[6, 6, 5, 3]]], walk: [[[9, 7, 3, 3]], [[9, 6, 3, 3]]] },
186 magneton: { idle: [[[11, 7, 5, 3], [6, 15, 5, 3], [16, 15, 5, 3]], [[11, 7, 5, 3], [6, 15, 5, 3], [16, 15, 5, 3]]], walk: [[[16, 6, 3, 3], [12, 14, 3, 3], [16, 13, 3, 3]], [[16, 6, 3, 3], [12, 14, 3, 3], [16, 13, 3, 3]]] },
187 mankey: { idle: [[[8, 8, 3, 3], [14, 8, 3, 3]], [[8, 8, 3, 3], [14, 8, 3, 3]]], walk: [[[16, 7, 3, 3]], [[16, 7, 3, 3]]] },
188 marowak: { idle: [[[6, 10, 2, 3], [13, 10, 2, 3]], [[6, 9, 2, 3], [13, 9, 2, 3]]], walk: [[[13, 10, 2, 2]], [[13, 9, 2, 2]]] },
189 meowth: { idle: [[[9, 17, 2, 2], [13, 17, 2, 2]], [[9, 17, 2, 2], [13, 17, 2, 2]]], walk: [[[18, 11, 2, 2]], [[18, 10, 2, 2]]] },
190 metapod: { idle: [[[3, 8, 2, 2], [10, 8, 2, 2]], [[3, 9, 2, 2], [10, 9, 2, 2]]], walk: [[[10, 10, 2, 2]], [[10, 10, 2, 2]]] },
191 mew: { idle: [[[5, 11, 3, 3], [12, 11, 3, 3]], [[5, 10, 3, 3], [12, 10, 3, 3]]], walk: [[[15, 12, 3, 3]], [[15, 11, 3, 3]]] },
192 mewtwo: { idle: [[[8, 11, 3, 3], [14, 11, 3, 3]], [[8, 10, 3, 3], [14, 10, 3, 3]]], walk: [[[15, 12, 3, 2]], [[15, 11, 3, 2]]] },
193 moltres: { idle: [[[11, 13, 2, 2], [16, 13, 2, 2]], [[11, 11, 2, 2], [16, 11, 2, 2]]], walk: [[[18, 12, 2, 2]], [[18, 11, 2, 2]]] },
194 mrmime: { idle: [[[9, 8, 2, 3], [13, 8, 2, 3]], [[9, 7, 2, 3], [13, 7, 2, 3]]], walk: [[[15, 9, 2, 2]], [[15, 8, 2, 2]]] },
195 muk: { idle: [[[6, 8, 3, 1], [14, 8, 3, 1]], [[6, 9, 3, 1], [14, 9, 3, 1]]], walk: [[[17, 8, 3, 1]], [[17, 9, 3, 1]]] },
196 nidoking: { idle: [[[9, 9, 2, 3], [16, 9, 2, 3]], [[9, 8, 2, 3], [16, 8, 2, 3]]], walk: [[[15, 10, 2, 3]], [[15, 9, 2, 3]]] },
197 nidoqueen: { idle: [[[9, 10, 2, 2], [16, 10, 2, 2]], [[9, 9, 2, 2], [16, 9, 2, 2]]], walk: [[[15, 10, 2, 3]], [[15, 9, 2, 3]]] },
198 nidoran_female: { idle: [[[3, 8, 2, 3], [9, 8, 2, 3]], [[3, 7, 2, 3], [9, 7, 2, 3]]], walk: [[[10, 9, 2, 2]], [[10, 8, 2, 2]]] },
199 nidoran_male: { idle: [[[4, 9, 2, 3], [10, 9, 2, 3]], [[4, 8, 2, 3], [10, 8, 2, 3]]], walk: [[[10, 10, 2, 3]], [[10, 9, 2, 3]]] },
200 nidorina: { idle: [[[7, 11, 2, 3], [14, 11, 2, 3]], [[7, 10, 2, 3], [14, 10, 2, 3]]], walk: [[[15, 11, 3, 2]], [[15, 10, 3, 2]]] },
201 nidorino: { idle: [[[8, 11, 2, 3], [15, 11, 2, 3]], [[8, 10, 2, 3], [15, 10, 2, 3]]], walk: [[[13, 12, 2, 3]], [[13, 11, 2, 3]]] },
202 ninetales: { idle: [[[11, 12, 2, 3], [16, 12, 2, 3]], [[11, 12, 2, 3], [16, 12, 2, 3]]], walk: [[[20, 12, 3, 3]], [[20, 11, 3, 3]]] },
203 oddish: { idle: [[[4, 9, 2, 1], [9, 9, 2, 1]], [[4, 9, 2, 1], [9, 9, 2, 1]]], walk: [[[9, 9, 2, 1]], [[9, 9, 2, 1]]] },
204 omanyte: { idle: [[[4, 12, 3, 3], [10, 12, 3, 3]], [[4, 11, 3, 3], [10, 11, 3, 3]]], walk: [[[11, 12, 2, 3]], [[11, 12, 2, 3]]] },
205 omastar: { idle: [[[7, 15, 3, 3], [13, 15, 3, 3]], [[7, 15, 3, 3], [13, 15, 3, 3]]], walk: [[[16, 14, 3, 3]], [[16, 14, 3, 3]]] },
206 onix: { idle: [[[7, 12, 3, 2], [13, 12, 3, 2]], [[7, 11, 3, 2], [13, 11, 3, 2]]], walk: [[[19, 12, 2, 2]], [[17, 12, 2, 2]]] },
207 paras: { idle: [[[4, 8, 4, 4], [11, 8, 4, 4]], [[4, 8, 4, 4], [11, 8, 4, 4]]], walk: [[[12, 9, 4, 4]], [[12, 8, 4, 4]]] },
208 parasect: { idle: [[[8, 16, 2, 2], [13, 16, 2, 2]], [[8, 16, 2, 2], [13, 16, 2, 2]]], walk: [[[19, 16, 2, 2]], [[19, 15, 2, 2]]] },
209 persian: { idle: [[[11, 18, 2, 3], [16, 18, 2, 3]], [[11, 18, 2, 3], [16, 18, 2, 3]]], walk: [[[21, 17, 2, 3]], [[21, 17, 2, 3]]] },
210 pidgeot: { idle: [[[10, 15, 2, 2], [18, 15, 2, 2]], [[10, 14, 2, 2], [18, 14, 2, 2]]], walk: [[[21, 14, 2, 2]], [[21, 13, 2, 2]]] },
211 pidgeotto: { idle: [[[10, 12, 2, 3], [15, 12, 2, 3]], [[10, 11, 2, 3], [15, 11, 2, 3]]], walk: [[[18, 11, 2, 2]], [[18, 10, 2, 2]]] },
212 pidgey: { idle: [[[4, 7, 2, 2], [9, 7, 2, 2]], [[4, 6, 2, 2], [9, 6, 2, 2]]], walk: [[[8, 7, 2, 2]], [[8, 6, 2, 2]]] },
213 pikachu: { idle: [[[5, 9, 2, 2], [11, 9, 2, 2]], [[5, 8, 2, 2], [11, 8, 2, 2]]], walk: [[[13, 10, 2, 2]], [[13, 9, 2, 2]]] },
214 pikachu_female: { idle: [[[5, 9, 2, 2], [11, 9, 2, 2]], [[5, 8, 2, 2], [11, 8, 2, 2]]], walk: [[[13, 10, 2, 2]], [[13, 9, 2, 2]]] },
215 pinsir: { idle: [[[8, 10, 2, 2], [15, 10, 2, 2]], [[8, 9, 2, 2], [15, 9, 2, 2]]], walk: [[[15, 11, 2, 2]], [[15, 10, 2, 2]]] },
216 poliwag: { idle: [[[7, 5, 3, 4], [14, 5, 3, 4]], [[7, 5, 3, 4], [14, 5, 3, 4]]], walk: [[[12, 5, 3, 4]], [[12, 5, 3, 4]]] },
217 poliwhirl: { idle: [[[5, 5, 3, 4], [14, 5, 3, 4]], [[5, 5, 3, 4], [14, 5, 3, 4]]], walk: [[[11, 4, 3, 4]], [[11, 4, 3, 4]]] },
218 poliwrath: { idle: [[[7, 7, 4, 3], [16, 7, 4, 3]], [[7, 6, 4, 3], [16, 6, 4, 3]]], walk: [[[13, 8, 4, 3]], [[13, 8, 4, 3]]] },
219 ponyta: { idle: [[[11, 10, 2, 2], [14, 10, 2, 2]], [[11, 10, 2, 2], [14, 10, 2, 2]]], walk: [[[17, 10, 2, 2]], [[17, 9, 2, 2]]] },
220 porygon: { idle: [[[8, 4, 2, 3], [13, 4, 2, 3]], [[8, 5, 2, 3], [13, 5, 2, 3]]], walk: [[[13, 6, 3, 3]], [[12, 6, 3, 3]]] },
221 primeape: { idle: [[[7, 11, 3, 3], [15, 11, 3, 3]], [[7, 11, 3, 3], [15, 11, 3, 3]]], walk: [[[16, 11, 2, 3]], [[16, 11, 2, 3]]] },
222 psyduck: { idle: [[[3, 5, 2, 2], [10, 5, 2, 2]], [[3, 5, 2, 2], [10, 5, 2, 2]]], walk: [[[8, 7, 2, 2]], [[8, 7, 2, 2]]] },
223 raichu: { idle: [[[12, 12, 2, 2], [17, 12, 2, 2]], [[12, 11, 2, 2], [17, 11, 2, 2]]], walk: [[[21, 12, 2, 2]], [[21, 11, 2, 2]]] },
224 rapidash: { idle: [[[11, 13, 2, 3], [16, 13, 2, 3]], [[11, 13, 2, 3], [16, 13, 2, 3]]], walk: [[[21, 14, 2, 2]], [[21, 13, 2, 2]]] },
225 raticate: { idle: [[[10, 15, 2, 2], [16, 15, 2, 2]], [[10, 15, 2, 2], [16, 15, 2, 2]]], walk: [[[17, 15, 2, 2]], [[17, 14, 2, 2]]] },
226 rattata: { idle: [[[6, 14, 2, 2], [10, 14, 2, 2]], [[6, 14, 2, 2], [10, 14, 2, 2]]], walk: [[[14, 14, 2, 2]], [[14, 13, 2, 2]]] },
227 rhydon: { idle: [[[9, 9, 2, 3], [18, 9, 2, 3]], [[9, 8, 2, 3], [18, 8, 2, 3]]], walk: [[[18, 10, 2, 3]], [[18, 9, 2, 3]]] },
228 rhyhorn: { idle: [[[10, 15, 2, 2], [17, 15, 2, 2]], [[10, 14, 2, 2], [17, 14, 2, 2]]], walk: [[[17, 17, 2, 1]], [[17, 16, 2, 1]]] },
229 sandshrew: { idle: [[[5, 7, 2, 3], [11, 7, 2, 3]], [[5, 6, 2, 3], [11, 6, 2, 3]]], walk: [[[11, 7, 3, 3]], [[11, 6, 3, 3]]] },
230 sandslash: { idle: [[[7, 8, 2, 3], [12, 8, 2, 3]], [[7, 8, 2, 3], [12, 8, 2, 3]]], walk: [[[15, 9, 2, 3]], [[15, 9, 2, 3]]] },
231 scyther: { idle: [[[8, 10, 2, 2], [13, 10, 2, 2]], [[8, 9, 2, 2], [13, 9, 2, 2]]], walk: [[[15, 11, 2, 2]], [[15, 10, 2, 2]]] },
232 seadra: { idle: [[[8, 10, 2, 2], [15, 10, 2, 2]], [[8, 9, 2, 2], [15, 9, 2, 2]]], walk: [[[10, 10, 2, 2]], [[10, 9, 2, 2]]] },
233 seaking: { idle: [[[9, 19, 2, 3], [18, 19, 2, 3]], [[9, 18, 2, 3], [18, 18, 2, 3]]], walk: [[[17, 18, 4, 3]], [[17, 17, 4, 3]]] },
234 seel: { idle: [[[10, 9, 2, 2], [13, 9, 2, 2]], [[10, 9, 2, 2], [13, 9, 2, 2]]], walk: [[], []] },
235 shellder: { idle: [[[2, 9, 3, 3], [8, 9, 3, 3]], [[2, 10, 3, 2], [8, 10, 3, 2]]], walk: [[[8, 9, 3, 3]], [[8, 10, 3, 2]]] },
236 slowbro: { idle: [[[9, 7, 3, 4], [16, 7, 3, 4]], [[9, 6, 3, 4], [16, 6, 3, 4]]], walk: [[[16, 7, 4, 4]], [[16, 6, 4, 4]]] },
237 slowpoke: { idle: [[[7, 14, 3, 3], [13, 14, 3, 3]], [[7, 13, 3, 3], [13, 13, 3, 3]]], walk: [[[18, 15, 2, 3], [20, 16, 1, 2]], [[18, 14, 2, 3], [20, 15, 1, 2]]] },
238 snorlax: { idle: [[], []], walk: [[], []] },
239 spearow: { idle: [[[9, 7, 2, 2], [14, 7, 2, 2]], [[9, 6, 2, 2], [14, 6, 2, 2]]], walk: [[[14, 9, 2, 1]], [[14, 9, 2, 1]]] },
240 squirtle: { idle: [[[5, 6, 2, 3], [10, 6, 2, 3]], [[5, 5, 2, 3], [10, 5, 2, 3]]], walk: [[[11, 6, 2, 2]], [[11, 5, 2, 2]]] },
241 starmie: { idle: [[], []], walk: [[], []] },
242 staryu: { idle: [[], []], walk: [[], []] },
243 tangela: { idle: [[[5, 13, 3, 3], [10, 13, 3, 3]], [[5, 12, 3, 3], [10, 12, 3, 3]]], walk: [[[12, 13, 2, 3]], [[12, 12, 2, 3]]] },
244 tauros: { idle: [[[11, 19, 2, 2], [16, 19, 2, 2]], [[11, 18, 2, 2], [16, 18, 2, 2]]], walk: [[[22, 20, 2, 1]], [[22, 19, 2, 1]]] },
245 tentacool: { idle: [[[4, 11, 3, 2], [10, 11, 3, 2]], [[4, 10, 3, 2], [10, 10, 3, 2]]], walk: [[[11, 11, 3, 2]], [[11, 10, 3, 2]]] },
246 tentacruel: { idle: [[[6, 14, 2, 1], [15, 14, 2, 1]], [[6, 13, 2, 1], [15, 13, 2, 1]]], walk: [[[14, 15, 2, 1], [16, 14, 2, 1]], [[14, 14, 2, 1], [16, 13, 2, 1]]] },
247 vaporeon: { idle: [[[10, 18, 2, 3], [17, 18, 2, 3]], [[10, 17, 2, 3], [17, 17, 2, 3]]], walk: [[[21, 17, 3, 3]], [[21, 16, 3, 3]]] },
248 venomoth: { idle: [[[9, 12, 4, 4], [16, 12, 4, 4]], [[9, 11, 4, 4], [16, 11, 4, 4]]], walk: [[[19, 13, 4, 4]], [[19, 12, 4, 4]]] },
249 venonat: { idle: [[[4, 10, 3, 3], [12, 10, 3, 3]], [[4, 10, 3, 3], [12, 10, 3, 3]]], walk: [[[9, 11, 3, 3]], [[9, 10, 3, 3]]] },
250 venusaur: { idle: [[[9, 19, 2, 2], [20, 19, 2, 2]], [[9, 18, 2, 2], [20, 18, 2, 2]]], walk: [[[18, 18, 2, 2]], [[18, 17, 2, 2]]] },
251 venusaur_female: { idle: [[[9, 19, 2, 2], [20, 19, 2, 2]], [[9, 18, 2, 2], [20, 18, 2, 2]]], walk: [[[18, 18, 2, 2]], [[18, 17, 2, 2]]] },
252 victreebel: { idle: [[[10, 14, 3, 2], [16, 14, 3, 2]], [[10, 14, 3, 2], [16, 14, 3, 2]]], walk: [[[18, 14, 3, 2]], [[17, 14, 3, 2]]] },
253 vileplume: { idle: [[[7, 14, 2, 1], [14, 14, 2, 1]], [[7, 13, 2, 1], [14, 13, 2, 1]]], walk: [[[12, 13, 2, 1]], [[12, 12, 2, 1]]] },
254 voltorb: { idle: [[[3, 9, 3, 2], [9, 9, 3, 2]], [[3, 8, 3, 2], [9, 8, 3, 2]]], walk: [[[9, 9, 3, 2]], [[9, 8, 3, 2]]] },
255 vulpix: { idle: [[[9, 10, 2, 3], [14, 10, 2, 3]], [[9, 10, 2, 3], [14, 10, 2, 3]]], walk: [[[18, 10, 3, 3]], [[18, 9, 3, 3]]] },
256 wartortle: { idle: [[[6, 10, 2, 2], [13, 10, 2, 2]], [[6, 9, 2, 2], [13, 9, 2, 2]]], walk: [[[13, 9, 2, 2]], [[13, 8, 2, 2]]] },
257 weedle: { idle: [[[4, 7, 2, 1], [9, 7, 2, 1]], [[4, 8, 2, 1], [9, 8, 2, 1]]], walk: [[[10, 8, 2, 1]], [[9, 8, 2, 1]]] },
258 weepinbell: { idle: [[[5, 11, 4, 3], [12, 11, 4, 3]], [[5, 10, 4, 3], [12, 10, 4, 3]]], walk: [[[11, 12, 4, 3]], [[10, 12, 4, 3]]] },
259 weezing: { idle: [[[6, 18, 4, 2], [14, 18, 4, 2], [19, 12, 2, 2], [24, 12, 2, 2]], [[6, 17, 4, 2], [14, 17, 4, 2], [19, 11, 2, 2], [24, 11, 2, 2]]], walk: [[[15, 18, 3, 2]], [[15, 17, 3, 2]]] },
260 wigglytuff: { idle: [[[4, 9, 4, 4], [11, 9, 4, 4]], [[4, 9, 4, 4], [11, 9, 4, 4]]], walk: [[[10, 9, 4, 4]], [[10, 9, 4, 4]]] },
261 zapdos: { idle: [[[10, 9, 2, 2], [17, 9, 2, 2]], [[10, 8, 2, 2], [17, 8, 2, 2]]], walk: [[[15, 10, 2, 2]], [[15, 9, 2, 2]]] },
262 zubat: { idle: [[], []], walk: [[], []] },
263}
264
265// The eye boxes for one frame of a mon, or null when it has none marked
266export function eyesOf(mon, variant, view, frame) {
267 const own = EYES[mon]?.[variant] ?? EYES[mon]
268 return own?.[view]?.[frame] ?? null
269}
270
271// Eyes drawn as lines, shut already, so EYES leaves them out and sleep leaves them be. A
272// shrunk band keeps them all the same. Boxes as in EYES, the same for both variants.
273const LINE_EYES = {
274 abra: { idle: [[[7, 8, 2, 2], [12, 8, 2, 2]], [[7, 7, 2, 2], [12, 7, 2, 2]]], walk: [[[13, 8, 3, 2]], [[13, 7, 3, 2]]] },
275 gloom: { idle: [[[3, 14, 4, 1], [11, 14, 4, 1]], [[3, 14, 4, 1], [11, 14, 4, 1]]], walk: [[[11, 13, 2, 1]], [[11, 13, 2, 1]]] },
276 snorlax: { idle: [[[9, 11, 3, 1], [15, 11, 3, 1]], [[9, 10, 3, 1], [15, 10, 3, 1]]], walk: [[[16, 10, 2, 2]], [[16, 9, 2, 2]]] },
277}
278
279// The eyes a shrunk band keeps on a frame: the open ones, or else the lines
280export function keptEyesOf(mon, variant, view, frame) {
281 const open = eyesOf(mon, variant, view, frame)
282 if (open?.length) return { boxes: open, lines: false }
283 return { boxes: LINE_EYES[mon]?.[view]?.[frame] ?? null, lines: true }
284}
285hooks/levels.js 30 lines1// Levels follow the medium fast curve from the games, where reaching level L takes
2// L ** 3 XP in total.
3
4export const MAX_LEVEL = 100
5
6const TURN_XP = 10
7const TURN_XP_PER_LEVEL = 2
8const LONG_TURN_MS = 2 * 60 * 1000
9
10const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))
11
12export const xpAt = (level) => level ** 3
13
14// The level counts up through whole cubes, so a perfect cube lands exactly on its level
15export function levelAt(xp) {
16 let level = 1
17 while (level < MAX_LEVEL && xpAt(level + 1) <= xp) level++
18 return level
19}
20
21// Care multiplies XP by 0.5 when both meters (0 to 100) are empty and 1.5 when both are full
22export const careOf = (food, happiness) => 0.5 + (food + happiness) / 200
23
24// A turn earns more XP at a higher level and with more care. Its length adds up to
25// double the XP, reached at LONG_TURN_MS.
26export function turnXp({ level, durationMs, care }) {
27 const length = 1 + clamp(durationMs / LONG_TURN_MS, 0, 1)
28 return Math.max(1, Math.round((TURN_XP + TURN_XP_PER_LEVEL * level) * length * care))
29}
30hooks/moves.js 313 lines1// Movesets for /pokemon attack, and the animation each move plays.
2
3// Every move's animation: the effect it plays from hooks/effects/, and optionally a
4// color, a power (1 or 2, for moves that share an effect at two sizes), and a line
5// to print after "<Mon> used <Move>!"
6export const MOVE_FX = {
7 'Absorb': { effect: 'drain' },
8 'Acid': { effect: 'acid' },
9 'Acid Armor': { effect: 'acidarmor' },
10 'Agility': { effect: 'agility' },
11 'Amnesia': { effect: 'amnesia' },
12 'Aurora Beam': { effect: 'aurorabeam' },
13 'Barrage': { effect: 'eggbomb', power: 1 },
14 'Barrier': { effect: 'barrier' },
15 'Bind': { effect: 'wrap' },
16 'Bite': { effect: 'bite' },
17 'Blizzard': { effect: 'blizzard' },
18 'Body Slam': { effect: 'bodyslam' },
19 'Bone Club': { effect: 'boneclub' },
20 'Bonemerang': { effect: 'bonemerang' },
21 'Bubble': { effect: 'bubble' },
22 'Bubble Beam': { effect: 'bubblebeam' },
23 'Clamp': { effect: 'clamp' },
24 'Comet Punch': { effect: 'cometpunch' },
25 'Confuse Ray': { effect: 'confuseray' },
26 'Confusion': { effect: 'confusion', color: 0xd88aff },
27 'Crabhammer': { effect: 'crabhammer' },
28 'Cut': { effect: 'cut' },
29 'Defense Curl': { effect: 'defensecurl' },
30 'Dig': { effect: 'dig' },
31 'Double Kick': { effect: 'doublekick' },
32 'Double Slap': { effect: 'doubleslap' },
33 'Double Team': { effect: 'doubleteam' },
34 'Dragon Rage': { effect: 'dragonrage' },
35 'Dream Eater': { effect: 'dreameater' },
36 'Drill Peck': { effect: 'drillpeck' },
37 'Earthquake': { effect: 'earthquake' },
38 'Egg Bomb': { effect: 'eggbomb', power: 2 },
39 'Ember': { effect: 'ember' },
40 'Explosion': { effect: 'explosion', power: 2 },
41 'Fire Punch': { effect: 'firepunch' },
42 'Fire Spin': { effect: 'firespin' },
43 'Flamethrower': { effect: 'flamethrower' },
44 'Fly': { effect: 'fly' },
45 'Focus Energy': { effect: 'focusenergy' },
46 'Fury Attack': { effect: 'furyattack' },
47 'Fury Swipes': { effect: 'furyswipes' },
48 'Growl': { effect: 'growl' },
49 'Guillotine': { effect: 'guillotine' },
50 'Gust': { effect: 'gust' },
51 'Harden': { effect: 'harden' },
52 'Headbutt': { effect: 'headbutt' },
53 'Hi Jump Kick': { effect: 'hijumpkick' },
54 'Horn Attack': { effect: 'hornattack' },
55 'Horn Drill': { effect: 'horndrill' },
56 'Hydro Pump': { effect: 'hydropump' },
57 'Hyper Beam': { effect: 'hyperbeam' },
58 'Hyper Fang': { effect: 'hyperfang' },
59 'Hypnosis': { effect: 'hypnosis' },
60 'Ice Beam': { effect: 'icebeam' },
61 'Ice Punch': { effect: 'icepunch' },
62 'Karate Chop': { effect: 'karatechop' },
63 'Kinesis': { effect: 'kinesis' },
64 'Leech Life': { effect: 'leechlife' },
65 'Leech Seed': { effect: 'leechseed' },
66 'Lick': { effect: 'lick' },
67 'Light Screen': { effect: 'lightscreen' },
68 'Lovely Kiss': { effect: 'lovelykiss' },
69 'Low Kick': { effect: 'lowkick' },
70 'Meditate': { effect: 'meditate' },
71 'Mega Drain': { effect: 'drain', power: 2 },
72 'Mega Kick': { effect: 'megakick' },
73 'Mega Punch': { effect: 'megapunch' },
74 'Minimize': { effect: 'minimize' },
75 'Mist': { effect: 'mist' },
76 'Night Shade': { effect: 'nightshade' },
77 'Outrage': { effect: 'outrage' },
78 'Pay Day': { effect: 'payday', text: 'Coins scattered everywhere!' },
79 'Peck': { effect: 'peck' },
80 'Petal Dance': { effect: 'petaldance' },
81 'Pin Missile': { effect: 'pinmissile' },
82 'Poison Gas': { effect: 'gas', color: 0x9a6ac8 },
83 'Poison Powder': { effect: 'powder', color: 0xb45ad8 },
84 'Poison Sting': { effect: 'poisonsting' },
85 'Pound': { effect: 'pound' },
86 'Psybeam': { effect: 'psybeam' },
87 'Psychic': { effect: 'psychic' },
88 'Quick Attack': { effect: 'quickattack' },
89 'Rage': { effect: 'rage' },
90 'Razor Leaf': { effect: 'razorleaf' },
91 'Recover': { effect: 'recover' },
92 'Reflect': { effect: 'reflect' },
93 'Rest': { effect: 'rest', text: 'It started sleeping!' },
94 'Roar': { effect: 'roar' },
95 'Rock Slide': { effect: 'rockslide' },
96 'Rock Throw': { effect: 'rockthrow' },
97 'Sand Attack': { effect: 'sandattack' },
98 'Scratch': { effect: 'scratch' },
99 'Screech': { effect: 'screech' },
100 'Seismic Toss': { effect: 'seismictoss' },
101 'Self-Destruct': { effect: 'explosion', power: 1 },
102 'Sing': { effect: 'sing' },
103 'Skull Bash': { effect: 'skullbash' },
104 'Sky Attack': { effect: 'skyattack' },
105 'Slam': { effect: 'slam' },
106 'Slash': { effect: 'slash' },
107 'Sleep Powder': { effect: 'powder', color: 0x8fd0ff },
108 'Sludge': { effect: 'sludge', color: 0x9a5ac8 },
109 'Smog': { effect: 'gas', color: 0x8a7a6a },
110 'Smokescreen': { effect: 'gas', color: 0x55555f, power: 2 },
111 'Soft-Boiled': { effect: 'softboiled' },
112 'Solar Beam': { effect: 'solarbeam' },
113 'Spike Cannon': { effect: 'spikecannon' },
114 'Splash': { effect: 'splash', text: 'But nothing happened!' },
115 'Spore': { effect: 'powder', color: 0xd8b46a },
116 'Stomp': { effect: 'stomp' },
117 'String Shot': { effect: 'stringshot' },
118 'Stun Spore': { effect: 'powder', color: 0xf2d22e },
119 'Submission': { effect: 'submission' },
120 'Supersonic': { effect: 'supersonic' },
121 'Surf': { effect: 'surf' },
122 'Swift': { effect: 'swift' },
123 'Tackle': { effect: 'tackle' },
124 'Take Down': { effect: 'takedown' },
125 'Teleport': { effect: 'teleport' },
126 'Thrash': { effect: 'thrash' },
127 'Thunder': { effect: 'thunder' },
128 'Thunder Punch': { effect: 'thunderpunch' },
129 'Thunder Shock': { effect: 'thundershock' },
130 'Thunder Wave': { effect: 'thunderwave' },
131 'Thunderbolt': { effect: 'thunderbolt' },
132 'Transform': { effect: 'transform' },
133 'Tri Attack': { effect: 'triattack' },
134 'Twineedle': { effect: 'twineedle' },
135 'Vice Grip': { effect: 'vicegrip' },
136 'Vine Whip': { effect: 'vinewhip' },
137 'Water Gun': { effect: 'watergun' },
138 'Waterfall': { effect: 'waterfall' },
139 'Wing Attack': { effect: 'wingattack' },
140 'Withdraw': { effect: 'withdraw' },
141 'Wrap': { effect: 'wrap' },
142}
143
144// Each mon's moveset: move names, or { name, ...options } for options only that mon
145// uses: from: 'top' for the moves the Bulbasaur line sends out of its bulb,
146// from: 'body' for the smog Koffing and Weezing pour from their whole body, and
147// mouth: for how far down the sprite a move leaves the mouth, when 0.35 misses it
148export const MOVES = {
149 abra: ['Teleport', 'Confusion'],
150 aerodactyl: ['Wing Attack', 'Bite', 'Hyper Beam', 'Supersonic'],
151 alakazam: ['Psychic', 'Kinesis', 'Recover', 'Reflect'],
152 arbok: ['Wrap', 'Poison Sting', 'Bite', 'Acid'],
153 arcanine: ['Flamethrower', 'Take Down', 'Bite', 'Agility'],
154 articuno: ['Ice Beam', 'Blizzard', 'Agility', 'Mist'],
155 beedrill: ['Twineedle', 'Fury Attack', 'Agility'],
156 bellsprout: ['Vine Whip', 'Sleep Powder', 'Acid'],
157 blastoise: ['Hydro Pump', 'Bite', 'Withdraw', 'Skull Bash'],
158 bulbasaur: [{ name: 'Vine Whip', from: 'top' }, 'Razor Leaf', 'Leech Seed', { name: 'Sleep Powder', from: 'top' }],
159 butterfree: ['Confusion', 'Sleep Powder', 'Stun Spore', 'Gust'],
160 caterpie: ['String Shot', 'Tackle'],
161 chansey: ['Soft-Boiled', 'Sing', 'Double Slap', 'Minimize'],
162 charizard: ['Flamethrower', 'Slash', 'Wing Attack', 'Seismic Toss'],
163 charmander: ['Ember', 'Scratch', 'Growl', 'Smokescreen'],
164 charmeleon: ['Flamethrower', 'Slash', 'Rage', 'Smokescreen'],
165 clefable: ['Sing', 'Double Slap', 'Minimize'],
166 clefairy: ['Sing', 'Pound', 'Minimize'],
167 cloyster: ['Clamp', 'Spike Cannon', 'Aurora Beam', 'Withdraw'],
168 cubone: ['Bone Club', 'Headbutt', 'Growl'],
169 dewgong: ['Aurora Beam', 'Headbutt', 'Rest'],
170 diglett: ['Dig', 'Scratch', 'Earthquake', 'Sand Attack'],
171 ditto: ['Transform'],
172 dodrio: ['Drill Peck', 'Tri Attack', 'Agility'],
173 doduo: ['Peck', 'Agility', 'Growl'],
174 dragonair: ['Dragon Rage', 'Wrap', 'Thunder Wave', 'Agility'],
175 dragonite: ['Hyper Beam', 'Outrage', 'Wing Attack', 'Thunder Punch'],
176 dratini: ['Wrap', 'Thunder Wave', 'Dragon Rage'],
177 drowzee: ['Hypnosis', 'Confusion', 'Dream Eater', 'Headbutt'],
178 dugtrio: ['Dig', 'Slash', 'Earthquake', 'Sand Attack'],
179 eevee: ['Quick Attack', 'Sand Attack', 'Bite', 'Growl'],
180 ekans: ['Wrap', 'Poison Sting', 'Bite'],
181 electabuzz: ['Thunder Punch', 'Thunderbolt', 'Light Screen', 'Quick Attack'],
182 electrode: ['Explosion', 'Screech', 'Swift', 'Thunder Shock'],
183 exeggcute: ['Hypnosis', 'Leech Seed', 'Barrage'],
184 exeggutor: ['Solar Beam', 'Stomp', 'Hypnosis', 'Egg Bomb'],
185 farfetchd: ['Cut', 'Fury Attack', 'Agility'],
186 fearow: ['Drill Peck', 'Agility', 'Fly'],
187 flareon: ['Flamethrower', 'Quick Attack', 'Bite', 'Smog'],
188 gastly: [{ name: 'Lick', mouth: 0.65 }, 'Night Shade', 'Hypnosis', 'Confuse Ray'],
189 gengar: ['Night Shade', 'Hypnosis', { name: 'Lick', mouth: 0.55 }, 'Dream Eater'],
190 geodude: ['Rock Throw', 'Tackle', 'Defense Curl', 'Self-Destruct'],
191 gloom: ['Absorb', 'Poison Powder', 'Acid', 'Petal Dance'],
192 golbat: ['Leech Life', 'Wing Attack', 'Supersonic', 'Bite'],
193 goldeen: ['Horn Attack', 'Supersonic', 'Waterfall'],
194 golduck: ['Confusion', 'Hydro Pump', 'Fury Swipes', 'Amnesia'],
195 golem: ['Rock Slide', 'Earthquake', 'Explosion', 'Defense Curl'],
196 graveler: ['Rock Throw', 'Earthquake', 'Self-Destruct', 'Defense Curl'],
197 grimer: ['Pound', 'Poison Gas', 'Sludge', 'Minimize'],
198 growlithe: ['Ember', 'Bite', 'Roar', 'Take Down'],
199 gyarados: ['Hyper Beam', 'Dragon Rage', 'Hydro Pump', 'Bite'],
200 haunter: [{ name: 'Lick', mouth: 0.45 }, 'Night Shade', 'Hypnosis', 'Confuse Ray'],
201 hitmonchan: ['Fire Punch', 'Ice Punch', 'Thunder Punch', 'Agility'],
202 hitmonlee: ['Hi Jump Kick', 'Mega Kick', 'Meditate'],
203 horsea: ['Bubble', 'Smokescreen', 'Water Gun'],
204 hypno: ['Hypnosis', 'Psychic', 'Dream Eater', 'Headbutt'],
205 ivysaur: ['Razor Leaf', { name: 'Vine Whip', from: 'top' }, { name: 'Poison Powder', from: 'top' }, 'Leech Seed'],
206 jigglypuff: ['Sing', 'Pound', 'Rest', 'Defense Curl'],
207 jolteon: ['Thunderbolt', 'Pin Missile', 'Quick Attack', 'Agility'],
208 jynx: ['Lovely Kiss', 'Ice Punch', 'Blizzard', 'Psychic'],
209 kabuto: ['Scratch', 'Harden', 'Absorb'],
210 kabutops: ['Slash', 'Hydro Pump', 'Absorb', 'Harden'],
211 kadabra: ['Teleport', 'Kinesis', 'Psybeam', 'Recover'],
212 kakuna: ['Harden'],
213 kangaskhan: ['Comet Punch', 'Rage', 'Bite', 'Earthquake'],
214 kingler: ['Crabhammer', 'Guillotine', 'Bubble', 'Stomp'],
215 koffing: ['Tackle', { name: 'Smog', from: 'body' }, 'Sludge', 'Self-Destruct'],
216 krabby: ['Bubble', 'Vice Grip', 'Harden', 'Crabhammer'],
217 lapras: ['Surf', 'Ice Beam', 'Sing', 'Body Slam'],
218 lickitung: [{ name: 'Lick', mouth: 0.45 }, 'Wrap', 'Stomp', 'Supersonic'],
219 machamp: ['Karate Chop', 'Seismic Toss', 'Submission', 'Earthquake'],
220 machoke: ['Karate Chop', 'Seismic Toss', 'Focus Energy'],
221 machop: ['Karate Chop', 'Low Kick', 'Seismic Toss'],
222 magikarp: ['Splash', 'Tackle'],
223 magmar: ['Fire Punch', 'Flamethrower', 'Smog', 'Confuse Ray'],
224 magnemite: ['Thunder Shock', 'Supersonic', 'Swift'],
225 magneton: ['Thunderbolt', 'Supersonic', 'Swift'],
226 mankey: ['Karate Chop', 'Low Kick', 'Thrash', 'Screech'],
227 marowak: ['Bonemerang', 'Bone Club', 'Thrash'],
228 meowth: ['Pay Day', 'Fury Swipes', 'Bite', 'Screech'],
229 metapod: ['Harden'],
230 mew: ['Psychic', 'Transform', 'Mega Punch'],
231 mewtwo: ['Psychic', 'Barrier', 'Recover', 'Swift'],
232 moltres: ['Fire Spin', 'Sky Attack', 'Agility', 'Peck'],
233 mrmime: ['Barrier', 'Confusion', 'Double Slap'],
234 muk: ['Sludge', 'Poison Gas', 'Body Slam', 'Acid Armor'],
235 nidoking: ['Horn Attack', 'Poison Sting', 'Earthquake', 'Double Kick'],
236 nidoqueen: ['Body Slam', 'Poison Sting', 'Scratch', 'Earthquake'],
237 nidoran_female: ['Scratch', 'Poison Sting', 'Growl'],
238 nidoran_male: ['Horn Attack', 'Poison Sting', 'Double Kick'],
239 nidorina: ['Scratch', 'Bite', 'Poison Sting'],
240 nidorino: ['Horn Attack', 'Poison Sting', 'Double Kick'],
241 ninetales: ['Flamethrower', 'Confuse Ray', 'Quick Attack'],
242 oddish: ['Absorb', 'Sleep Powder', 'Acid'],
243 omanyte: ['Water Gun', 'Withdraw', 'Horn Attack'],
244 omastar: ['Hydro Pump', 'Spike Cannon', 'Withdraw', 'Horn Attack'],
245 onix: ['Rock Throw', 'Bind', 'Slam', 'Harden'],
246 paras: ['Scratch', 'Stun Spore', 'Leech Life'],
247 parasect: ['Spore', 'Slash', 'Leech Life'],
248 persian: ['Slash', 'Pay Day', 'Bite', 'Screech'],
249 pidgeot: ['Wing Attack', 'Quick Attack', 'Agility', 'Sand Attack'],
250 pidgeotto: ['Gust', 'Quick Attack', 'Sand Attack'],
251 pidgey: ['Gust', 'Sand Attack', 'Quick Attack'],
252 pikachu: ['Thunderbolt', 'Quick Attack', 'Thunder', 'Agility'],
253 pikachu_female: ['Thunderbolt', 'Quick Attack', 'Thunder', 'Agility'],
254 pinsir: ['Vice Grip', 'Seismic Toss', 'Guillotine', 'Harden'],
255 poliwag: ['Bubble', 'Hypnosis', 'Water Gun'],
256 poliwhirl: ['Water Gun', 'Hypnosis', 'Double Slap', 'Body Slam'],
257 poliwrath: ['Submission', 'Hydro Pump', 'Hypnosis', 'Body Slam'],
258 ponyta: ['Ember', 'Stomp', 'Agility'],
259 porygon: ['Psybeam', 'Tri Attack', 'Agility', 'Recover'],
260 primeape: ['Fury Swipes', 'Seismic Toss', 'Thrash', 'Screech'],
261 psyduck: ['Confusion', 'Scratch', 'Water Gun'],
262 raichu: ['Thunderbolt', 'Thunder Wave', 'Quick Attack', 'Swift'],
263 rapidash: ['Fire Spin', 'Stomp', 'Agility'],
264 raticate: ['Hyper Fang', 'Quick Attack', 'Focus Energy'],
265 rattata: ['Quick Attack', 'Hyper Fang'],
266 rhydon: ['Horn Drill', 'Earthquake', 'Rock Slide'],
267 rhyhorn: ['Horn Attack', 'Fury Attack', 'Rock Slide'],
268 sandshrew: ['Scratch', 'Sand Attack', 'Defense Curl'],
269 sandslash: ['Slash', 'Sand Attack', 'Poison Sting', 'Swift'],
270 scyther: ['Slash', 'Quick Attack', 'Double Team', 'Wing Attack'],
271 seadra: ['Hydro Pump', 'Smokescreen', 'Agility', 'Bubble Beam'],
272 seaking: ['Horn Drill', 'Waterfall', 'Supersonic', 'Agility'],
273 seel: ['Headbutt', 'Aurora Beam', 'Rest', 'Growl'],
274 shellder: ['Withdraw', 'Supersonic', 'Clamp', 'Aurora Beam'],
275 slowbro: ['Psychic', 'Withdraw', 'Water Gun', 'Headbutt'],
276 slowpoke: ['Confusion', 'Water Gun', 'Headbutt', 'Amnesia'],
277 snorlax: ['Rest', 'Body Slam', 'Amnesia', 'Hyper Beam'],
278 spearow: ['Peck', 'Growl', 'Agility'],
279 squirtle: ['Tackle', 'Bubble', 'Water Gun', 'Withdraw'],
280 starmie: ['Swift', 'Hydro Pump', 'Recover', 'Psychic'],
281 staryu: ['Water Gun', 'Swift', 'Recover', 'Harden'],
282 tangela: ['Vine Whip', 'Absorb', 'Stun Spore', 'Slam'],
283 tauros: ['Take Down', 'Earthquake', 'Hyper Beam'],
284 tentacool: ['Acid', 'Wrap', 'Supersonic'],
285 tentacruel: ['Hydro Pump', 'Wrap', 'Acid', 'Barrier'],
286 vaporeon: ['Hydro Pump', 'Aurora Beam', 'Quick Attack', 'Acid Armor'],
287 venomoth: ['Psybeam', 'Poison Powder', 'Leech Life', 'Supersonic'],
288 venonat: ['Tackle', 'Poison Powder', 'Leech Life', 'Confusion'],
289 venusaur: ['Solar Beam', 'Razor Leaf', { name: 'Vine Whip', from: 'top' }, { name: 'Sleep Powder', from: 'top' }],
290 venusaur_female: [
291 'Solar Beam', 'Razor Leaf', { name: 'Vine Whip', from: 'top' }, { name: 'Sleep Powder', from: 'top' }
292 ],
293 victreebel: ['Razor Leaf', 'Vine Whip', 'Acid', 'Stun Spore'],
294 vileplume: ['Petal Dance', 'Stun Spore', 'Acid', 'Mega Drain'],
295 voltorb: ['Tackle', 'Screech', 'Self-Destruct', 'Thunder Wave'],
296 vulpix: ['Ember', 'Quick Attack', 'Confuse Ray'],
297 wartortle: ['Water Gun', 'Bite', 'Withdraw', 'Skull Bash'],
298 weedle: ['Poison Sting', 'String Shot'],
299 weepinbell: ['Razor Leaf', 'Wrap', 'Acid', 'Sleep Powder'],
300 weezing: [{ name: 'Smog', from: 'body' }, 'Sludge', 'Explosion', 'Tackle'],
301 wigglytuff: ['Sing', 'Double Slap', 'Body Slam', 'Rest'],
302 zapdos: ['Thunder', 'Drill Peck', 'Agility', 'Light Screen'],
303 zubat: ['Leech Life', 'Supersonic', 'Wing Attack'],
304}
305
306// A mon's moves, each with its effect and options filled in
307export function movesOf(mon) {
308 return (MOVES[mon] ?? []).map((entry) => {
309 const own = typeof entry === 'string' ? { name: entry } : entry
310 return { ...MOVE_FX[own.name], ...own }
311 })
312}
313hooks/names.js 14 lines1// How a mon's key reads in messages: "nidoran_female" is "Nidoran♀", "abra" is "Abra"
2const DISPLAY_NAMES = {
3 nidoran_female: 'Nidoran♀',
4 nidoran_male: 'Nidoran♂',
5 mrmime: 'Mr. Mime',
6 farfetchd: "Farfetch'd",
7 pikachu_female: 'Pikachu',
8 venusaur_female: 'Venusaur',
9}
10
11export function displayName(mon) {
12 return DISPLAY_NAMES[mon] ?? mon.split('_').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
13}
14hooks/png.js 236 lines1// Encodes RGBA pixels as a PNG, base64. The runtime has no compression API, so this
2// carries its own deflate: LZ77 matches over fixed Huffman codes, which shrinks a
3// sprite frame's long runs and repeated rows to a few KB.
4
5const CRC_TABLE = new Uint32Array(256)
6for (let n = 0; n < 256; n++) {
7 let c = n
8 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
9 CRC_TABLE[n] = c >>> 0
10}
11
12function crc32(bytes, start, end) {
13 let c = 0xffffffff
14 for (let i = start; i < end; i++) c = CRC_TABLE[(c ^ bytes[i]) & 255] ^ (c >>> 8)
15 return (c ^ 0xffffffff) >>> 0
16}
17
18// The sums take their remainder once per block, short enough that b can't pass 2^31
19function adler32(bytes) {
20 let a = 1
21 let b = 0
22 for (let i = 0; i < bytes.length; ) {
23 const end = Math.min(bytes.length, i + 2048)
24 for (; i < end; i++) {
25 a += bytes[i]
26 b += a
27 }
28 a %= 65521
29 b %= 65521
30 }
31 return ((b << 16) | a) >>> 0
32}
33
34// Deflate's length and distance codes: the base value each starts at and its extra bits
35const LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
36const LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
37const DIST_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
38const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
39
40const WINDOW = 32768
41const MAX_MATCH = 258
42const MAX_CHAIN = 32
43
44// Match tables shared by every call. Only head needs clearing, since a call reads prev
45// only at positions it has inserted.
46const HEAD = new Int32Array(65536)
47const PREV = new Int32Array(WINDOW)
48
49class Bits {
50 constructor(size) {
51 this.out = new Uint8Array(size)
52 this.pos = 0
53 this.acc = 0
54 this.count = 0
55 }
56
57 // Extra bits and headers go least significant bit first
58 put(value, count) {
59 this.acc |= value << this.count
60 this.count += count
61 while (this.count >= 8) {
62 this.push(this.acc & 255)
63 this.acc >>>= 8
64 this.count -= 8
65 }
66 }
67
68 // Huffman codes go most significant bit first
69 code(value, count) {
70 let reversed = 0
71 for (let k = 0; k < count; k++) reversed |= ((value >> k) & 1) << (count - 1 - k)
72 this.put(reversed, count)
73 }
74
75 push(byte) {
76 if (this.pos === this.out.length) {
77 const grown = new Uint8Array(this.out.length * 2)
78 grown.set(this.out)
79 this.out = grown
80 }
81 this.out[this.pos++] = byte
82 }
83
84 finish() {
85 if (this.count > 0) this.push(this.acc & 255)
86 return this.out.subarray(0, this.pos)
87 }
88}
89
90function literal(bits, value) {
91 if (value < 144) bits.code(0x30 + value, 8)
92 else if (value < 256) bits.code(0x190 + value - 144, 9)
93 else if (value < 280) bits.code(value - 256, 7)
94 else bits.code(0xc0 + value - 280, 8)
95}
96
97function match(bits, length, distance) {
98 let l = 0
99 while (l < 28 && LENGTH_BASE[l + 1] <= length) l++
100 literal(bits, 257 + l)
101 bits.put(length - LENGTH_BASE[l], LENGTH_EXTRA[l])
102 let d = 0
103 while (d < 29 && DIST_BASE[d + 1] <= distance) d++
104 bits.code(d, 5)
105 bits.put(distance - DIST_BASE[d], DIST_EXTRA[d])
106}
107
108// zlib stream of one fixed Huffman block
109function deflate(data) {
110 const bits = new Bits(1024 + (data.length >> 2))
111 bits.push(0x78)
112 bits.push(0x01)
113 bits.put(1, 1)
114 bits.put(1, 2)
115 const head = HEAD.fill(-1)
116 const prev = PREV
117 const hash = (i) => ((data[i] << 16) ^ (data[i + 1] << 8) ^ data[i + 2]) * 2654435761 >>> 16
118 const insert = (i) => {
119 if (i + 2 >= data.length) return
120 const h = hash(i)
121 prev[i % WINDOW] = head[h]
122 head[h] = i
123 }
124 let i = 0
125 while (i < data.length) {
126 let bestLength = 0
127 let bestDistance = 0
128 if (i + 2 < data.length) {
129 const limit = Math.min(MAX_MATCH, data.length - i)
130 let candidate = head[hash(i)]
131 for (let chain = 0; candidate >= 0 && i - candidate <= WINDOW && chain < MAX_CHAIN; chain++) {
132 let length = 0
133 while (length < limit && data[candidate + length] === data[i + length]) length++
134 if (length > bestLength) {
135 bestLength = length
136 bestDistance = i - candidate
137 if (length === limit) break
138 }
139 candidate = prev[candidate % WINDOW]
140 }
141 }
142 if (bestLength >= 3) {
143 match(bits, bestLength, bestDistance)
144 for (let k = 0; k < bestLength; k++) insert(i + k)
145 i += bestLength
146 } else {
147 literal(bits, data[i])
148 insert(i)
149 i++
150 }
151 }
152 literal(bits, 256)
153 const body = bits.finish()
154 const sum = adler32(data)
155 const out = new Uint8Array(body.length + 4)
156 out.set(body)
157 out.set([sum >>> 24, (sum >>> 16) & 255, (sum >>> 8) & 255, sum & 255], body.length)
158 return out
159}
160
161function chunk(type, data) {
162 const out = new Uint8Array(12 + data.length)
163 const view = new DataView(out.buffer)
164 view.setUint32(0, data.length)
165 for (let k = 0; k < 4; k++) out[4 + k] = type.charCodeAt(k)
166 out.set(data, 8)
167 view.setUint32(8 + data.length, crc32(out, 4, 8 + data.length))
168 return out
169}
170
171// A frame of up to 256 colors goes out as palette indices, a quarter of its RGBA bytes
172export function encodePng(width, height, rgba) {
173 const colors = new Map()
174 const indices = new Uint8Array(width * height)
175 const words = new Uint32Array(rgba.buffer, rgba.byteOffset, width * height)
176 let paletted = true
177 // A run of one color looks it up once
178 let lastWord = -1
179 let index = 0
180 for (let p = 0; p < words.length; p++) {
181 const word = rgba[p * 4 + 3] === 0 ? 0 : words[p]
182 if (word === lastWord) {
183 indices[p] = index
184 continue
185 }
186 lastWord = word
187 index = colors.get(word)
188 if (index === undefined) {
189 if (colors.size === 256) {
190 paletted = false
191 break
192 }
193 index = colors.size
194 colors.set(word, index)
195 }
196 indices[p] = index
197 }
198 const channels = paletted ? 1 : 4
199 const pixels = paletted ? indices : rgba
200 const stride = width * channels
201 // Each row is filtered against the one above, so a repeated row is all zeros
202 const raw = new Uint8Array((stride + 1) * height)
203 for (let y = 0; y < height; y++) {
204 const row = y * (stride + 1)
205 raw[row] = 2
206 for (let x = 0; x < stride; x++) {
207 const above = y > 0 ? pixels[(y - 1) * stride + x] : 0
208 raw[row + 1 + x] = (pixels[y * stride + x] - above) & 255
209 }
210 }
211 const header = new Uint8Array(13)
212 const view = new DataView(header.buffer)
213 view.setUint32(0, width)
214 view.setUint32(4, height)
215 header.set([8, paletted ? 3 : 6, 0, 0, 0], 8)
216 const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', header)]
217 if (paletted) {
218 const palette = new Uint8Array(colors.size * 3)
219 const alpha = new Uint8Array(colors.size)
220 for (const [word, index] of colors) {
221 const bytes = new Uint8Array(new Uint32Array([word]).buffer)
222 palette.set(bytes.subarray(0, 3), index * 3)
223 alpha[index] = bytes[3]
224 }
225 parts.push(chunk('PLTE', palette), chunk('tRNS', alpha))
226 }
227 parts.push(chunk('IDAT', deflate(raw)), chunk('IEND', new Uint8Array(0)))
228 const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0))
229 let offset = 0
230 for (const part of parts) {
231 out.set(part, offset)
232 offset += part.length
233 }
234 return out.toBase64()
235}
236hooks/party.js 224 lines1import { rnd } from './effects/draw.js'
2
3// Claude's running subagents as a party of Poké Balls along the left of the strip.
4// A ball drops in when a subagent starts, rocks now and then while it runs, and
5// pops open in a burst of light when it stops. The balls to its right then slide
6// over to close the gap. Past six, the sixth slot shows +n for the rest.
7// A party is a plain array of balls, oldest first. Every function returns a new
8// party or new stamps and leaves its input alone.
9
10const FALL_TICKS = 2
11const BOUNCE_TICKS = [2, 4]
12const SETTLE_TICKS = 10
13const WOBBLE_TICKS = 40
14const WOBBLE = [-1, -1, -1, 0, 1, 1, 1]
15const OPEN_TICKS = 3
16const POP_TICKS = 16
17const FADE_TICKS = 4
18const SLIDE_TICKS = 2
19const EXPIRE_TICKS = 3 * 60 * 60 * 20
20
21export const PARTY_SIZE = 6
22
23const BALL_SIZE = 5
24const SLOT_STEP = 6
25const LEFT = 1
26const MAX_EXTRA = 9
27const SPARKS = 9
28
29// Frames are 7 pixels wide, drawn a pixel left of the slot so a ball can lean and
30// spill light. A 5x5 ball: K outline, R red top with an h shine, G dark band,
31// B light button, W white bottom with a g shade under the button.
32export const BALL = ['..KKK..', '.KhRRK.', '.GGBGG.', '.KWgWK.', '..KKK..']
33// Rocking left and right: the band tips and the top two rows lean a pixel that way
34export const LEANS = [
35 ['.KKK...', 'KhRRG..', '.KGBGK.', '.GWgWK.', '..KKK..'],
36 BALL,
37 ['...KKK.', '..GhRRK', '.KGBGK.', '.KWgWG.', '..KKK..'],
38]
39// Popping: the lid lifts off the bottom half and light spills out of the gap
40export const OPEN = [
41 ['..KKK..', '.KhRRK.', 'rwwwwwr', '.GGBGG.', '.KWgWK.', '..KKK..'],
42 ['..KKK..', '.KhRRK.', 'r.www.r', 'rwwwwwr', '.GGBGG.', '.KWgWK.', '..KKK..'],
43]
44// Then a flash where the ball was, big and then small
45export const FLASH = [
46 ['...r...', '..rwr..', '.rwwwr.', 'rwwwwwr', '.rwwwr.', '..rwr..', '...r...'],
47 ['.......', '...r...', '..rwr..', '.rwwwr.', '..rwr..', '...r...', '.......'],
48]
49export const BALL_COLORS = { K: 0x000000, R: 0xe03030, h: 0xff9090, G: 0x4a4a56, B: 0xffffff, W: 0xf0f0f4, g: 0xa0a0ac }
50export const LIGHT_COLORS = { ...BALL_COLORS, w: 0xffffff, r: 0xff8080 }
51const LABEL_COLOR = 0x9a9ab0
52
53// The +n label: a plus beside a digit, 5 pixels tall like a ball
54const PLUS = ['...', '.n.', 'nnn', '.n.', '...']
55const DIGITS = {
56 2: ['nnn', '..n', 'nnn', 'n..', 'nnn'],
57 3: ['nnn', '..n', 'nnn', '..n', 'nnn'],
58 4: ['n.n', 'n.n', 'nnn', '..n', '..n'],
59 5: ['nnn', 'n..', 'nnn', '..n', 'nnn'],
60 6: ['nnn', 'n..', 'nnn', 'n.n', 'nnn'],
61 7: ['nnn', '..n', '..n', '..n', '..n'],
62 8: ['nnn', 'n.n', 'nnn', 'n.n', 'nnn'],
63 9: ['nnn', 'n.n', 'nnn', '..n', 'nnn'],
64}
65
66const stamp = (x, y, rows, colors) => ({ x, y, rows, colors })
67
68// A stable number per subagent, so each ball keeps its own beat and sparks
69function hashOf(id) {
70 let h = 0x811c9dc5
71 for (const ch of String(id)) h = Math.imul(h ^ ch.charCodeAt(0), 0x01000193)
72 return h >>> 0
73}
74
75const slotX = (slot) => LEFT + slot * SLOT_STEP
76
77// Every ball past the fifth shares the sixth slot
78const slotOf = (i) => Math.min(i, PARTY_SIZE - 1)
79
80// Where a ball stands: in its slot, or sliding left into it a pixel at a time
81function ballX(ball, i, tick) {
82 const home = slotX(slotOf(i))
83 if (ball.slideFrom === null) return home
84 return Math.max(home, ball.slideFrom - Math.floor((tick - ball.slideStart) / SLIDE_TICKS))
85}
86
87const landingAge = (ground) => (ground + 1) * FALL_TICKS
88
89// Whether a ball reached the ground, before it popped if it popped
90const hasLanded = (ball, tick, ground) => (ball.popStart ?? tick) - ball.born >= landingAge(ground)
91
92// A ball's top pixel row: falling a pixel every FALL_TICKS from above the strip
93// like a berry, then bouncing a pixel once it lands
94function topAt(age, ground) {
95 const rest = ground - BALL_SIZE + 1
96 const fall = -BALL_SIZE + Math.floor(age / FALL_TICKS)
97 if (fall < rest) return fall
98 const since = age - landingAge(ground)
99 return since >= BOUNCE_TICKS[0] && since < BOUNCE_TICKS[1] ? rest - 1 : rest
100}
101
102// -1 or 1 while a resting ball rocks left or right. Each ball keeps its own beat,
103// WOBBLE_TICKS give or take 4, so neighbors drift in and out of step.
104function tiltAt(ball, age, ground) {
105 const rested = age - landingAge(ground) - SETTLE_TICKS
106 if (rested < 0) return 0
107 const h = hashOf(ball.id)
108 const beat = WOBBLE_TICKS - 4 + (h % 9)
109 return WOBBLE[(rested + h) % beat] ?? 0
110}
111
112export function joinParty(party, id, tick) {
113 if (party.some((ball) => ball.id === id)) return party
114 return [...party, { id, born: tick, popStart: null, slideFrom: null, slideStart: 0 }]
115}
116
117export function leaveParty(party, id, tick) {
118 const i = party.findIndex((ball) => ball.id === id)
119 if (i < 0 || party[i].popStart !== null) return party
120 return party.map((ball, k) => (k === i ? { ...ball, popStart: tick } : ball))
121}
122
123// Pop balls past the cap, drop balls done popping, and slide each ball that moves
124// to a lower slot over from where it stands. Returns the same party when nothing changes.
125export function prunedParty(party, tick) {
126 const expired = (ball) => ball.popStart === null && tick - ball.born >= EXPIRE_TICKS
127 const done = (ball) => ball.popStart !== null && tick - ball.popStart >= POP_TICKS
128 if (!party.some((ball) => expired(ball) || done(ball))) return party
129 const popped = party.map((ball) => (expired(ball) ? { ...ball, popStart: tick } : ball))
130 const was = new Map(popped.map((ball, i) => [ball, i]))
131 return popped.filter((ball) => !done(ball)).map((ball, i) => {
132 const old = was.get(ball)
133 return slotOf(old) === slotOf(i) ? ball : { ...ball, slideFrom: ballX(ball, old, tick), slideStart: tick }
134 })
135}
136
137// Scattered pixels, each [x, y, letter], as one stamp, the first pixel at a spot on top
138function scatter(pixels, colors) {
139 if (pixels.length === 0) return []
140 const xs = pixels.map((p) => p[0])
141 const ys = pixels.map((p) => p[1])
142 const left = Math.min(...xs)
143 const top = Math.min(...ys)
144 const grid = Array.from({ length: Math.max(...ys) - top + 1 }, () => Array(Math.max(...xs) - left + 1).fill('.'))
145 for (const [x, y, ch] of pixels) if (grid[y - top][x - left] === '.') grid[y - top][x - left] = ch
146 return [stamp(left, top, grid.map((row) => row.join('')), colors)]
147}
148
149// The sparks of a popped ball, t ticks after the flash: a fan flying up and out of
150// its middle, fast white ones and slow light red ones in turn, with light red tails
151// that drop off as they fade
152function sparkPixels(ball, t, cx, cy) {
153 const seed = hashOf(ball.id)
154 const fading = t >= POP_TICKS - OPEN_TICKS - FADE_TICKS
155 const heads = []
156 const tails = []
157 for (let k = 0; k < SPARKS; k++) {
158 const angle = Math.PI * (1.1 + (0.8 * (k + 0.2 + 0.6 * rnd(seed, k))) / SPARKS)
159 const speed = (k % 2 ? 0.4 : 0.75) + 0.15 * rnd(seed, k, 1)
160 const at = (r) => [Math.round(cx + Math.cos(angle) * r), Math.round(cy + Math.sin(angle) * r + 0.01 * t * t)]
161 heads.push([...at(2.5 + speed * t), fading || k % 2 ? 'r' : 'w'])
162 if (!fading) tails.push([...at(1.3 + speed * t), 'r'])
163 }
164 return [...heads, ...tails]
165}
166
167// The popping ball itself: the lid lifting off, then a flash where the ball was.
168// A ball folded into the +n label only flashes.
169function lidStamp(age, x, top, folded) {
170 if (age < OPEN_TICKS) {
171 if (folded) return null
172 const open = OPEN[age < OPEN_TICKS - 1 ? 0 : 1]
173 return stamp(x - 1, top + BALL_SIZE - open.length, open, LIGHT_COLORS)
174 }
175 const flash = FLASH[age - OPEN_TICKS]
176 return flash ? stamp(x - 1, top - 1, flash, LIGHT_COLORS) : null
177}
178
179// The small +n in the sixth slot, standing on the ground like a ball
180function labelStamp(n, ground, color) {
181 const digit = DIGITS[Math.min(MAX_EXTRA, n)]
182 const rows = PLUS.map((row, y) => row + '.' + digit[y])
183 return stamp(slotX(PARTY_SIZE - 1), ground - BALL_SIZE + 1, rows, { n: color })
184}
185
186// What to draw this tick, the first stamp on top: popping lids and flashes, falling
187// balls, resting balls, sparks, then the +n label. Balls past the fifth that have
188// landed fold into the label, so a new one falls into the sixth slot and joins it.
189export function partyStamps(party, tick, { columns, ground, label = LABEL_COLOR }) {
190 const landed = (ball) => hasLanded(ball, tick, ground)
191 const crowd = party.slice(PARTY_SIZE - 1)
192 const settled = crowd.filter(landed)
193 const folded = settled.length > 1
194 // Of the balls falling into the sixth slot together, only the lowest shows
195 const leader = crowd.find((ball) => ball.popStart === null && !landed(ball))
196 const lids = []
197 const falling = []
198 const resting = []
199 const sparks = []
200 party.forEach((ball, i) => {
201 const x = ballX(ball, i, tick)
202 if (x >= columns) return
203 const hidden = folded && settled.includes(ball)
204 if (ball.popStart !== null) {
205 const age = tick - ball.popStart
206 if (age >= POP_TICKS) return
207 const top = topAt(ball.popStart - ball.born, ground)
208 const lid = lidStamp(age, x, top, hidden)
209 if (lid) lids.push(lid)
210 if (age >= OPEN_TICKS) sparks.push(...sparkPixels(ball, age - OPEN_TICKS, x + 2, top + 2))
211 return
212 }
213 if (hidden || (i >= PARTY_SIZE - 1 && !landed(ball) && ball !== leader)) return
214 const age = tick - ball.born
215 const tilt = tiltAt(ball, age, ground)
216 const s = stamp(x - 1, topAt(age, ground), LEANS[tilt + 1], BALL_COLORS)
217 if (landed(ball)) resting.push(s)
218 else falling.push(s)
219 })
220 const inside = ([x, y]) => x >= 0 && x < columns && y >= 0 && y <= ground
221 const labels = folded && slotX(PARTY_SIZE - 1) < columns ? [labelStamp(settled.length, ground, label)] : []
222 return [...lids, ...falling, ...resting, ...scatter(sparks.filter(inside), LIGHT_COLORS), ...labels]
223}
224hooks/wild.js 160 lines1import { TIERS } from './dex.js'
2import { rnd } from './effects/draw.js'
3
4// A wild mon's visit, kept as plain data. The home mon walks home while the foe is
5// calling, then the foe walks in from the scene's left edge, stands facing the home mon
6// in battle, and walks back off the way it came when it flees. A thrown ball holds it
7// in the throw phase until it's caught or breaks free. A function that moves the visit on
8// returns a new wild, or null once it's gone, and leaves its input alone.
9
10const WILD_LEFT = 2
11const WILD_GAP = 8
12export const WILD_STAY_MS = 4 * 60 * 1000
13// How long a worn out foe waits for a ball
14export const WORN_OUT_MS = 90 * 1000
15// The fewest milliseconds between the home mon's moves at the foe, and the most added at random
16export const ATTACK_GAP_MS = 6000
17export const ATTACK_JITTER_MS = 2000
18// Main turns that may end before an uncaught foe leaves
19export const WILD_TURNS = 4
20const WALK_TICKS = 2
21const MAX_HP = 100
22const MAX_CHARGE = 2
23// A hit jolts the foe a pixel to either side in turn, and blinks it, in 2-tick phases
24const JOLT_TICKS = 12
25const BLINK_TICKS = 8
26const HP_BAR = 12
27const HP_COLORS = [[0.5, 0x48c858], [0.2, 0xe8c030], [0, 0xe04040]]
28
29// How long each stage of a throw takes, in ticks. The drop's length depends on how far
30// the ball falls, and wobble takes shake ticks per shake.
31export const THROW_TICKS = { arc: 14, open: 3, bounce: 4, shake: 12, caught: 20, breakout: 7 }
32
33export const newWild = (pick, seed) => ({
34 ...pick,
35 seed,
36 throws: 0,
37 throw: null,
38 phase: 'calling',
39 walked: 0,
40 x: 0,
41 leaveAt: null,
42 hp: MAX_HP,
43 maxHp: MAX_HP,
44 charge: 0,
45 nextAttackAt: 0,
46 hitAt: null,
47 turns: 0,
48})
49
50// Work done while the foe is in battle, or held in a ball, banks a move, up to MAX_CHARGE
51export const chargedWild = (wild) =>
52 wild?.phase === 'battle' || wild?.phase === 'throw' ? { ...wild, charge: Math.min(MAX_CHARGE, wild.charge + 1) } : wild
53
54// A worn out foe is a sure catch. Otherwise the odds grow from 0 at full HP to its tier's best.
55const catchChance = (wild) => (wild.hp === 0 ? 1 : TIERS[wild.tier].catch * (1 - wild.hp / wild.maxHp))
56
57// The times the ball rocks before it clicks shut, or before the foe breaks out
58const shakesFor = (chance, caught) => (caught ? 3 : Math.min(3, Math.floor(chance * 4)))
59
60// Throw a ball, rolling its outcome up front. drop is how many pixels it falls from the foe to the ground.
61export function thrownWild(wild, tick, drop) {
62 const throws = wild.throws + 1
63 const chance = catchChance(wild)
64 const caught = rnd(wild.seed, throws) < chance
65 return { ...wild, phase: 'throw', throws, throw: { start: tick, caught, shakes: shakesFor(chance, caught), drop } }
66}
67
68// Which stage of the throw plays at tick, and the ticks since it began. Past the last, 'done'.
69export function throwStage(thrown, tick) {
70 let t = tick - thrown.start
71 const stages = [
72 ['arc', THROW_TICKS.arc],
73 ['open', THROW_TICKS.open],
74 ['drop', thrown.drop + THROW_TICKS.bounce],
75 ['wobble', THROW_TICKS.shake * thrown.shakes],
76 thrown.caught ? ['caught', THROW_TICKS.caught] : ['breakout', THROW_TICKS.breakout],
77 ]
78 for (const [stage, ticks] of stages) {
79 if (t < ticks) return { stage, t }
80 t -= ticks
81 }
82 return { stage: 'done', t }
83}
84
85// What a move takes off the foe: a share of its HP by tier, 0.8 to 1.2 of it by roll,
86// and half again for a power 2 move
87export const damageOf = (wild, power, roll) => (wild.maxHp / TIERS[wild.tier].hits) * (0.8 + 0.4 * roll) * (power >= 2 ? 1.5 : 1)
88
89export const foeJolt = (wild, tick) =>
90 wild.hitAt !== null && tick - wild.hitAt < JOLT_TICKS ? (Math.floor((tick - wild.hitAt) / 2) % 2 === 0 ? -1 : 1) : 0
91
92export const foeBlinks = (wild, tick) => wild.hitAt !== null && tick - wild.hitAt < BLINK_TICKS && Math.floor((tick - wild.hitAt) / 2) % 2 === 1
93
94// The HP bar as stamp rows and colors: filled from the left in green, yellow, or red by
95// what's left, the rest in empty
96export function hpBar(wild, empty) {
97 const share = wild.hp / wild.maxHp
98 const filled = Math.ceil(share * HP_BAR)
99 const color = HP_COLORS.find(([above]) => share > above)?.[1] ?? HP_COLORS[2][1]
100 return { rows: ['f'.repeat(filled) + 'e'.repeat(HP_BAR - filled)], colors: { f: color, e: empty }, width: HP_BAR }
101}
102
103// Drawn in the scene, from walking in to walking off
104export const isOnStage = (wild) => wild !== null && wild.phase !== 'calling'
105
106// Here to fight: walking in, standing, or held in a ball for now
107export const isHere = (wild) => wild !== null && ['entering', 'battle', 'throw'].includes(wild.phase)
108
109// The strip columns a battle takes: WILD_LEFT, the foe's body, the gap, and homeSpan, from
110// the home mon's body to the strip's right edge. foe is the box of the foe's idle frame.
111export const battleColumns = (homeSpan, foe) => WILD_LEFT + foe.right - foe.left + 1 + WILD_GAP + homeSpan
112
113// The strip column of the foe's frame when its body stands WILD_GAP columns left of homeLeft
114export const foeSpotX = (homeLeft, foe) => homeLeft - WILD_GAP - foe.right - 1
115
116// Where the foe's frame is now. It enters from just past the scene's left edge, offLeft,
117// so it starts out of sight however wide the strip grows meanwhile.
118export function foeX(wild, { spot, offLeft }) {
119 if (wild.phase === 'entering') return Math.min(spot, offLeft + wild.walked)
120 if (wild.phase === 'fleeing') return wild.x
121 return spot
122}
123
124// Walk off from where it is. One still calling hasn't shown up, so it just goes.
125export function fleeingWild(wild, at) {
126 if (wild.phase === 'calling') return null
127 if (wild.phase === 'fleeing') return wild
128 return { ...wild, phase: 'fleeing', x: foeX(wild, at) }
129}
130
131// One tick of the visit, with the event it reached: 'arrived' when the foe takes its
132// spot, 'timeout' when it leaves for want of a fight, 'caught' when the ball clicks shut,
133// 'brokefree' when the foe breaks out, and 'gone' once it's out of sight or in the ball.
134// at holds spot and offLeft as foeX takes them, and stayTicks is how long it waits for a fight.
135export function stepWild(wild, { tick, atHome, at, stayTicks }) {
136 const moves = tick % WALK_TICKS === 0
137 if (wild.phase === 'calling') return { wild: atHome ? { ...wild, phase: 'entering', walked: 0 } : wild }
138 if (wild.phase === 'entering') {
139 if (!moves) return { wild }
140 const next = { ...wild, walked: wild.walked + 1 }
141 if (at.offLeft + next.walked < at.spot) return { wild: next }
142 return { wild: { ...next, phase: 'battle', leaveAt: tick + stayTicks }, event: 'arrived' }
143 }
144 if (wild.phase === 'battle') {
145 if (tick < wild.leaveAt) return { wild }
146 return { wild: fleeingWild(wild, at), event: 'timeout' }
147 }
148 if (wild.phase === 'throw') {
149 const { stage } = throwStage(wild.throw, tick)
150 if (stage === throwStage(wild.throw, tick - 1).stage) return { wild }
151 if (stage === 'caught' || stage === 'breakout') return { wild, event: stage === 'caught' ? 'caught' : 'brokefree' }
152 if (stage !== 'done') return { wild }
153 if (wild.throw.caught) return { wild: null, event: 'gone' }
154 return { wild: { ...wild, phase: 'battle', throw: null, leaveAt: tick + stayTicks } }
155 }
156 if (!moves) return { wild }
157 if (wild.x - 1 <= at.offLeft) return { wild: null, event: 'gone' }
158 return { wild: { ...wild, x: wild.x - 1 } }
159}
160