769 lines
34 KiB
Python
769 lines
34 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
|
||
|
||
# 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
|