""" 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, WindowLedge 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() JUMP = 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 # Primary monitor work area (excludes taskbar) 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] # Full virtual desktop spanning ALL monitors – used for roaming, platforming, # and dragging so the cat can freely explore secondary displays. vd = WindowManager.get_virtual_desktop_bounds() self.vd_left = vd[0] self.vd_top = vd[1] self.vd_right = vd[2] self.vd_bottom = vd[3] # Horizontal roaming boundaries across all monitors self.min_x = float(self.vd_left + 15) self.max_x = float(self.vd_right - self.config.cat_width - 15) # Position & motion self.x = float(self.screen_left + 100) self.floor_y = WindowManager.get_floor_y_at(self.x + self.config.cat_width * 0.5, self.config.cat_height) 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 surface in pixels # Physics velocities and window platforming state self.vx = 0.0 self.vy = 0.0 self.target_landing_y = self.floor_y self.current_ledge: Optional[WindowLedge] = None self.target_jump_ledge: Optional[WindowLedge] = None self.next_jump_check_time = time.time() + 4.0 # 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 virtual desktop boundaries so cat can attack across all monitors dest_x = max(float(self.vd_left), min(float(self.vd_right - w), dest_x)) dest_y = max(float(self.vd_top), min(float(self.vd_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 _initiate_jump(self, target_x: float, target_y: float, target_ledge: Optional[WindowLedge] = None) -> None: """ Initiates a parabolic jump trajectory to land at (target_x, target_y). Dynamically calculates launch velocities (vx, vy) based on gravity and elevation difference. """ g = self.config.gravity_px_s2 delta_y = self.y - target_y # positive if jumping UP to higher elevation delta_x = target_x - self.x if delta_y > 0: # Jumping UP to higher elevation (lower Y coordinate) apex_margin = 30.0 vy0 = -math.sqrt(2.0 * g * (delta_y + apex_margin)) t_up = -vy0 / g t_down = math.sqrt((2.0 * apex_margin) / g) t_flight = max(0.25, t_up + t_down) else: # Jumping DOWN or hopping horizontally vy0 = -180.0 # slight upward launch hop apex_margin = (vy0 * vy0) / (2.0 * g) t_up = -vy0 / g fall_dist = abs(delta_y) + apex_margin t_down = math.sqrt((2.0 * fall_dist) / g) t_flight = max(0.25, t_up + t_down) self.vx = delta_x / t_flight self.vy = vy0 self.target_landing_y = target_y self.target_jump_ledge = target_ledge self.current_ledge = None if self.vx < -10.0: self.facing_left = True self.direction = -1.0 elif self.vx > 10.0: self.facing_left = False self.direction = 1.0 self.set_state(CatState.JUMP) 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 # Clamp to FULL virtual desktop so the cat can be dragged to any monitor. # self.vd_* covers the combined bounds of all connected displays. self.x = max(float(self.vd_left), min(float(self.vd_right - self.config.cat_width), new_x)) self.y = max(float(self.vd_top), min(float(self.vd_bottom - self.config.cat_height), new_y)) surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height) self.lift = max(0.0, surface_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.vy = 0.0 self.current_ledge = None surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height) self.target_landing_y = surface_y 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 desktop floors and window ledges) # ---------------------------------------------------------------------- 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) cat_center_x = self.x + self.config.cat_width * 0.5 # 1. Platform Ledge Navigation (cat is walking on top of an app window) if self.current_ledge: # If cat stepped off ledge boundaries or window moved/disappeared: if cat_center_x < self.current_ledge.left - 15.0 or cat_center_x > self.current_ledge.right + 15.0: self.current_ledge = None self.vy = 50.0 self.target_landing_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height) self.set_state(CatState.FALL) else: # Move along ledge step = self.direction * self.walk_speed_px * dt self.x += step # Ledge edge turnaround ledge_margin = 20.0 if self.x >= self.current_ledge.right - self.config.cat_width - ledge_margin: self.x = self.current_ledge.right - self.config.cat_width - ledge_margin self.direction = -1.0 self.facing_left = True elif self.x <= self.current_ledge.left + ledge_margin: self.x = self.current_ledge.left + ledge_margin self.direction = 1.0 self.facing_left = False # Periodically consider jumping down from window ledge if now >= self.next_jump_check_time: self.next_jump_check_time = now + random.uniform(5.0, 10.0) if random.random() < 0.35: hop_x = self.x + self.direction * 90.0 floor_beneath = WindowManager.get_floor_y_at(hop_x + self.config.cat_width * 0.5, self.config.cat_height) self._initiate_jump(target_x=hop_x, target_y=floor_beneath) # 2. Floor Navigation (across all monitors) else: current_floor = WindowManager.get_floor_y_at(cat_center_x, self.config.cat_height) # Check if floor dropped out underneath (e.g. stepped from primary to lower secondary monitor) if current_floor > self.y + 40.0: self.target_landing_y = current_floor self.vy = 50.0 self.set_state(CatState.FALL) else: # Check if there is an elevation step-up ahead (e.g. secondary to higher primary monitor) ahead_x = cat_center_x + (self.direction * 60.0) floor_ahead = WindowManager.get_floor_y_at(ahead_x, self.config.cat_height) if floor_ahead < self.y - 40.0: # Step-up detected! Leap onto the higher monitor floor jump_target_x = ahead_x + self.direction * 50.0 self._initiate_jump(target_x=jump_target_x, target_y=floor_ahead) else: # Standard walking along floor step = self.direction * self.walk_speed_px * dt self.x += step # Snap Y to current monitor floor self.y = current_floor self.floor_y = current_floor # Check autonomous window jump if self.config.enable_window_platforms and now >= self.next_jump_check_time: self.next_jump_check_time = now + random.uniform(3.5, 7.5) if random.random() < self.config.platform_jump_chance: ledges = WindowManager.get_window_ledges() reachable = [] for ledge in ledges: height_diff = (self.y + self.config.cat_height) - ledge.top_y if 60.0 <= height_diff <= 380.0: if self.direction > 0 and (ledge.left <= cat_center_x + 350.0 and ledge.right >= cat_center_x): reachable.append(ledge) elif self.direction < 0 and (ledge.right >= cat_center_x - 350.0 and ledge.left <= cat_center_x): reachable.append(ledge) if reachable: chosen_ledge = random.choice(reachable) min_lx = chosen_ledge.left + 20.0 max_lx = chosen_ledge.right - self.config.cat_width - 20.0 if max_lx >= min_lx: target_lx = max(min_lx, min(max_lx, self.x + self.direction * 120.0)) target_ly = float(chosen_ledge.top_y - self.config.cat_height) self._initiate_jump(target_x=target_lx, target_y=target_ly, target_ledge=chosen_ledge) # Bounce at virtual desktop outer boundaries if self.x >= self.max_x: self.x = self.max_x self.direction = -1.0 self.facing_left = True elif self.x <= self.min_x: self.x = self.min_x 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 surface beneath self.set_state(CatState.FALL) self.vy = 0.0 self.current_ledge = None 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: self.vy += self.config.gravity_px_s2 * dt self.y += self.vy * dt # Target landing surface directly beneath surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height) self.lift = max(0.0, surface_y - self.y) if self.y >= surface_y: self.y = surface_y self.vy = 0.0 self.lift = 0.0 self.floor_y = self.y self.current_ledge = None # Check if landed on a window ledge cat_center_x = self.x + self.config.cat_width * 0.5 if self.config.enable_window_platforms: for ledge in WindowManager.get_window_ledges(): if abs((self.y + self.config.cat_height) - ledge.top_y) <= 20.0 and (ledge.left <= cat_center_x <= ledge.right): self.current_ledge = ledge break 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: JUMP (parabolic leap onto window ledge or between monitors) # ---------------------------------------------------------------------- elif self.state == CatState.JUMP: self.x += self.vx * dt self.y += self.vy * dt self.vy += self.config.gravity_px_s2 * dt # Clamp within virtual desktop horizontally self.x = max(self.min_x, min(self.max_x, self.x)) # Lift relative to target landing surface self.lift = max(0.0, self.target_landing_y - self.y) # Check landing condition when falling downwards if self.vy > 0 and self.y >= self.target_landing_y: self.y = self.target_landing_y self.vy = 0.0 self.vx = 0.0 self.lift = 0.0 self.floor_y = self.y self.current_ledge = self.target_jump_ledge self.target_jump_ledge = None self.set_state(CatState.RELEASE) # Sprite: Reaching paw stance while ascending, landing walk stance while descending if self.vy < 0: img = self.assets.get_paw_frame(0, coat, self.facing_left) else: img = self.assets.get_walk_frame(1, coat, self.facing_left, face_type) 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.canvas_width, self.config.canvas_height), (0, 0, 0, 0)) canvas.paste(base, (0, 0), base) # Heart floats upwards hx = self.config.pad_x + int(self.config.cat_width * 0.65) hy = self.config.pad_y + 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: surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height) if surface_y > self.y + 35.0: self.current_ledge = None self.vy = 50.0 self.set_state(CatState.FALL) else: 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: surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height) if surface_y > self.y + 35.0: self.current_ledge = None self.vy = 50.0 self.set_state(CatState.FALL) else: 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.canvas_width, self.config.canvas_height), (0, 0, 0, 0)) canvas.paste(base, (0, 0), base) zx = self.config.pad_x + int(self.config.cat_width * 0.65) zy = self.config.pad_y + 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: surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height) if surface_y > self.y + 35.0: self.current_ledge = None self.vy = 50.0 self.set_state(CatState.FALL) else: 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: surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height) if surface_y > self.y + 35.0: self.current_ledge = None self.vy = 50.0 self.set_state(CatState.FALL) else: 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.canvas_width h = self.config.canvas_height canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0)) lift_ratio = min(1.0, max(0.0, lift / 250.0)) shadow = self.assets.get_shadow(self.config.cat_width, lift_ratio) sx = self.config.pad_x + (self.config.cat_width - shadow.width) // 2 sy = self.config.pad_y + self.config.cat_height - 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.""" canvas = self._composite_with_shadow(sprite, 0.0) alert_bubble = self.assets.get_alert_bubble(size=30) ax = self.config.pad_x + (int(self.config.cat_width * 0.70) if not self.facing_left else int(self.config.cat_width * 0.05)) ay = self.config.pad_y + 2 canvas.paste(alert_bubble, (ax, ay), alert_bubble) return canvas