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>
This commit is contained in:
+35
-6
@@ -95,17 +95,46 @@ def _load_frames_from_dir(directory: Path, size: int) -> list[QPixmap]:
|
||||
return frames
|
||||
|
||||
|
||||
WALK = "walk"
|
||||
|
||||
# Animations that aren't pipeline states. Walking is a property of *movement*,
|
||||
# orthogonal to whether the pet is idle/listening/talking, so it deliberately
|
||||
# isn't a PetState — state.py stays a description of the conversation, not of
|
||||
# the body. Loaded the same way, keyed by name.
|
||||
EXTRA_ANIMATIONS = (WALK,)
|
||||
|
||||
|
||||
class SpriteSet:
|
||||
"""All animations for every PetState, loaded from *sprite_dir*."""
|
||||
"""All animations for every PetState, plus the extras, from *sprite_dir*."""
|
||||
|
||||
def __init__(self, sprite_dir: Path = DEFAULT_SPRITE_DIR, size: int = 160):
|
||||
self.size = size
|
||||
self._animations: dict[PetState, SpriteAnimation] = {}
|
||||
self._animations: dict[str, SpriteAnimation] = {}
|
||||
self._loaded: set[str] = set() # keys backed by real art, not placeholders
|
||||
for state in PetState:
|
||||
frames = _load_frames_from_dir(sprite_dir / state.value, size)
|
||||
if not frames:
|
||||
if frames:
|
||||
self._loaded.add(state.value)
|
||||
else:
|
||||
frames = _placeholder_frames(state, size)
|
||||
self._animations[state] = SpriteAnimation(frames)
|
||||
self._animations[state.value] = SpriteAnimation(frames)
|
||||
for name in EXTRA_ANIMATIONS:
|
||||
frames = _load_frames_from_dir(sprite_dir / name, size)
|
||||
if frames:
|
||||
self._loaded.add(name)
|
||||
self._animations[name] = SpriteAnimation(frames)
|
||||
|
||||
def get(self, state: PetState) -> SpriteAnimation:
|
||||
return self._animations[state]
|
||||
@staticmethod
|
||||
def _key(key) -> str:
|
||||
return key.value if isinstance(key, PetState) else str(key)
|
||||
|
||||
def get(self, key) -> SpriteAnimation:
|
||||
"""Animation for a PetState or an extra name. Unknown/absent extras
|
||||
fall back to idle, so a sprite folder with no walk/ still runs."""
|
||||
return self._animations.get(self._key(key)) or self._animations[PetState.IDLE.value]
|
||||
|
||||
def has(self, key) -> bool:
|
||||
"""True only when real frames were found — the caller uses this to
|
||||
decide whether to use an extra animation at all, rather than being
|
||||
handed a placeholder blob that looks nothing like walking."""
|
||||
return self._key(key) in self._loaded
|
||||
|
||||
Reference in New Issue
Block a user