Files
Catser/catser/cat_controller.py

816 lines
36 KiB
Python

"""
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
# Dynamic display topology signature & periodic checker
self._current_display_signature = None
self.next_display_check_time = time.time() + 1.5
self.refresh_display_bounds(force=True)
# 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)
def refresh_display_bounds(self, force: bool = False) -> bool:
"""
Dynamically refreshes monitor geometries, virtual desktop boundaries,
and clamps cat position if display topology or resolution changed.
Returns True if a display change occurred, False otherwise.
"""
new_signature = WindowManager.get_display_topology_signature()
if not force and new_signature == self._current_display_signature:
return False
self._current_display_signature = new_signature
# Update 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]
# Update full virtual desktop spanning all monitors
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]
# Update horizontal roaming boundaries
self.min_x = float(self.vd_left + 15)
self.max_x = float(self.vd_right - self.config.cat_width - 15)
monitors = WindowManager.get_monitors()
logger.info(
f"Dynamic display configuration updated: {len(monitors)} monitor(s) detected, "
f"virtual desktop=({self.vd_left}, {self.vd_top}, {self.vd_right}, {self.vd_bottom}), "
f"primary work area={work_area}"
)
# Ensure the cat is not stranded outside newly active bounds
if hasattr(self, "x") and hasattr(self, "y"):
prev_x = self.x
self.x = max(self.min_x, min(self.max_x, self.x))
# Re-evaluate floor at current position
new_floor = WindowManager.get_floor_y_at(self.x + self.config.cat_width * 0.5, self.config.cat_height)
self.floor_y = new_floor
# If walking or resting on floor, adjust Y to new floor
if hasattr(self, "current_ledge") and not self.current_ledge:
if hasattr(self, "state") and self.state in (CatState.WALK, CatState.SIT, CatState.SLEEP, CatState.RELEASE):
self.y = new_floor
if prev_x != self.x:
logger.info(f"Clamped cat X from {prev_x} to {self.x} due to display boundary change")
return True
# ==========================================================================
# 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")
# Dynamic check for monitor addition/removal, resolution or work-area change
if now >= self.next_display_check_time:
self.next_display_check_time = now + 1.5
self.refresh_display_bounds()
# ----------------------------------------------------------------------
# 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