SLOPSHOPPER

pokemon

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

newbandspinnerguardcommandprompt
★ 24v?no licenseupdated 2026-10-09dgokcin/claude-pokemon-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pokemon
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pokemon ⎿ pokemon: Showing default Abra Lv. 5, wandering (food 80%, happiness 80%). Options: <mon>, default, shiny, wander, needs ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-pokemon

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

CI Release 151 Pokémon 135 moves

Bulbasaur gets petted and eats an oran berry above the Claude Code prompt

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 env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1"
  }
}

Why you'll keep it on

  • 🔎 It watches Claude work. A pencil for edits, a magnifier for reads, >_ for shell commands, a TM disc for skills.
  • ⚪ Every subagent is a Poké Ball. It drops when the subagent starts and pops when it reports back.
  • ❗ It tells you when Claude needs you. A red "!" for questions, plan approvals, and permission prompts.
  • 💦 It flinches when a tool fails. A shake and a sweat drop, so you notice.
  • 📈 It levels up as you ship. Every answered turn earns XP, and mons evolve at their levels from the games.
  • 🫐 It needs you too. Feed it and pet it, or it gets hungry and lonely.
  • ⚡ It fights. 135 moves from gen 1, each with its own pixel animation.
  • 🌿 Wild Pokémon show up. Your work wears them down, and /pokemon catch fills your Pokédex.
  • 🎨 All 151 gen 1 Pokémon, each in default and shiny.

See it

Watch it follow Claude's tools. A magnifier for reads, a pencil for writes, and a level-up when the answer lands.

Pikachu's bubble shows a magnifier, then a pencil, while Claude reads files and writes a haiku

Run subagents and watch the balls drop.

Four Poké Balls drop beside Squirtle while four subagents run, and pop as they finish

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

Growlithe glows, flickers into Arcanine's silhouette, and flashes into Arcanine

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

Magikarp splashes to no effect, then Gengar casts Night Shade

Commands

CommandWhat it does
/pokemon <mon>Pick a mon, like /pokemon charmander
/pokemon petPet it
/pokemon feedToss it a berry
/pokemon attack [move]Use a random move, or a named one like thunderbolt
/pokemon evolveEvolve with a stone or a trade
/pokemon statsLevel, XP, evolution, and meters
/pokemon boxEvery mon you've raised
/pokemon catchThrow a Poké Ball at a wild mon
/pokemon dexEvery 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
CommandEffect
/pokemonShow 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 listList every mon name
/pokemon shiny, /pokemon defaultPick a variant, saved across sessions
/pokemon wanderToggle idle wandering (on by default)
/pokemon petPet the mon. A sleeping mon stays asleep. Counts pets across sessions
/pokemon feedToss it a random oran 🫐, pecha 🍑, razz 🍓, or sitrus 🍋 berry
/pokemon attackUse a random move from the mon's moveset
/pokemon attack <move>Use a named move, ignoring case, spaces, and dashes
/pokemon moves, /pokemon attack listList the active mon's moves
/pokemon moves <mon>List another mon's moves
/pokemon evolveEvolve 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 stopCancel an evolution
/pokemon sleepTuck the mon in, so its meters drain slower until Claude starts working in a session showing it. Run it again to wake it
/pokemon needsToggle food and happiness (on by default)
/pokemon syncToggle 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 autoevolveToggle level evolutions starting on their own once idle (on by default). Off, the mon waits for /pokemon evolve
/pokemon emojiToggle emoji meters, 🍓 and 💗 instead of ● and ♥ (off by default, saved across sessions)
/pokemon size small, /pokemon size largeDraw 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 statsShow the level, XP to the next one, how it evolves, the meters, and your pets and feeds
/pokemon boxList 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 catchThrow a Poké Ball at the wild mon. The odds rise as its HP falls
/pokemon runSend the wild mon away
/pokemon dexCount 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+ACollapse or expand the band (Claude Code's own binding)

Requirements

  • Claude Code v2.1.287 or later, in the terminal or the Desktop app's Code tab.
  • In the terminal, a truecolor one, like iTerm2, Ghostty, kitty, or WezTerm. Light and dark themes both work.
  • The VS Code extension and the mobile app have no band to draw in, so the mon doesn't show there.

How it works

WhenThe monDriven by
Claude worksPaces back and forth across the stripband isWorking
Claude thinksShows a thought bubble with animated dotsspinner mode === 'thinking'
Claude runs a toolShows 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 resttool.call (main agent only)
A skill is usedShows 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'ttool.call (any agent), command.run, skill.prompt
A tool call failsShakes, 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 showingtool.call (main agent only), tool.check
Claude needs youStops, 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 minutesAskUserQuestion, ExitPlanMode, turn.complete, classic.PermissionRequest, classic.Notification
A subagent runsDrops 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 +nagent.spawn, turn.complete, $.agent.list()
A turn endsHops twice, unless you interrupted itturn.complete (main agent only)
A turn ends with an answerEarns XP, and may level up or evolveturn.complete (main agent only)
A wild mon is dueWalks home, and the strip widens as the foe walks in from the lefttool.call (main agent only), the shared wildAt store key
Claude works during a battleBanks a move per tool call and answered turn, and fires one at the foe every 6 to 8 stool.call (any agent), turn.complete
You're idleStrolls to random spots, resting 3 to 8 s between walks. With wandering off, it walks home and bobs
5 minutes with no activityFalls asleep with pixel Zsprompt.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 moreHops 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%.

MeterIconsEmpties from full inFilled by
Food●●●○○8 hours/pokemon feed: +20
Happiness♥♥♥♥♥, the last two dimmed12 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.jshyperbeam, solarbeam, psybeam, aurorabeam, icebeam, bubblebeam
electric.jsthundershock, thunderbolt, thunder, thunderwave
fire.jsember, flamethrower, firespin, dragonrage
water.jswatergun, hydropump, surf, waterfall, bubble, clamp
nature.jsvinewhip, razorleaf, petaldance, leechseed, drain, leechlife, powder, stringshot
poison.jspoisonsting, twineedle, acid, sludge, gas, pinmissile, spikecannon
mind.jsconfusion, psychic, kinesis, hypnosis, nightshade, lick, confuseray, dreameater
sound.jsscreech, supersonic, growl, roar, sing, lovelykiss
charge.jstackle, quickattack, bodyslam, headbutt, skullbash, takedown, rage, thrash, outrage, hornattack, horndrill, stomp, slam
strikes.jspound, doubleslap, megapunch, cometpunch, firepunch, icepunch, thunderpunch, megakick, lowkick, doublekick, hijumpkick, seismictoss, submission, karatechop
weapons.jsscratch, slash, furyswipes, cut, bite, hyperfang, crabhammer, vicegrip, guillotine, peck, drillpeck, furyattack, boneclub, bonemerang, wrap
sky.jsgust, wingattack, fly, skyattack, agility, doubleteam
earth.jsearthquake, dig, rockthrow, rockslide, sandattack, blizzard, mist
guard.jsharden, withdraw, defensecurl, minimize, focusenergy, meditate, amnesia, barrier, reflect, lightscreen, acidarmor
self.jsrecover, softboiled, rest, splash, teleport, transform, explosion
special.jsswift, payday, triattack, eggbomb
evolve.jsevolve, the evolution sequence
basic.jsimpact, 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 ●●●●○ ♥♥♥♥♡.

Hack on it

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:

  1. Open the mod's store file, ~/.claude/plugins/store/pokemon_<marketplace>-<hash>.json. For the local marketplace above it's pokemon_claude-pokemon-<hash>.json.
  2. Add "debug": true to the top-level object.
  3. Run /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.

  1. Copy the four GIFs from 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.
  2. Run node scripts/build-frames.mjs.
  3. Give it a moveset in MOVES in hooks/moves.js. Every mon needs at least one move.
  4. If it evolves, add it to hooks/evolutions.js. If its name isn't just the key capitalized, add it to hooks/names.js.
  5. Mark its eyes on every frame in EYES in hooks/eyes.js, and check them asleep with node scripts/preview-sleep.mjs <mon>.
  6. Run 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
PathContents
hooks/register.jsBand renderer, animation timer, /pokemon command
hooks/attacks.jsAttack registry: each move's pose, aim, and frame
hooks/effects/*.jsAttack animations, one file per family
hooks/moves.jsEach move's effect, and each mon's moveset
hooks/names.jsDisplay names, like Nidoran♀ and Mr. Mime
hooks/levels.jsThe XP curve, and the XP a turn earns
hooks/evolutions.jsGen 1 evolutions, and each mon's start level
hooks/party.jsThe Poké Balls of running subagents
hooks/dex.jsThe Pokédex, and how rare each mon is in the wild
hooks/wild.jsA wild mon's visit: walking in, battle, throws, and fleeing
hooks/zoom.jsShrinks the band to its size and to fit a short pane
hooks/png.jsEncodes the small band's frames as PNGs for terminals that draw images
hooks/eyes.jsEach mon's eyes on every frame, shut while it sleeps
hooks/frames.jsGenerated pixel frames. Don't edit by hand
sprites/<mon>/*.gifSource GIFs, 32x32
scripts/Frame builder, attack, sleep, and battle previewers on a stand-in host, data check
tests/pokemon.test.tsclaude 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.

Credits

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

Source 32 files
hooks/register.js 2781 lines
1import { 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 lines
1// 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}
123
hooks/custom.js 306 lines
1// 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}
306
hooks/dex.js 93 lines
1import { 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'
93
hooks/evolutions.js 113 lines
1// 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}
113
hooks/eyes.js 285 lines
1// 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}
285
hooks/levels.js 30 lines
1// 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}
30
hooks/moves.js 313 lines
1// 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}
313
hooks/names.js 14 lines
1// 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}
14
hooks/png.js 236 lines
1// 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}
236
hooks/party.js 224 lines
1import { 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}
224
hooks/wild.js 160 lines
1import { 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