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>
@@ -1,37 +1,84 @@
|
||||
# Sprite assets
|
||||
|
||||
Art: [Kenney's Robot Pack](https://kenney.nl/assets/robot-pack) (CC0 — no
|
||||
attribution required, credited here anyway), the green side-view robot.
|
||||
Source pack lives at `~/Documents/kenney_robot-pack`; only the frames listed
|
||||
below were copied in.
|
||||
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.
|
||||
|
||||
```bash
|
||||
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.png robot_greenBody (standing)
|
||||
listening/ frame_00.png, frame_01.png robot_greenDrive1/2 (tracks rolling — "leaning in")
|
||||
thinking/ frame_00.png, frame_01.png robot_greenDamage1/2 (flicker — "processing")
|
||||
talking/ frame_00.png, frame_01.png robot_greenBody, robot_greenJump (bounce)
|
||||
error/ frame_00.png robot_greenHurt
|
||||
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`).
|
||||
- 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`).
|
||||
- Frames are scaled to fit within `PET_SIZE` (default 160px), keeping aspect
|
||||
ratio, and centered in the (square) pet window — the source art here isn't
|
||||
square, so don't assume it fills the frame edge-to-edge.
|
||||
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. 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
|
||||
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:
|
||||
|
||||
```bash
|
||||
|
||||
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |