Files
Bolt-Pet/bolt_pet/assets/sprites/README.md
T
themajesticmagician b121bbba17 Multi-monitor jumps, screen OCR, and generated sprite art
petctl gains screen verbs: `jump` (1-based number, name, next/prev/
primary/other, or a direction resolved from real geometry), `monitors`,
and `read` for OCR of a monitor's contents.

- monitors.py: pure layout model + jump-target resolution. The monitor
  list is published by PetWindow from QGuiApplication.screens() over a
  queued signal, so the controller and window agree on what "monitor 2"
  means; xrandr and Qt order screens differently on the same machine.
- screen_text.py: pull-only OCR (mss capture + Tesseract/RapidOCR).
  Nothing captures unless the server asks, and the text rides back up
  the tool-result relay so Bolt can read a screen mid-turn. Both deps
  optional, soft-failing with a reason. SCREEN_TEXT=false removes it.
- Query verbs are answered in controller._handle_command rather than
  pet_actions.describe(), because their output is the point.
- scripts/generate_bolt_sprites.py draws every frame; walk/ is a
  side-view cycle stepped by distance travelled, not by the animation
  timer, so the planted paw tracks the window exactly. sprite.py loads
  it via EXTRA_ANIMATIONS keyed by name, with has() so callers can
  decline a placeholder blob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:17:40 -06:00

4.4 KiB

Sprite assets

Art: Bolt himself — a cream shepherd pup with a slate cap, a lightning blaze on his forehead and a bolt tag on his collar. The frames are generated, not hand-drawn: scripts/generate_bolt_sprites.py draws every one of them with Pillow and writes this folder.

python scripts/generate_bolt_sprites.py                  # rewrite this folder
python scripts/generate_bolt_sprites.py --out /tmp/prev  # preview elsewhere first
python scripts/generate_bolt_sprites.py --states idle    # just one state

That means tweaking the art is editing code, not 24 PNGs: the palette is a block of constants at the top of the script, the body/head/ear/tail shapes are one function each in normalised 0..1 coordinates, and each state's animation is a list of pose dicts in frames_for(). Everything is super-sampled 4x and downscaled on save, because PIL's draw primitives have no antialiasing.

Regenerate after editing — the PNGs here are committed, so a change to the script alone doesn't move the pet.

Convention the loader (bolt_pet/ui/sprite.py) expects:

assets/sprites/
  idle/        frame_00..07.png   breathing, tail wag, blink on frame 06
  listening/   frame_00..03.png   ears perked, head tilted in, collar tag lit, sound arcs
  thinking/    frame_00..05.png   eyes up, head cocked, cycling dots
  talking/     frame_00..03.png   mouth open/close with tongue, ears bouncing
  error/       frame_00..01.png   X eyes, ears drooped, red spark
  walk/        frame_00..07.png   side-view walk cycle (see below)
  • One subfolder per pet state (matches bolt_pet.state.PetState), plus walk/, which is not a state — see below.
  • Any *.png filenames work — they're played back in alphabetical-sort order, looping, at IDLE_ANIMATION_FPS (see .env). At the default 6fps the 8-frame idle loop runs about 1.3s.
  • Frames are square (320px, 2x the default PET_SIZE of 160) so they downscale cleanly; the loader scales to fit PET_SIZE keeping aspect ratio and centres them in the square pet window.
  • A state directory with no frames in it falls back to a small procedurally-drawn placeholder blob (see _placeholder_frames in sprite.py).

The walk cycle

walk/ is the one animation that isn't a PetState. Walking is a property of movement — orthogonal to whether he's idle, listening or talking — so it stays out of the state machine and is keyed by name instead (sprite.EXTRA_ANIMATIONS). PetWindow uses it whenever the pet is actually travelling and falls back to the state animation the moment it stops.

Three things about it are load-bearing if you redraw it:

  • It's a side view, drawn facing right. The other poses are a front-facing sit, which is fine standing still but slides like a chess piece when moving. PetWindow._oriented() mirrors the frames (cached) when he walks left, so only the right-facing version exists on disk.
  • The cycle is advanced by distance travelled, not by the animation timer (_WALK_PIXELS_PER_FRAME, one frame per ~13px). That's what keeps a planted paw tracking backwards at exactly the speed the window moves forwards. Drive it off the clock and the feet skate whenever PET_WANDER_SPEED doesn't happen to match IDLE_ANIMATION_FPS. If you change the number of frames or the stride length in paw_position(), retune that constant to match or he'll moonwalk.
  • The frames carry their own vertical bob, so the window's own bob is switched off while they're in use. Only the no-walk-art fallback still bobs in code.

Delete walk/ and everything still runs — he reverts to sliding with a small coded bob, which is what the pet did before the cycle existed.

Swapping in different art

Replace any state's PNGs (same alphabetical-order-loops convention) to change its look — no code changes needed, and nothing forces you to keep using the generator. If your source is a single grid spritesheet (rows/cols of frames in one PNG) rather than one-file-per-frame, use scripts/slice_spritesheet.py to cut it into this folder-of-frames convention:

python scripts/slice_spritesheet.py path/to/idle_sheet.png assets/sprites/idle \
    --cols 6 --rows 1

If your format is something else entirely (a single animated GIF/APNG, a Spine/DragonBones skeletal export, an Aseprite .json atlas, etc.) — tell me the format and I'll adapt sprite.py's loader rather than making you convert by hand.