Compare commits

..
20 Commits
Author SHA1 Message Date
me0nline ef919284ae build: ignore release/ artifact directory 2026-09-09 00:21:46 +02:00
me0nline 6de406787c docs: add MIT license with Workcat asset attribution 2026-09-09 00:21:10 +02:00
me0nline 8aa2e63998 build: add PyInstaller build specification with embedded assets and icon 2026-09-09 00:21:05 +02:00
me0nline 81f190b936 feat(assets): add multi-resolution Windows application icon catser.ico 2026-09-09 00:21:01 +02:00
me0nline 6f678ec0a4 feat(assets): support PyInstaller sys._MEIPASS frozen asset bundle path 2026-09-09 00:19:35 +02:00
me0nline c8f12b26c1 docs: document dynamic monitor hotplug and resolution detection 2026-09-08 23:47:57 +02:00
me0nline 449c6bac9a feat(app): connect overlay on_display_change to controller.refresh_display_bounds 2026-09-08 23:47:32 +02:00
me0nline 8aeb5360f6 feat(controller): add refresh_display_bounds and dynamic resolution and monitor change detection 2026-09-08 23:47:22 +02:00
me0nline 3afd197027 feat(overlay): handle WM_DISPLAYCHANGE and WM_SETTINGCHANGE for dynamic display updates 2026-09-08 23:46:55 +02:00
me0nline 0033fbce9b feat(window_manager): add get_display_topology_signature for dynamic monitor and resolution change detection 2026-09-08 23:46:30 +02:00
me0nline 59ba4b988f docs: document window ledge platforming, jumping kinematics, and multi-monitor roaming 2026-09-08 23:45:00 +02:00
me0nline dc2d6ee9eb feat(controller): add jump state, window platform walking, and multi-monitor physics navigation 2026-09-08 23:44:43 +02:00
me0nline 5d152db057 feat(window_manager): add multi-monitor awareness, floor calculation, and window ledge platform extraction 2026-09-08 23:41:25 +02:00
me0nline 71fe013c80 feat(config): add physics and window platforming parameters 2026-09-08 23:40:33 +02:00
me0nline a969c91c28 feat(aw): scan both active foreground and visible desktop windows for distractions
Previously, fallback window detection only checked user32.GetForegroundWindow().
If the user had YouTube Shorts open in Chrome on a second monitor or behind the terminal,
it was ignored.

Now check_for_distraction():
1. Checks the active foreground window first.
2. If not a distraction, scans all visible top-level desktop windows via find_window_by_match().
3. Immediately wakes the cat from sleeping or sitting if any distraction window is discovered.
2026-09-08 23:38:00 +02:00
me0nline 6204a88792 feat(window_manager): use OpenInputDesktop in find_window_by_match and exclude Catser overlay
Ensures desktop window enumeration accesses the user's interactive desktop station
across multiple monitors and ignores Catser's own overlay window.
2026-09-08 23:37:54 +02:00
me0nline ff8cc01dd8 feat(config): increase sleep duration to 1-4 minutes and expand default distraction keywords
1. Increased SLEEP_MIN_MS to 60s and SLEEP_MAX_MS to 240s so the cat takes real
   naps (1-4 min) instead of waking up after only a few seconds.
2. Increased SIT_MIN_MS to 15s and SIT_MAX_MS to 45s.
3. Added 'youtube', 'youtube.com/shorts', 'instagram', and 'twitch' to default
   distraction keywords. Browser tabs playing YouTube Shorts have titles like
   '<Video Title> - YouTube - Google Chrome' where 'shorts' does not appear.
2026-09-08 23:37:49 +02:00
me0nline e439e638da fix(app): initialize overlay with canvas dimensions and offset draw_frame by padding 2026-09-08 23:33:20 +02:00
me0nline 3b202c8e68 fix(controller): update particle positioning and compositing for padded canvas 2026-09-08 23:33:16 +02:00
me0nline 78b3f949c3 fix(assets): unified padded canvas eliminates tail clipping and fixes animation scaling
Workcat's tail flick (-15% left), paw reach (+9% right), and stretch motions
extend beyond the nominal cat bounding box. Previously, frames were drawn
onto a compact 144x110 canvas without padding, causing negative offsets
(e.g. ox=-22px on tail) to truncate the tail tip and create mismatched scales.

Fixes:
- Added padding (pad_x=20%, pad_y=20%) to the render canvas so all extended
  poses (tail swings to x=16, paw strikes to x=185) fit completely without clipping.
- Unified the coordinate system: walk, tail, stretch, paw, and scruff now share
  the exact same body scale (height ~93-94px) and feet baseline (y=128).
2026-09-08 23:33:12 +02:00
12 changed files with 732 additions and 172 deletions
+1 -1
View File
@@ -8,8 +8,8 @@ env/
venv/
.venv/
build/
develop-eggs/
dist/
release/
downloads/
eggs/
.eggs/
+39
View File
@@ -0,0 +1,39 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['run_catser.py'],
pathex=[],
binaries=[],
datas=[('catser/assets', 'catser/assets')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Catser',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['catser.ico'],
)
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 Catser Contributors
Sprite assets courtesy of Workcat (https://workcat.app)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19 -2
View File
@@ -29,9 +29,20 @@ A Windows desktop cat companion inspired by [Workcat](https://workcat.app/en/#ho
$$\text{offset}_y = \text{height} \times 0.3874$$
- Sprints diagonally across your screen to reach the close button, strikes it with its paw, and dispatches native `WM_CLOSE` to close the distracting app.
- 🪟 **Window Platforming & Ledge Walking**:
- Treats the top frames of visible open application windows (browsers, editors, terminals) as physical platforms.
- Cat autonomously performs parabolic leaps onto window ledges, walks along them, sits, naps, and hops back down.
- Dynamic surface tracking: If an underlying window moves or closes while the cat is resting on it, the cat wakes up and falls under gravity to the next surface or floor.
- 🖥️ **Dynamic Multi-Monitor & Resolution Detection**:
- Full virtual desktop support spanning all connected displays with arbitrary resolutions, positions, and DPI scaling.
- **Live Hotplug Detection**: Listens to Win32 `WM_DISPLAYCHANGE` and `WM_SETTINGCHANGE` system events (with a 1.5s background check) to dynamically detect when monitors are connected, disconnected, rearranged, or when display resolution/scaling changes.
- Automatically recalculates screen bounds and taskbar floors in real-time, clamping the cat into valid active displays so it never gets stranded off-screen.
- Seamlessly navigates elevation differences between monitors (e.g. stepping off higher floors into gravity falls, or leaping up steep monitor steps).
- 🪟 **High-Performance Transparent Overlay**:
- Built with Win32 Layered Windows (`WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE`).
- Uses `UpdateLayeredWindow` with 32-bit ARGB premultiplied alpha for clean anti-aliased fur edges, soft contact shadows, and tear-free 60 FPS rendering.
- Uses `UpdateLayeredWindow` with 32-bit ARGB premultiplied alpha (vectorized with numpy) for clean anti-aliased fur edges, soft contact shadows, and tear-free 60 FPS rendering.
- Pixel-perfect hit-testing allows mouse clicks on transparent regions to pass through to your apps, while clicking the cat itself allows dragging and petting.
---
@@ -127,13 +138,19 @@ Catser/
## Technical Details
### Kinematic & Physics Constants
Sourced directly from Workcat's engine (`apps/desktop/src/features/pet-mode/petModeData.ts` and `cat.js`):
Sourced directly from Workcat's engine (`apps/desktop/src/features/pet-mode/petModeData.ts` and `cat.js`) and desktop physics kinematics:
- `WALK_SPEED = 20.8` (stride = `26.0`, cadence = `0.9`)
- `RUN_SPEED = 300.0` (stride = `72.0`)
- `PAW_FPS = 24` (17 total frames, contact at index `14`)
- `CONTACT_RATIO_X = (143.0 - 11.4) / 121.2 ≈ 1.0858`
- `CONTACT_RATIO_Y = (40.0 - 4.2) / 92.4 ≈ 0.3874`
- `ALERT_HOLD_MS = 620ms`
- `GRAVITY = 1600.0 px/s²` (smooth parabolic acceleration during falls and jumps)
- `JUMP_VELOCITY = 650.0 px/s` (dynamic trajectory calculation solving launch velocity $(v_x, v_y)$ for any elevation difference)
### Window Platforming & Multi-Monitor Support
- Uses Win32 `OpenInputDesktop` and `EnumDesktopWindows` to extract visible top frames of non-minimized desktop windows as physical ledges.
- Tracks multi-display monitor geometries via `EnumDisplayMonitors` / `GetMonitorInfoW` to support arbitrary screen resolutions and vertical offsets.
### Win32 Window Closing
On contact frame 14, Catser issues both `WM_CLOSE` (`0x0010`) and `WM_SYSCOMMAND / SC_CLOSE` (`0x0112 / 0xF060`) to the target window's handle (`HWND`), ensuring smooth closing across standard Win32 apps and Chromium browsers.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+55 -36
View File
@@ -169,57 +169,76 @@ class ActivityWatchClient:
"""Signals the background worker to exit."""
self._running = False
def check_for_distraction(self) -> Optional[DistractionEvent]:
"""
Checks current active window against distraction rules.
Uses ActivityWatch if available, otherwise falls back to native Win32.
"""
app_name = ""
window_title = ""
active_window_info: Optional[WindowInfo] = None
# 1. Try ActivityWatch
aw_event = self.get_current_window_event()
if aw_event:
app_name = (aw_event.get("app") or "").lower()
window_title = aw_event.get("title") or ""
# Locate corresponding Win32 HWND for screen coordinates and close button
active_window_info = WindowManager.find_window_by_match(
title_predicate=lambda t: window_title.lower() in t.lower() or t.lower() in window_title.lower(),
process_predicate=lambda p: app_name in p.lower(),
)
# 2. Fallback to native Win32 if ActivityWatch is unavailable or HWND not found
if not active_window_info and self.config.auto_fallback_to_win32:
active_window_info = WindowManager.get_foreground_window_info()
if active_window_info:
app_name = active_window_info.process_name.lower()
window_title = active_window_info.title
if not active_window_info or not window_title:
return None
def _match_event(self, app_name: str, window_title: str, win_info: WindowInfo) -> Optional[DistractionEvent]:
"""Evaluates a window title and process name against distraction rules."""
title_lower = window_title.lower()
app_lower = app_name.lower()
# Check for title keyword matches (e.g. "shorts", "reels", "tiktok")
for kw in self.config.distraction_keywords:
if kw.lower() in title_lower:
return DistractionEvent(
app=app_name,
title=window_title,
matched_rule=f"keyword '{kw}'",
window_info=active_window_info,
window_info=win_info,
)
# Check for application process matches
for target_app in self.config.distraction_apps:
if target_app.lower() in app_name:
if target_app.lower() in app_lower:
return DistractionEvent(
app=app_name,
title=window_title,
matched_rule=f"app '{target_app}'",
window_info=active_window_info,
window_info=win_info,
)
return None
def check_for_distraction(self) -> Optional[DistractionEvent]:
"""
Checks current active window and visible background windows against distraction rules.
Uses ActivityWatch if available, otherwise falls back to native Win32.
"""
# 1. Try ActivityWatch (non-blocking read of cached event)
aw_event = self.get_current_window_event()
if aw_event:
app_name = (aw_event.get("app") or "").lower()
window_title = aw_event.get("title") or ""
active_window_info = WindowManager.find_window_by_match(
title_predicate=lambda t: window_title.lower() in t.lower() or t.lower() in window_title.lower(),
process_predicate=lambda p: app_name in p.lower(),
)
if active_window_info:
matched = self._match_event(app_name, window_title, active_window_info)
if matched:
return matched
if not self.config.auto_fallback_to_win32:
return None
# 2. Native Win32: Check foreground window first
fg_info = WindowManager.get_foreground_window_info()
if fg_info and fg_info.title:
matched = self._match_event(fg_info.process_name, fg_info.title, fg_info)
if matched:
return matched
# 3. Native Win32: If foreground window is not a distraction, check any visible desktop window!
# This catches distraction windows open in the background, on a 2nd monitor, or when user clicks away
def title_matches(t: str) -> bool:
tl = t.lower()
return any(kw.lower() in tl for kw in self.config.distraction_keywords)
def app_matches(p: str) -> bool:
pl = p.lower()
return any(a.lower() in pl for a in self.config.distraction_apps)
matched_window = WindowManager.find_window_by_match(
title_predicate=title_matches if self.config.distraction_keywords else None,
process_predicate=app_matches if self.config.distraction_apps else None,
)
if matched_window and matched_window.title:
return self._match_event(matched_window.process_name, matched_window.title, matched_window)
return None
+10 -5
View File
@@ -48,13 +48,14 @@ class CatserApp:
# 3. Transparent Overlay Window
self.overlay = OverlayWindow(
width=self.config.cat_width,
height=self.config.cat_height,
initial_x=int(self.controller.x),
initial_y=int(self.controller.y),
width=self.config.canvas_width,
height=self.config.canvas_height,
initial_x=int(self.controller.x - self.config.pad_x),
initial_y=int(self.controller.y - self.config.pad_y),
on_mouse_down=self.controller.on_mouse_down,
on_mouse_move=self.controller.on_mouse_move,
on_mouse_up=self.controller.on_mouse_up,
on_display_change=self.controller.refresh_display_bounds,
)
# 4. ActivityWatch Initial Ping
@@ -100,7 +101,11 @@ class CatserApp:
frame = self.controller.update(dt)
# 4. Update Layered Window Position & Pixels
self.overlay.draw_frame(frame, self.controller.x, self.controller.y)
self.overlay.draw_frame(
frame,
self.controller.x - self.config.pad_x,
self.controller.y - self.config.pad_y,
)
# 5. Check duration limit
if max_seconds and (now - start_time >= max_seconds):
+68 -43
View File
@@ -14,6 +14,7 @@ Assets managed:
"""
import os
import sys
import re
import json
import math
@@ -30,8 +31,11 @@ from .config import (
LIFE_MOTION_FRAME_COUNT,
CAT_BOX_WIDTH,
CAT_BOX_HEIGHT,
GAIT_SHIFT_X,
GAIT_SHIFT_Y,
Coat,
COATS,
Config,
)
logger = logging.getLogger("catser.assets")
@@ -45,9 +49,12 @@ def _download_file(url: str, dest_path: Path) -> bool:
Downloads a file with custom User-Agent to avoid Cloudflare 403 blocks.
Returns True on success, False otherwise.
"""
dest_path.parent.mkdir(parents=True, exist_ok=True)
if dest_path.exists() and dest_path.stat().st_size > 0:
return True
try:
dest_path.parent.mkdir(parents=True, exist_ok=True)
except Exception:
pass
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
@@ -80,10 +87,22 @@ class AssetsManager:
Manages downloading, caching, rasterizing, and scaling of all cat sprites.
"""
def __init__(self, assets_dir: Optional[Path] = None, cat_width: int = 144):
self.assets_dir = assets_dir or (Path(__file__).parent / "assets")
self.cat_width = cat_width
self.cat_height = int(round(cat_width * (CAT_BOX_HEIGHT / CAT_BOX_WIDTH)))
def __init__(self, assets_dir: Optional[Path] = None, cat_width: int = 144, config: Optional[Config] = None):
if assets_dir:
self.assets_dir = assets_dir
elif getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
mei_catser = Path(sys._MEIPASS) / "catser" / "assets"
self.assets_dir = mei_catser if mei_catser.exists() else (Path(sys._MEIPASS) / "assets")
else:
self.assets_dir = Path(__file__).parent / "assets"
self.config = config or Config(cat_width=cat_width)
self.cat_width = self.config.cat_width
self.cat_height = self.config.cat_height
self.canvas_width = self.config.canvas_width
self.canvas_height = self.config.canvas_height
self.pad_x = self.config.pad_x
self.pad_y = self.config.pad_y
self.scale = self.cat_width / CAT_BOX_WIDTH
# Memory cache for rendered frames keyed by (coat_name, frame_id, facing_left)
@@ -98,7 +117,10 @@ class AssetsManager:
def initialize(self) -> None:
"""Downloads all missing assets and prepares raw frames."""
self.assets_dir.mkdir(parents=True, exist_ok=True)
try:
self.assets_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
self._ensure_assets_downloaded()
self._load_raw_assets()
@@ -203,37 +225,41 @@ class AssetsManager:
target_w = self.cat_width
target_h = self.cat_height
canvas_w = self.canvas_width
canvas_h = self.canvas_height
pad_x = self.pad_x
pad_y = self.pad_y
# 4× supersampling for clean anti-aliased polygon edges
SS = 4
canvas_w = target_w * SS
canvas_h = target_h * SS
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
ss_w = canvas_w * SS
ss_h = canvas_h * SS
img = Image.new("RGBA", (ss_w, ss_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# The Workcat SVG walk frames use viewBox="0 0 150 120".
# Polygon coordinates span ~4145 in X and ~8112 in Y, fitting the viewBox.
# We simply map viewBox coords → supersampled canvas pixels.
# No GAIT_SHIFT or overcorrection needed: those are internal cat.js transforms
# that cancel out at the viewBox level.
# In Workcat:
# The SVG viewBox is 0 0 150 120, scaled to target_w x target_h,
# shifted by GAIT_SHIFT_X/Y relative to the cat unit (target_w / CAT_BOX_WIDTH),
# and placed with pad_x / pad_y inside the overlay canvas.
unit = (target_w / CAT_BOX_WIDTH) * SS
shift_x = pad_x * SS + GAIT_SHIFT_X * unit
shift_y = pad_y * SS + GAIT_SHIFT_Y * unit
scale_x = (target_w / 150.0) * SS
scale_y = (target_h / 120.0) * SS
if self.walk_path_strings:
pts = _parse_svg_polygon_points(self.walk_path_strings[frame_index])
ss_points = [(p[0] * scale_x, p[1] * scale_y) for p in pts]
ss_points = [(p[0] * scale_x + shift_x, p[1] * scale_y + shift_y) for p in pts]
fur_rgba = (*coat.fur_color, 255)
draw.polygon(ss_points, fill=fur_rgba)
# Face features — all coordinates in the same 150×120 viewBox space.
# Eyes: left at (120.4, 46.2), right at (140.3, 46.2), radius 2.2
# Mouth: W-shape centred around (131.6, 51.0)
# Face features — coordinates in 150x120 viewBox shifted into position
ink_rgba = (*coat.ink_color, 255)
eye_l_cx = 120.4 * scale_x
eye_l_cy = 46.2 * scale_y
eye_r_cx = 140.3 * scale_x
eye_r_cy = 46.2 * scale_y
eye_l_cx = 120.4 * scale_x + shift_x
eye_l_cy = 46.2 * scale_y + shift_y
eye_r_cx = 140.3 * scale_x + shift_x
eye_r_cy = 46.2 * scale_y + shift_y
eye_r = 2.2 * scale_x
if face_type == "open":
@@ -255,15 +281,15 @@ class AssetsManager:
draw.arc([eye_l_cx - w_eye, eye_l_cy - h_eye, eye_l_cx + w_eye, eye_l_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS))
draw.arc([eye_r_cx - w_eye, eye_r_cy - h_eye, eye_r_cx + w_eye, eye_r_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS))
mouth_cx = 131.6 * scale_x
mouth_cy = 51.0 * scale_y
mouth_cx = 131.6 * scale_x + shift_x
mouth_cy = 51.0 * scale_y + shift_y
mw = 4.0 * scale_x
mh = 2.5 * scale_y
draw.arc([mouth_cx - mw, mouth_cy - mh, mouth_cx, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS))
draw.arc([mouth_cx, mouth_cy - mh, mouth_cx + mw, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS))
# Downsample to target resolution with high-quality Lanczos resampling
final_img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
# Downsample to canvas resolution with high-quality Lanczos resampling
final_img = img.resize((canvas_w, canvas_h), Image.Resampling.LANCZOS)
if facing_left:
final_img = final_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
@@ -271,7 +297,6 @@ class AssetsManager:
self._cache[cache_key] = final_img
return final_img
def get_paw_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
"""
Retrieves and tints a frame from the paw strike animation (f001-f017).
@@ -291,10 +316,10 @@ class AssetsManager:
ph = int(round(self.cat_height * 0.96833))
resized = tinted.resize((pw, ph), Image.Resampling.LANCZOS)
# Composite onto standard cat canvas with offsets
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
offset_x = int(round(self.cat_width * 0.02265))
offset_y = int(round(self.cat_height * -0.03083))
# Composite onto canvas with pad offsets
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
offset_x = self.pad_x + int(round(self.cat_width * 0.02265))
offset_y = self.pad_y + int(round(self.cat_height * -0.03083))
canvas.paste(resized, (offset_x, offset_y), resized)
if facing_left:
@@ -319,10 +344,9 @@ class AssetsManager:
sh = int(round(self.cat_height * 1.29))
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
# Center in canvas
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
ox = (self.cat_width - sw) // 2
oy = (self.cat_height - sh) // 2
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
ox = self.pad_x + (self.cat_width - sw) // 2
oy = self.pad_y + (self.cat_height - sh) // 2
canvas.paste(resized, (ox, oy), resized)
self._cache[cache_key] = canvas
@@ -331,7 +355,7 @@ class AssetsManager:
def get_tail_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
"""
Frame from the 37-frame tail flick life motion.
Geometry: left: -15.0564%, top: -14.2679%, width: 117.5657%, height: 115.3692%
Geometry from Workcat: left: -15.0564%, top: -14.2679%, width: 117.5657%, height: 115.3692%
"""
frame_index = max(0, min(frame_index, len(self.tail_images) - 1)) if self.tail_images else 0
cache_key = f"tail_{frame_index}_{coat.name}_{facing_left}"
@@ -345,9 +369,10 @@ class AssetsManager:
th = int(round(self.cat_height * 1.153692))
resized = tinted.resize((tw, th), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
ox = int(round(self.cat_width * -0.150564))
oy = int(round(self.cat_height * -0.142679))
# Composite onto padded canvas — tail extension is fully preserved
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
ox = self.pad_x + int(round(self.cat_width * -0.150564))
oy = self.pad_y + int(round(self.cat_height * -0.142679))
canvas.paste(resized, (ox, oy), resized)
if facing_left:
@@ -359,7 +384,7 @@ class AssetsManager:
def get_stretch_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
"""
Frame from the 37-frame stretch life motion.
Geometry: left: -4.5169%, top: 0.215%, width: 107.0263%, height: 100.8865%
Geometry from Workcat: left: -4.5169%, top: 0.215%, width: 107.0263%, height: 100.8865%
"""
frame_index = max(0, min(frame_index, len(self.stretch_images) - 1)) if self.stretch_images else 0
cache_key = f"stretch_{frame_index}_{coat.name}_{facing_left}"
@@ -373,9 +398,9 @@ class AssetsManager:
sh = int(round(self.cat_height * 1.008865))
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
ox = int(round(self.cat_width * -0.045169))
oy = int(round(self.cat_height * 0.00215))
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
ox = self.pad_x + int(round(self.cat_width * -0.045169))
oy = self.pad_y + int(round(self.cat_height * 0.00215))
canvas.paste(resized, (ox, oy), resized)
if facing_left:
+322 -77
View File
@@ -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,26 @@ class CatController:
self.config = config
self.assets = assets
# Primary monitor work area (excludes taskbar) used for walk/roam floor clamping
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.
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)
# 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 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
@@ -144,6 +141,62 @@ class CatController:
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
# ==========================================================================
@@ -206,9 +259,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 +273,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 +351,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)
@@ -294,8 +390,13 @@ class CatController:
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 the desktop floor)
# State: WALK (roaming desktop floors and window ledges)
# ----------------------------------------------------------------------
if self.state == CatState.WALK:
elapsed = now - self.walk_start_time
@@ -303,18 +404,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 +555,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 +575,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)
# ----------------------------------------------------------------------
@@ -437,11 +660,11 @@ class CatController:
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 = 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 = int(self.config.cat_width * 0.65)
hy = int(self.config.cat_height * 0.15 - progress * 20.0)
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)
@@ -449,9 +672,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,17 +689,23 @@ 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
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 = Image.new("RGBA", (self.config.canvas_width, self.config.canvas_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)
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)
@@ -478,11 +713,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 +732,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)
@@ -543,14 +790,14 @@ class CatController:
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
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, 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
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
@@ -559,12 +806,10 @@ class CatController:
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
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
+15 -6
View File
@@ -62,10 +62,10 @@ ACTION_WEIGHTS: Dict[str, Dict[str, int]] = {
# Settled action intervals (ms)
LIFE_ACTION_INTERVAL_MS: int = 9000
SETTLED_ACTION_INTERVAL_MS: int = 3400
SIT_MIN_MS: int = 3200
SIT_MAX_MS: int = 9000
SLEEP_MIN_MS: int = 6400
SLEEP_MAX_MS: int = 18000
SIT_MIN_MS: int = 15000
SIT_MAX_MS: int = 45000
SLEEP_MIN_MS: int = 60000 # 1 to 4 minutes of peaceful sleep
SLEEP_MAX_MS: int = 240000
# ==============================================================================
@@ -118,12 +118,15 @@ class Config:
auto_fallback_to_win32: bool = True
distraction_keywords: List[str] = field(default_factory=lambda: [
"shorts",
"youtube shorts",
"youtube.com/shorts",
"youtube",
"reels",
"instagram reels",
"instagram.com/reels",
"instagram",
"tiktok",
"reddit",
"twitch.tv",
"twitch",
])
distraction_apps: List[str] = field(default_factory=lambda: [
# Users can add specific processes e.g. "discord.exe", "steam.exe"
@@ -134,6 +137,12 @@ class Config:
sound_enabled: bool = True
cooldown_after_close_sec: float = 4.0
# Physics & Platforming parameters
gravity_px_s2: float = 1600.0 # Gravitational acceleration (px/s^2)
jump_velocity_px_s: float = 650.0 # Vertical launch velocity for jumps (px/s)
platform_jump_chance: float = 0.20 # Autonomous chance to jump onto a window platform
enable_window_platforms: bool = True # Treat open windows as walkable ledges
@property
def cat_height(self) -> int:
"""Calculates cat height proportionally based on aspect ratio 121.2 / 92.4."""
+12
View File
@@ -76,6 +76,8 @@ WM_LBUTTONDOWN = 0x0201
WM_LBUTTONUP = 0x0202
WM_MOUSEMOVE = 0x0200
WM_NCHITTEST = 0x0084
WM_DISPLAYCHANGE = 0x007E
WM_SETTINGCHANGE = 0x001A
# Cursors
IDC_ARROW = 32512
@@ -187,6 +189,7 @@ class OverlayWindow:
on_mouse_down: Optional[Callable[[int, int], None]] = None,
on_mouse_move: Optional[Callable[[int, int], None]] = None,
on_mouse_up: Optional[Callable[[int, int], None]] = None,
on_display_change: Optional[Callable[[], None]] = None,
):
self.width = width
self.height = height
@@ -196,6 +199,7 @@ class OverlayWindow:
self.on_mouse_down = on_mouse_down
self.on_mouse_move = on_mouse_move
self.on_mouse_up = on_mouse_up
self.on_display_change = on_display_change
self.hwnd: Optional[int] = None
self._wndproc_ref = None # Prevent garbage collection of callback
@@ -352,6 +356,14 @@ class OverlayWindow:
self.on_mouse_up(pos.x, pos.y)
return 0
elif msg in (WM_DISPLAYCHANGE, WM_SETTINGCHANGE):
if self.on_display_change:
try:
self.on_display_change()
except Exception as e:
logger.error(f"Error in on_display_change callback: {e}")
return 0
elif msg == WM_DESTROY:
user32.PostQuitMessage(0)
return 0
+169 -2
View File
@@ -60,6 +60,25 @@ class WindowInfo:
return max(0, self.rect[3] - self.rect[1])
@dataclass
class WindowLedge:
"""Represents a walkable top surface of a desktop window."""
hwnd: int
title: str
left: float
right: float
top_y: float
@dataclass
class MonitorInfo:
"""Represents a display monitor and its working area (excluding taskbar)."""
handle: int
is_primary: bool
rect: Tuple[int, int, int, int] # (left, top, right, bottom)
work_area: Tuple[int, int, int, int] # (left, top, right, bottom) excluding taskbar
class WindowManager:
"""Win32 window query and manipulation utility."""
@@ -174,7 +193,9 @@ class WindowManager:
process_predicate: Optional[Callable[[str], bool]] = None,
) -> Optional[WindowInfo]:
"""
Enumerates top-level desktop windows to find the first matching window.
Enumerates top-level desktop windows to find the first matching visible window.
Uses the user's interactive input desktop to ensure windows across all monitors
are discovered even if the calling thread has a different default desktop.
"""
found_hwnd = None
@@ -189,6 +210,10 @@ class WindowManager:
if not title:
return True
# Never target Catser's own overlay window
if "catser overlay" in title.lower():
return True
if title_predicate and title_predicate(title):
found_hwnd = hwnd
return False # Stop enumeration
@@ -201,7 +226,12 @@ class WindowManager:
return True
user32.EnumWindows(WNDENUMPROC(enum_proc), 0)
h_input_desk = user32.OpenInputDesktop(0, False, 0x01FF)
if h_input_desk:
user32.EnumDesktopWindows(h_input_desk, WNDENUMPROC(enum_proc), 0)
user32.CloseDesktop(h_input_desk)
else:
user32.EnumWindows(WNDENUMPROC(enum_proc), 0)
if found_hwnd:
return cls.get_window_info(found_hwnd)
@@ -283,3 +313,140 @@ class WindowManager:
return (0, 0, w, h)
return (vx, vy, vx + vw, vy + vh)
@classmethod
def get_monitors(cls) -> List[MonitorInfo]:
"""
Enumerates all connected display monitors and returns their full
bounding rectangles and work areas (excluding taskbars).
"""
class _MONITORINFO(ctypes.Structure):
_fields_ = [
("cbSize", wintypes.DWORD),
("rcMonitor", wintypes.RECT),
("rcWork", wintypes.RECT),
("dwFlags", wintypes.DWORD),
]
monitors: List[MonitorInfo] = []
MONITORENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HMONITOR, wintypes.HDC, ctypes.POINTER(wintypes.RECT), wintypes.LPARAM)
def enum_mon_proc(h_mon, hdc, lprect, lparam):
mi = _MONITORINFO()
mi.cbSize = ctypes.sizeof(_MONITORINFO)
if user32.GetMonitorInfoW(h_mon, ctypes.byref(mi)):
m = mi.rcMonitor
w = mi.rcWork
is_pri = bool(mi.dwFlags & 1)
monitors.append(MonitorInfo(
handle=int(h_mon),
is_primary=is_pri,
rect=(m.left, m.top, m.right, m.bottom),
work_area=(w.left, w.top, w.right, w.bottom),
))
return True
user32.EnumDisplayMonitors(0, None, MONITORENUMPROC(enum_mon_proc), 0)
return monitors
@classmethod
def get_display_topology_signature(cls) -> tuple:
"""
Returns a hashable signature representing current displays, resolutions, and work areas.
Used to detect dynamic monitor connection, disconnection, or resolution adjustments.
"""
monitors = cls.get_monitors()
vd = cls.get_virtual_desktop_bounds()
return (vd, tuple((m.handle, m.is_primary, m.rect, m.work_area) for m in monitors))
@classmethod
def get_floor_y_at(cls, x: float, cat_height: int) -> float:
"""
Returns the screen floor Y coordinate for a given horizontal X position,
accounting for multi-monitor setups where different monitors have taskbars
at different vertical coordinates.
"""
monitors = cls.get_monitors()
for mon in monitors:
wa = mon.work_area
if wa[0] <= x < wa[2]:
return float(wa[3] - cat_height)
# Fallback to primary work area
wa_pri = cls.get_work_area()
return float(wa_pri[3] - cat_height)
@classmethod
def get_window_ledges(cls, min_width: int = 120) -> List[WindowLedge]:
"""
Queries all visible, non-minimized top-level windows to extract
walkable horizontal platforms (window top titlebar frames).
"""
ledges: List[WindowLedge] = []
WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
def enum_proc(hwnd, lparam):
if not user32.IsWindowVisible(hwnd) or user32.IsIconic(hwnd):
return True
title = cls.get_window_title(hwnd)
if not title:
return True
# Exclude overlays and desktop shell
t_lower = title.lower()
if "catser overlay" in t_lower or "program manager" in t_lower:
return True
rect = cls.get_window_rect(hwnd)
if not rect:
return True
left, top, right, bottom = rect
width = right - left
height = bottom - top
# Window must be large enough to serve as a platform
if width >= min_width and height >= 60:
ledges.append(WindowLedge(
hwnd=hwnd,
title=title,
left=float(left),
right=float(right),
top_y=float(top),
))
return True
h_input_desk = user32.OpenInputDesktop(0, False, 0x01FF)
if h_input_desk:
user32.EnumDesktopWindows(h_input_desk, WNDENUMPROC(enum_proc), 0)
user32.CloseDesktop(h_input_desk)
else:
user32.EnumWindows(WNDENUMPROC(enum_proc), 0)
# Sort highest ledge first (lowest top_y)
ledges.sort(key=lambda l: l.top_y)
return ledges
@classmethod
def get_surface_beneath(cls, x: float, y: float, cat_width: int, cat_height: int) -> float:
"""
Finds the highest walking surface directly beneath the cat (either an open
window top ledge or the monitor floor). Returns the target cat.y coordinate
(i.e. surface_top_y - cat_height).
"""
cat_center_x = x + cat_width * 0.5
feet_y = y + cat_height
floor_y = cls.get_floor_y_at(cat_center_x, cat_height)
# Search for window ledges below the cat's current feet
candidate_y = floor_y
ledges = cls.get_window_ledges(min_width=100)
for ledge in ledges:
if ledge.left <= cat_center_x <= ledge.right:
target_cat_y = ledge.top_y - cat_height
# Is this ledge below the cat's feet (with 6px landing tolerance)?
if ledge.top_y >= feet_y - 6.0 and target_cat_y < candidate_y:
candidate_y = target_cat_y
return candidate_y