From dedae31a665933e80b7ac5cf4c8de4cc5a311bbe Mon Sep 17 00:00:00 2001 From: max Date: Tue, 8 Sep 2026 23:06:14 +0200 Subject: [PATCH] feat(controller): add cat AI state machine, kinematics, and paw targeting logic --- catser/cat_controller.py | 561 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100644 catser/cat_controller.py diff --git a/catser/cat_controller.py b/catser/cat_controller.py new file mode 100644 index 0000000..ae496a2 --- /dev/null +++ b/catser/cat_controller.py @@ -0,0 +1,561 @@ +""" +Cat Controller and State Machine for Catser. + +Faithfully implements Workcat's motion physics, life-action state machine, +and targeting kinematics: +- States: WALK, ALERT, RUN, PAW, RELEASE, DRAG, FALL, HAPPY, SIT, SLEEP, STRETCH, TAIL. +- Target tracking: Calculates trajectory so front paw reaches [X] close button + at exactly PAW_CONTACT_FRAME (frame 14). +- Life actions: Weighted probabilistic transitions between standing, sitting, + sleeping (with 'z' indicator), stretching, and tail flicking. +- Petting & Drag: Interactive mouse control with physics gravity fall. +""" + +import time +import math +import random +import logging +from enum import Enum, auto +from typing import Optional, Tuple, Callable + +from PIL import Image + +from .config import ( + Config, + Coat, + GAIT_FRAME_COUNT, + WALK_SPEED, + WALK_STRIDE, + RUN_SPEED, + RUN_STRIDE, + GAIT_CADENCE, + PAW_FPS, + PAW_FRAME_COUNT, + PAW_CONTACT_FRAME, + CONTACT_RATIO_X, + CONTACT_RATIO_Y, + ALERT_HOLD_MS, + RELEASE_SETTLE_MS, + HAPPY_MS, + BLINK_DURATION_MS, + BLINK_INTERVAL_MIN_MS, + BLINK_INTERVAL_MAX_MS, + APPROACH_MIN_MS, + APPROACH_MAX_MS, + ACTION_WEIGHTS, + LIFE_ACTION_INTERVAL_MS, + SETTLED_ACTION_INTERVAL_MS, + SIT_MIN_MS, + SIT_MAX_MS, + SLEEP_MIN_MS, + SLEEP_MAX_MS, + LIFE_MOTION_FPS, + LIFE_MOTION_FRAME_COUNT, +) +from .assets_manager import AssetsManager +from .window_manager import WindowManager, WindowInfo + +logger = logging.getLogger("catser.controller") + + +class CatState(Enum): + """Cat behavior state machine enum.""" + WALK = auto() + ALERT = auto() + RUN = auto() + PAW = auto() + DRAG = auto() + FALL = auto() + RELEASE = auto() + HAPPY = auto() + SIT = auto() + SLEEP = auto() + STRETCH = auto() + TAIL = auto() + + +class CatController: + """ + Coordinates cat position, animations, state transitions, targeting, and physics. + """ + + def __init__(self, config: Config, assets: AssetsManager): + self.config = config + self.assets = assets + + # Screen boundaries + work_area = WindowManager.get_work_area() + self.screen_left = work_area[0] + self.screen_top = work_area[1] + self.screen_right = work_area[2] + self.screen_bottom = work_area[3] + + # Floor position: Cat walks on top of the taskbar / bottom of work area + self.floor_y = float(self.screen_bottom - self.config.cat_height) + + # Position & motion + self.x = float(self.screen_left + 100) + self.y = self.floor_y + self.direction = 1.0 # 1.0 = right, -1.0 = left + self.facing_left = False + self.lift = 0.0 # Height above floor in pixels + + # State machine + self.state = CatState.WALK + self.state_start_time = time.time() + self.walk_start_time = time.time() + + # Blinking + self.is_blinking = False + self.next_blink_time = time.time() + random.uniform(BLINK_INTERVAL_MIN_MS, BLINK_INTERVAL_MAX_MS) / 1000.0 + + # Targeting & Attack run + self.target_window: Optional[WindowInfo] = None + self.target_close_coords: Optional[Tuple[int, int]] = None + self.run_start_pos = (0.0, 0.0) + self.run_target_pos = (0.0, 0.0) + self.run_duration = 0.8 + self.on_contact_callback: Optional[Callable[[WindowInfo], None]] = None + self.contact_triggered = False + + # Paw timeline + self.paw_start_time = 0.0 + + # Drag & Fall + self.drag_start_cursor = (0, 0) + self.drag_start_pos = (0.0, 0.0) + self.fall_start_y = 0.0 + self.fall_duration = 0.4 + + # Life action timer + self.last_action_type = "standing" + self.settle_duration = 5.0 + self.next_life_action_time = time.time() + 8.0 + + # Speeds in px/s + self.walk_speed_px = (WALK_SPEED / config.cat_width) * (config.cat_width * 5.0) + self.run_speed_px = (RUN_SPEED / config.cat_width) * (config.cat_width * 4.0) + + # ========================================================================== + # State Transitions & Targeting + # ========================================================================== + + def set_state(self, new_state: CatState) -> None: + """Transitions to a new state and resets state timer.""" + if self.state == new_state: + return + logger.debug(f"Cat state: {self.state.name} -> {new_state.name}") + self.state = new_state + self.state_start_time = time.time() + if new_state == CatState.WALK: + self.walk_start_time = time.time() + + def target_window_for_close(self, window_info: WindowInfo, on_contact: Callable[[WindowInfo], None]) -> bool: + """ + Commands the cat to target a distraction window. + Enters ALERT phase, then RUNS to the window's close button [X], + swipes it with its paw, and triggers on_contact callback. + """ + # Don't interrupt while being dragged or mid-strike + if self.state in (CatState.DRAG, CatState.PAW): + return False + + self.target_window = window_info + self.on_contact_callback = on_contact + self.contact_triggered = False + + # Wake up if sleeping or sitting + if self.state in (CatState.SLEEP, CatState.SIT): + self.set_state(CatState.WALK) + + close_x, close_y = window_info.close_button + self.target_close_coords = (close_x, close_y) + + # Face towards the target + cat_center_x = self.x + self.config.cat_width / 2.0 + self.facing_left = (close_x < cat_center_x) + self.direction = -1.0 if self.facing_left else 1.0 + + # Transition to ALERT state (cat perks up with '!' indicator) + self.set_state(CatState.ALERT) + return True + + def _start_attack_run(self) -> None: + """Begins diagonal run/jump trajectory toward the close button.""" + if not self.target_close_coords: + self.set_state(CatState.WALK) + return + + tx, ty = self.target_close_coords + w = float(self.config.cat_width) + h = float(self.config.cat_height) + + # Workcat contact offset formula: + # Aligns the cat so that at Frame 14 the paw tip is on (tx, ty) + if self.facing_left: + dest_x = tx - w * (1.0 - CONTACT_RATIO_X) + else: + dest_x = tx - w * CONTACT_RATIO_X + dest_y = ty - h * CONTACT_RATIO_Y + + # Clamp within desktop boundaries + dest_x = max(float(self.screen_left), min(float(self.screen_right - w), dest_x)) + dest_y = max(float(self.screen_top), min(float(self.screen_bottom - h), dest_y)) + + self.run_start_pos = (self.x, self.y) + self.run_target_pos = (dest_x, dest_y) + + # Calculate distance and scale speed so sprint duration is within 620-1150ms + dist = math.hypot(dest_x - self.x, dest_y - self.y) + natural_sec = dist / max(100.0, self.run_speed_px) + self.run_duration = max(APPROACH_MIN_MS / 1000.0, min(APPROACH_MAX_MS / 1000.0, natural_sec)) + + self.set_state(CatState.RUN) + + def _trigger_paw_strike(self) -> None: + """Starts the 17-frame paw swipe animation at 24fps.""" + self.set_state(CatState.PAW) + self.paw_start_time = time.time() + self.contact_triggered = False + + # ========================================================================== + # Mouse Drag & Pet Interaction + # ========================================================================== + + def on_mouse_down(self, cursor_x: int, cursor_y: int) -> None: + """Called when user presses mouse button down on the cat.""" + if self.state == CatState.PAW: + return + + self.drag_start_cursor = (cursor_x, cursor_y) + self.drag_start_pos = (self.x, self.y) + # Wake up immediately if sleeping or sitting + if self.state in (CatState.SLEEP, CatState.SIT): + self.set_state(CatState.WALK) + + def on_mouse_move(self, cursor_x: int, cursor_y: int) -> None: + """Tracks dragging when mouse moves beyond threshold.""" + dx = cursor_x - self.drag_start_cursor[0] + dy = cursor_y - self.drag_start_cursor[1] + dist = math.hypot(dx, dy) + + if self.state != CatState.DRAG: + if dist >= 7: # DRAG_THRESHOLD_PX + self.set_state(CatState.DRAG) + + if self.state == CatState.DRAG: + new_x = self.drag_start_pos[0] + dx + new_y = self.drag_start_pos[1] + dy + # Keep on screen + self.x = max(float(self.screen_left), min(float(self.screen_right - self.config.cat_width), new_x)) + self.y = max(float(self.screen_top), min(float(self.screen_bottom - self.config.cat_height), new_y)) + self.lift = max(0.0, self.floor_y - self.y) + + def on_mouse_up(self, cursor_x: int, cursor_y: int) -> None: + """Handles drop after dragging or petting click.""" + if self.state == CatState.DRAG: + # Drop the cat with gravity + self.set_state(CatState.FALL) + self.fall_start_y = self.y + self.fall_duration = max(0.2, min(0.6, math.sqrt((self.floor_y - self.y) / 500.0))) + elif self.state != CatState.PAW: + # Clicked without dragging -> Pet the cat! + self.set_state(CatState.HAPPY) + + # ========================================================================== + # Frame Update Loop + # ========================================================================== + + def update(self, dt: float) -> Image.Image: + """ + Updates kinematics, state machine, and renders the active composite frame. + Returns 32-bit RGBA PIL Image. + """ + now = time.time() + coat = self.config.coat + + # Handle Blinking + if now >= self.next_blink_time and not self.is_blinking: + if self.state not in (CatState.SLEEP, CatState.HAPPY): + self.is_blinking = True + self.next_blink_time = now + (BLINK_DURATION_MS / 1000.0) + elif self.is_blinking and now >= self.next_blink_time: + self.is_blinking = False + self.next_blink_time = now + random.uniform(BLINK_INTERVAL_MIN_MS, BLINK_INTERVAL_MAX_MS) / 1000.0 + + face_type = "happy" if self.state == CatState.HAPPY else ("blink" if self.is_blinking else "open") + + # ---------------------------------------------------------------------- + # State: WALK (roaming the desktop floor) + # ---------------------------------------------------------------------- + if self.state == CatState.WALK: + elapsed = now - self.walk_start_time + # 30 frames cycle + fps = (GAIT_FRAME_COUNT * WALK_SPEED * GAIT_CADENCE) / WALK_STRIDE # ~21.6 fps + frame_idx = int(elapsed * fps) % max(len(self.assets.walk_path_strings), 1) + + # Move horizontally + step = self.direction * self.walk_speed_px * dt + self.x += step + + # Bounce at screen edges + margin = 15.0 + if self.x >= self.screen_right - self.config.cat_width - margin: + self.x = self.screen_right - self.config.cat_width - margin + self.direction = -1.0 + self.facing_left = True + elif self.x <= self.screen_left + margin: + self.x = self.screen_left + margin + self.direction = 1.0 + self.facing_left = False + + # Check life actions + if now >= self.next_life_action_time: + self._choose_life_action() + + img = self.assets.get_walk_frame(frame_idx, coat, self.facing_left, face_type) + return self._composite_with_shadow(img, self.lift) + + # ---------------------------------------------------------------------- + # State: ALERT (spotted target window) + # ---------------------------------------------------------------------- + elif self.state == CatState.ALERT: + elapsed_ms = (now - self.state_start_time) * 1000.0 + if elapsed_ms >= ALERT_HOLD_MS: + self._start_attack_run() + + # Display paw frame 0 (alert stance) + alert bubble + base = self.assets.get_paw_frame(0, coat, self.facing_left) + return self._composite_with_alert(base) + + # ---------------------------------------------------------------------- + # State: RUN (sprinting / jumping toward [X]) + # ---------------------------------------------------------------------- + elif self.state == CatState.RUN: + elapsed = now - self.state_start_time + progress = min(1.0, elapsed / max(0.01, self.run_duration)) + + # Linear interpolation of trajectory to target close button + self.x = self.run_start_pos[0] + (self.run_target_pos[0] - self.run_start_pos[0]) * progress + self.y = self.run_start_pos[1] + (self.run_target_pos[1] - self.run_start_pos[1]) * progress + self.lift = max(0.0, self.floor_y - self.y) + + # Fast run stride frames + run_fps = (GAIT_FRAME_COUNT * RUN_SPEED * GAIT_CADENCE) / RUN_STRIDE + frame_idx = int(elapsed * run_fps) % max(len(self.assets.walk_path_strings), 1) + + if progress >= 1.0: + self._trigger_paw_strike() + + img = self.assets.get_walk_frame(frame_idx, coat, self.facing_left, face_type) + return self._composite_with_shadow(img, self.lift) + + # ---------------------------------------------------------------------- + # State: PAW (17 frames strike swipe) + # ---------------------------------------------------------------------- + elif self.state == CatState.PAW: + elapsed = now - self.paw_start_time + frame_idx = int(elapsed * PAW_FPS) + + # Frame 14 is the contact frame where paw strikes [X] + if frame_idx >= PAW_CONTACT_FRAME and not self.contact_triggered: + self.contact_triggered = True + if self.on_contact_callback and self.target_window: + try: + self.on_contact_callback(self.target_window) + except Exception as e: + logger.error(f"Error in on_contact_callback: {e}") + + # End of paw sequence + if frame_idx >= PAW_FRAME_COUNT: + # Settle and fall back to floor + self.set_state(CatState.FALL) + self.fall_start_y = self.y + self.fall_duration = max(0.2, math.sqrt(max(0.1, self.floor_y - self.y) / 400.0)) + return self.assets.get_paw_frame(0, coat, self.facing_left) + + img = self.assets.get_paw_frame(frame_idx, coat, self.facing_left) + return self._composite_with_shadow(img, self.lift) + + # ---------------------------------------------------------------------- + # State: DRAG (user picked up cat by scruff) + # ---------------------------------------------------------------------- + elif self.state == CatState.DRAG: + img = self.assets.get_scruff_frame(coat) + return self._composite_with_shadow(img, self.lift) + + # ---------------------------------------------------------------------- + # State: FALL (gravity drop after drag or jumping) + # ---------------------------------------------------------------------- + elif self.state == CatState.FALL: + elapsed = now - self.state_start_time + t = min(1.0, elapsed / max(0.01, self.fall_duration)) + # Quadratic acceleration (gravity) + self.y = self.fall_start_y + (self.floor_y - self.fall_start_y) * (t * t) + self.lift = max(0.0, self.floor_y - self.y) + + if t >= 1.0: + self.y = self.floor_y + self.lift = 0.0 + self.set_state(CatState.RELEASE) + + img = self.assets.get_paw_frame(0, coat, self.facing_left) + return self._composite_with_shadow(img, self.lift) + + # ---------------------------------------------------------------------- + # State: RELEASE (landing settle after falling) + # ---------------------------------------------------------------------- + elif self.state == CatState.RELEASE: + elapsed_ms = (now - self.state_start_time) * 1000.0 + if elapsed_ms >= RELEASE_SETTLE_MS: + self.set_state(CatState.WALK) + + img = self.assets.get_paw_frame(0, coat, self.facing_left) + return self._composite_with_shadow(img, 0.0) + + # ---------------------------------------------------------------------- + # State: HAPPY (petting purr & floating heart) + # ---------------------------------------------------------------------- + elif self.state == CatState.HAPPY: + elapsed_ms = (now - self.state_start_time) * 1000.0 + if elapsed_ms >= HAPPY_MS: + self.set_state(CatState.TAIL) + + # Walk frame 0 with happy smiling face + base = self.assets.get_walk_frame(0, coat, self.facing_left, "happy") + # Floating heart particle + progress = min(1.0, elapsed_ms / HAPPY_MS) + heart_alpha = 1.0 - progress + heart = self.assets.get_heart_particle(size=28, alpha=heart_alpha) + + canvas = Image.new("RGBA", (self.config.cat_width, self.config.cat_height), (0, 0, 0, 0)) + canvas.paste(base, (0, 0), base) + # Heart floats upwards + hx = int(self.config.cat_width * 0.65) + hy = int(self.config.cat_height * 0.15 - progress * 20.0) + canvas.paste(heart, (hx, max(0, hy)), heart) + return self._composite_with_shadow(canvas, 0.0) + + # ---------------------------------------------------------------------- + # State: SIT (sitting idle) + # ---------------------------------------------------------------------- + elif self.state == CatState.SIT: + elapsed = now - self.state_start_time + if elapsed >= self.settle_duration: + self.set_state(CatState.WALK) + # Sitting stance using walk frame 0 + img = self.assets.get_walk_frame(0, coat, self.facing_left, face_type) + return self._composite_with_shadow(img, 0.0) + + # ---------------------------------------------------------------------- + # State: SLEEP (sleeping with 'z' snoring) + # ---------------------------------------------------------------------- + elif self.state == CatState.SLEEP: + elapsed = now - self.state_start_time + if elapsed >= self.settle_duration: + self.set_state(CatState.WALK) + + base = self.assets.get_walk_frame(0, coat, self.facing_left, "blink") + # Animated zzz particle + zzz = self.assets.get_zzz_indicator(size=22) + canvas = Image.new("RGBA", (self.config.cat_width, self.config.cat_height), (0, 0, 0, 0)) + canvas.paste(base, (0, 0), base) + zx = int(self.config.cat_width * 0.65) + zy = int(self.config.cat_height * 0.10 + math.sin(elapsed * 2.0) * 4.0) + canvas.paste(zzz, (zx, max(0, zy)), zzz) + return self._composite_with_shadow(canvas, 0.0) + + # ---------------------------------------------------------------------- + # State: STRETCH (37 frames stretch) + # ---------------------------------------------------------------------- + elif self.state == CatState.STRETCH: + elapsed = now - self.state_start_time + frame_idx = int(elapsed * LIFE_MOTION_FPS) + if frame_idx >= LIFE_MOTION_FRAME_COUNT: + self.set_state(CatState.WALK) + return self.assets.get_walk_frame(0, coat, self.facing_left, face_type) + + img = self.assets.get_stretch_frame(frame_idx, coat, self.facing_left) + return self._composite_with_shadow(img, 0.0) + + # ---------------------------------------------------------------------- + # State: TAIL (37 frames tail flick) + # ---------------------------------------------------------------------- + elif self.state == CatState.TAIL: + elapsed = now - self.state_start_time + frame_idx = int(elapsed * LIFE_MOTION_FPS) + if frame_idx >= LIFE_MOTION_FRAME_COUNT: + self.set_state(CatState.WALK) + return self.assets.get_walk_frame(0, coat, self.facing_left, face_type) + + img = self.assets.get_tail_frame(frame_idx, coat, self.facing_left) + return self._composite_with_shadow(img, 0.0) + + # Fallback + return self.assets.get_walk_frame(0, coat, self.facing_left, face_type) + + # ========================================================================== + # Life Action Scheduling + # ========================================================================== + + def _choose_life_action(self) -> None: + """Selects next autonomous idle action based on Workcat action weights.""" + posture = "sitting" if self.state == CatState.SIT else "standing" + weights = ACTION_WEIGHTS.get(posture, ACTION_WEIGHTS["standing"]) + actions = list(weights.keys()) + total = sum(weights.values()) + + cursor = random.uniform(0.0, total) + chosen = "walk" + for act in actions: + cursor -= weights[act] + if cursor <= 0: + chosen = act + break + + if chosen == "sit": + self.settle_duration = random.uniform(SIT_MIN_MS, SIT_MAX_MS) / 1000.0 + self.set_state(CatState.SIT) + self.next_life_action_time = time.time() + self.settle_duration + (SETTLED_ACTION_INTERVAL_MS / 1000.0) + elif chosen == "sleep": + self.settle_duration = random.uniform(SLEEP_MIN_MS, SLEEP_MAX_MS) / 1000.0 + self.set_state(CatState.SLEEP) + self.next_life_action_time = time.time() + self.settle_duration + (SETTLED_ACTION_INTERVAL_MS / 1000.0) + elif chosen == "stretch": + self.set_state(CatState.STRETCH) + self.next_life_action_time = time.time() + (LIFE_MOTION_FRAME_COUNT / LIFE_MOTION_FPS) + 6.0 + else: + self.set_state(CatState.WALK) + self.next_life_action_time = time.time() + (LIFE_ACTION_INTERVAL_MS / 1000.0) + + # ========================================================================== + # Compositing Helpers + # ========================================================================== + + def _composite_with_shadow(self, sprite: Image.Image, lift: float) -> Image.Image: + """Draws soft contact shadow underneath the cat sprite.""" + w = self.config.cat_width + h = self.config.cat_height + canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + + lift_ratio = min(1.0, lift / max(1.0, self.floor_y)) + shadow = self.assets.get_shadow(w, lift_ratio) + sx = (w - shadow.width) // 2 + sy = h - shadow.height - 1 + canvas.paste(shadow, (sx, sy), shadow) + + # Cat sprite on top + canvas.paste(sprite, (0, 0), sprite) + return canvas + + def _composite_with_alert(self, sprite: Image.Image) -> Image.Image: + """Paints alert exclamation mark bubble above the cat's head.""" + w = self.config.cat_width + h = self.config.cat_height + canvas = self._composite_with_shadow(sprite, 0.0) + + alert_bubble = self.assets.get_alert_bubble(size=30) + ax = int(w * 0.70) if not self.facing_left else int(w * 0.05) + ay = 2 + canvas.paste(alert_bubble, (ax, ay), alert_bubble) + return canvas