Files
Catser/catser/cat_controller.py
T

562 lines
23 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
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