diff --git a/catser/cat_controller.py b/catser/cat_controller.py index 44d094a..c617ca7 100644 --- a/catser/cat_controller.py +++ b/catser/cat_controller.py @@ -53,7 +53,7 @@ from .config import ( LIFE_MOTION_FRAME_COUNT, ) from .assets_manager import AssetsManager -from .window_manager import WindowManager, WindowInfo +from .window_manager import WindowManager, WindowInfo, WindowLedge logger = logging.getLogger("catser.controller") @@ -72,6 +72,7 @@ class CatState(Enum): SLEEP = auto() STRETCH = auto() TAIL = auto() + JUMP = auto() class CatController: @@ -83,30 +84,40 @@ class CatController: self.config = config 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() 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 drag clamping so - # the user can pick up the cat and carry it to a second monitor. + # 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] - # Floor position: Cat walks on top of the taskbar / bottom of work area - self.floor_y = float(self.screen_bottom - self.config.cat_height) + # 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 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 self.state = CatState.WALK @@ -206,9 +217,9 @@ class CatController: 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)) + # 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) @@ -220,6 +231,46 @@ class CatController: 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) @@ -258,15 +309,18 @@ class CatController: # 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)) - 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: """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))) + 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) @@ -295,7 +349,7 @@ class CatController: 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: 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 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 + cat_center_x = self.x + self.config.cat_width * 0.5 - # 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 + # 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.screen_left + margin: - self.x = self.screen_left + margin + elif self.x <= self.min_x: + self.x = self.min_x self.direction = 1.0 self.facing_left = False @@ -377,10 +508,10 @@ class CatController: # End of paw sequence 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.fall_start_y = self.y - self.fall_duration = max(0.2, math.sqrt(max(0.1, self.floor_y - self.y) / 400.0)) + 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) @@ -397,20 +528,65 @@ class CatController: # 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) + self.vy += self.config.gravity_px_s2 * dt + self.y += self.vy * dt - if t >= 1.0: - self.y = self.floor_y + # 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) # ---------------------------------------------------------------------- @@ -449,9 +625,15 @@ class CatController: # 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) + 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) @@ -460,9 +642,15 @@ class CatController: # 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) + 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 @@ -478,11 +666,17 @@ class CatController: # 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) + 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) @@ -491,11 +685,17 @@ class CatController: # 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) + 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) @@ -547,7 +747,7 @@ class CatController: h = self.config.canvas_height 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) sx = self.config.pad_x + (self.config.cat_width - shadow.width) // 2 sy = self.config.pad_y + self.config.cat_height - shadow.height - 1