feat(controller): add jump state, window platform walking, and multi-monitor physics navigation

This commit is contained in:
2026-09-08 23:44:43 +02:00
parent 5d152db057
commit dc2d6ee9eb
+250 -50
View File
@@ -53,7 +53,7 @@ from .config import (
LIFE_MOTION_FRAME_COUNT, LIFE_MOTION_FRAME_COUNT,
) )
from .assets_manager import AssetsManager from .assets_manager import AssetsManager
from .window_manager import WindowManager, WindowInfo from .window_manager import WindowManager, WindowInfo, WindowLedge
logger = logging.getLogger("catser.controller") logger = logging.getLogger("catser.controller")
@@ -72,6 +72,7 @@ class CatState(Enum):
SLEEP = auto() SLEEP = auto()
STRETCH = auto() STRETCH = auto()
TAIL = auto() TAIL = auto()
JUMP = auto()
class CatController: class CatController:
@@ -83,30 +84,40 @@ class CatController:
self.config = config self.config = config
self.assets = assets self.assets = assets
# Primary monitor work area (excludes taskbar) used for walk/roam floor clamping # Primary monitor work area (excludes taskbar)
work_area = WindowManager.get_work_area() work_area = WindowManager.get_work_area()
self.screen_left = work_area[0] self.screen_left = work_area[0]
self.screen_top = work_area[1] self.screen_top = work_area[1]
self.screen_right = work_area[2] self.screen_right = work_area[2]
self.screen_bottom = work_area[3] self.screen_bottom = work_area[3]
# Full virtual desktop spanning ALL monitors used for drag clamping so # Full virtual desktop spanning ALL monitors used for roaming, platforming,
# the user can pick up the cat and carry it to a second monitor. # and dragging so the cat can freely explore secondary displays.
vd = WindowManager.get_virtual_desktop_bounds() vd = WindowManager.get_virtual_desktop_bounds()
self.vd_left = vd[0] self.vd_left = vd[0]
self.vd_top = vd[1] self.vd_top = vd[1]
self.vd_right = vd[2] self.vd_right = vd[2]
self.vd_bottom = vd[3] self.vd_bottom = vd[3]
# Floor position: Cat walks on top of the taskbar / bottom of work area # Horizontal roaming boundaries across all monitors
self.floor_y = float(self.screen_bottom - self.config.cat_height) self.min_x = float(self.vd_left + 15)
self.max_x = float(self.vd_right - self.config.cat_width - 15)
# Position & motion # Position & motion
self.x = float(self.screen_left + 100) 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.y = self.floor_y
self.direction = 1.0 # 1.0 = right, -1.0 = left self.direction = 1.0 # 1.0 = right, -1.0 = left
self.facing_left = False self.facing_left = False
self.lift = 0.0 # Height above floor in pixels 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 # State machine
self.state = CatState.WALK self.state = CatState.WALK
@@ -206,9 +217,9 @@ class CatController:
dest_x = tx - w * CONTACT_RATIO_X dest_x = tx - w * CONTACT_RATIO_X
dest_y = ty - h * CONTACT_RATIO_Y dest_y = ty - h * CONTACT_RATIO_Y
# Clamp within desktop boundaries # Clamp within virtual desktop boundaries so cat can attack across all monitors
dest_x = max(float(self.screen_left), min(float(self.screen_right - w), dest_x)) dest_x = max(float(self.vd_left), min(float(self.vd_right - w), dest_x))
dest_y = max(float(self.screen_top), min(float(self.screen_bottom - h), dest_y)) 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_start_pos = (self.x, self.y)
self.run_target_pos = (dest_x, dest_y) self.run_target_pos = (dest_x, dest_y)
@@ -220,6 +231,46 @@ class CatController:
self.set_state(CatState.RUN) 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: def _trigger_paw_strike(self) -> None:
"""Starts the 17-frame paw swipe animation at 24fps.""" """Starts the 17-frame paw swipe animation at 24fps."""
self.set_state(CatState.PAW) self.set_state(CatState.PAW)
@@ -258,15 +309,18 @@ class CatController:
# self.vd_* covers the combined bounds of all connected displays. # 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.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)) self.y = max(float(self.vd_top), min(float(self.vd_bottom - self.config.cat_height), new_y))
self.lift = max(0.0, self.floor_y - self.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: def on_mouse_up(self, cursor_x: int, cursor_y: int) -> None:
"""Handles drop after dragging or petting click.""" """Handles drop after dragging or petting click."""
if self.state == CatState.DRAG: if self.state == CatState.DRAG:
# Drop the cat with gravity # Drop the cat with gravity
self.set_state(CatState.FALL) self.set_state(CatState.FALL)
self.fall_start_y = self.y self.vy = 0.0
self.fall_duration = max(0.2, min(0.6, math.sqrt((self.floor_y - self.y) / 500.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: elif self.state != CatState.PAW:
# Clicked without dragging -> Pet the cat! # Clicked without dragging -> Pet the cat!
self.set_state(CatState.HAPPY) self.set_state(CatState.HAPPY)
@@ -295,7 +349,7 @@ class CatController:
face_type = "happy" if self.state == CatState.HAPPY else ("blink" if self.is_blinking else "open") face_type = "happy" if self.state == CatState.HAPPY else ("blink" if self.is_blinking else "open")
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# State: WALK (roaming the desktop floor) # State: WALK (roaming desktop floors and window ledges)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
if self.state == CatState.WALK: if self.state == CatState.WALK:
elapsed = now - self.walk_start_time elapsed = now - self.walk_start_time
@@ -303,18 +357,95 @@ class CatController:
fps = (GAIT_FRAME_COUNT * WALK_SPEED * GAIT_CADENCE) / WALK_STRIDE # ~21.6 fps 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) frame_idx = int(elapsed * fps) % max(len(self.assets.walk_path_strings), 1)
# Move horizontally cat_center_x = self.x + self.config.cat_width * 0.5
step = self.direction * self.walk_speed_px * dt
self.x += step
# Bounce at screen edges # 1. Platform Ledge Navigation (cat is walking on top of an app window)
margin = 15.0 if self.current_ledge:
if self.x >= self.screen_right - self.config.cat_width - margin: # If cat stepped off ledge boundaries or window moved/disappeared:
self.x = self.screen_right - self.config.cat_width - margin 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.direction = -1.0
self.facing_left = True self.facing_left = True
elif self.x <= self.screen_left + margin: elif self.x <= self.min_x:
self.x = self.screen_left + margin self.x = self.min_x
self.direction = 1.0 self.direction = 1.0
self.facing_left = False self.facing_left = False
@@ -377,10 +508,10 @@ class CatController:
# End of paw sequence # End of paw sequence
if frame_idx >= PAW_FRAME_COUNT: if frame_idx >= PAW_FRAME_COUNT:
# Settle and fall back to floor # Settle and fall back to surface beneath
self.set_state(CatState.FALL) self.set_state(CatState.FALL)
self.fall_start_y = self.y self.vy = 0.0
self.fall_duration = max(0.2, math.sqrt(max(0.1, self.floor_y - self.y) / 400.0)) self.current_ledge = None
return self.assets.get_paw_frame(0, coat, self.facing_left) return self.assets.get_paw_frame(0, coat, self.facing_left)
img = self.assets.get_paw_frame(frame_idx, coat, self.facing_left) img = self.assets.get_paw_frame(frame_idx, coat, self.facing_left)
@@ -397,20 +528,65 @@ class CatController:
# State: FALL (gravity drop after drag or jumping) # State: FALL (gravity drop after drag or jumping)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
elif self.state == CatState.FALL: elif self.state == CatState.FALL:
elapsed = now - self.state_start_time self.vy += self.config.gravity_px_s2 * dt
t = min(1.0, elapsed / max(0.01, self.fall_duration)) self.y += self.vy * dt
# 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: # Target landing surface directly beneath
self.y = self.floor_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)
if self.y >= surface_y:
self.y = surface_y
self.vy = 0.0
self.lift = 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) self.set_state(CatState.RELEASE)
img = self.assets.get_paw_frame(0, coat, self.facing_left) img = self.assets.get_paw_frame(0, coat, self.facing_left)
return self._composite_with_shadow(img, self.lift) 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) # State: RELEASE (landing settle after falling)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -449,9 +625,15 @@ class CatController:
# State: SIT (sitting idle) # State: SIT (sitting idle)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
elif self.state == CatState.SIT: elif self.state == CatState.SIT:
elapsed = now - self.state_start_time surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height)
if elapsed >= self.settle_duration: if surface_y > self.y + 35.0:
self.set_state(CatState.WALK) 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 # Sitting stance using walk frame 0
img = self.assets.get_walk_frame(0, coat, self.facing_left, face_type) img = self.assets.get_walk_frame(0, coat, self.facing_left, face_type)
return self._composite_with_shadow(img, 0.0) return self._composite_with_shadow(img, 0.0)
@@ -460,9 +642,15 @@ class CatController:
# State: SLEEP (sleeping with 'z' snoring) # State: SLEEP (sleeping with 'z' snoring)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
elif self.state == CatState.SLEEP: elif self.state == CatState.SLEEP:
elapsed = now - self.state_start_time surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height)
if elapsed >= self.settle_duration: if surface_y > self.y + 35.0:
self.set_state(CatState.WALK) 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") base = self.assets.get_walk_frame(0, coat, self.facing_left, "blink")
# Animated zzz particle # Animated zzz particle
@@ -478,11 +666,17 @@ class CatController:
# State: STRETCH (37 frames stretch) # State: STRETCH (37 frames stretch)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
elif self.state == CatState.STRETCH: elif self.state == CatState.STRETCH:
elapsed = now - self.state_start_time surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height)
frame_idx = int(elapsed * LIFE_MOTION_FPS) if surface_y > self.y + 35.0:
if frame_idx >= LIFE_MOTION_FRAME_COUNT: self.current_ledge = None
self.set_state(CatState.WALK) self.vy = 50.0
return self.assets.get_walk_frame(0, coat, self.facing_left, face_type) 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) img = self.assets.get_stretch_frame(frame_idx, coat, self.facing_left)
return self._composite_with_shadow(img, 0.0) return self._composite_with_shadow(img, 0.0)
@@ -491,11 +685,17 @@ class CatController:
# State: TAIL (37 frames tail flick) # State: TAIL (37 frames tail flick)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
elif self.state == CatState.TAIL: elif self.state == CatState.TAIL:
elapsed = now - self.state_start_time surface_y = WindowManager.get_surface_beneath(self.x, self.y, self.config.cat_width, self.config.cat_height)
frame_idx = int(elapsed * LIFE_MOTION_FPS) if surface_y > self.y + 35.0:
if frame_idx >= LIFE_MOTION_FRAME_COUNT: self.current_ledge = None
self.set_state(CatState.WALK) self.vy = 50.0
return self.assets.get_walk_frame(0, coat, self.facing_left, face_type) 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) img = self.assets.get_tail_frame(frame_idx, coat, self.facing_left)
return self._composite_with_shadow(img, 0.0) return self._composite_with_shadow(img, 0.0)
@@ -547,7 +747,7 @@ class CatController:
h = self.config.canvas_height h = self.config.canvas_height
canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0)) canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0))
lift_ratio = min(1.0, lift / max(1.0, self.floor_y)) lift_ratio = min(1.0, max(0.0, lift / 250.0))
shadow = self.assets.get_shadow(self.config.cat_width, lift_ratio) shadow = self.assets.get_shadow(self.config.cat_width, lift_ratio)
sx = self.config.pad_x + (self.config.cat_width - shadow.width) // 2 sx = self.config.pad_x + (self.config.cat_width - shadow.width) // 2
sy = self.config.pad_y + self.config.cat_height - shadow.height - 1 sy = self.config.pad_y + self.config.cat_height - shadow.height - 1