diff --git a/docs/superpowers/plans/2026-04-07-pet-lab.md b/docs/superpowers/plans/2026-04-07-pet-lab.md new file mode 100644 index 0000000..0f3a65b --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-pet-lab.md @@ -0,0 +1,672 @@ +# Pet Lab Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Windows 데스크톱 펫 애플리케이션 — 화면 하단에 고정된 캐릭터가 마우스 시선을 추적하고 클릭/우클릭 상호작용을 지원한다. + +**Architecture:** PyQt5 투명 프레임리스 윈도우에 캐릭터 이미지를 표시. QTimer 루프로 마우스 좌표를 폴링하여 이미지 기울기/반전으로 시선을 표현. 좌클릭(점프)/더블클릭(흔들기) 애니메이션과 우클릭 컨텍스트 메뉴 제공. + +**Tech Stack:** Python 3.12, PyQt5 + +**Project Path:** `C:\Users\jaeoh\Desktop\workspace\pet-lab` + +--- + +## File Structure + +| 파일 | 역할 | 생성/수정 | +|------|------|-----------| +| `app/config.py` | 상수 정의 (크기, 위치, 애니메이션, 경로) | Create | +| `app/eye_tracker.py` | 마우스→기울기 각도/반전 계산 (순수 함수) | Create | +| `app/pet_widget.py` | 투명 윈도우 + 캐릭터 렌더링 + QTimer 루프 | Create | +| `app/interaction.py` | 클릭 애니메이션 + 우클릭 메뉴 | Create | +| `app/main.py` | 엔트리포인트 (QApplication 초기화) | Create | +| `assets/characters/박뚱냥.png` | 캐릭터 이미지 | Copy | +| `requirements.txt` | PyQt5 의존성 | Create | +| `tests/test_eye_tracker.py` | eye_tracker 단위 테스트 | Create | +| `tests/test_config.py` | config 상수 검증 테스트 | Create | + +--- + +### Task 1: 프로젝트 초기화 + config.py + +**Files:** +- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\requirements.txt` +- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\config.py` +- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\tests\test_config.py` +- Copy: `Z:\homes\jaeoh\캐릭터\박뚱냥.jpg` → `C:\Users\jaeoh\Desktop\workspace\pet-lab\assets\characters\박뚱냥.png` + +- [ ] **Step 1: 프로젝트 디렉토리 생성 및 git 초기화** + +```bash +mkdir -p "C:\Users\jaeoh\Desktop\workspace\pet-lab"/{app,assets/characters,tests} +cd "C:\Users\jaeoh\Desktop\workspace\pet-lab" +git init +``` + +- [ ] **Step 2: 캐릭터 이미지 복사** + +```bash +cp "Z:\homes\jaeoh\캐릭터\박뚱냥.jpg" "C:\Users\jaeoh\Desktop\workspace\pet-lab\assets\characters\박뚱냥.png" +``` + +참고: 원본이 .jpg이지만 투명 배경이 있는 이미지이므로 그대로 사용. 파일명은 .png으로 저장하되, 실제 포맷이 JPG라면 PyQt5의 QPixmap이 자동 감지하므로 문제없음. + +- [ ] **Step 3: requirements.txt 생성** + +``` +PyQt5>=5.15,<6.0 +pytest>=7.0 +``` + +- [ ] **Step 4: 가상환경 생성 및 의존성 설치** + +```bash +cd "C:\Users\jaeoh\Desktop\workspace\pet-lab" +python -m venv venv +venv\Scripts\activate +pip install -r requirements.txt +``` + +- [ ] **Step 5: config.py 작성** + +```python +"""pet-lab 설정 상수.""" +import os + +# 캐릭터 크기 (높이 기준 px, 너비는 비율 유지) +SIZES = {"small": 100, "medium": 150, "large": 200} +DEFAULT_SIZE = "medium" + +# 수평 위치 프리셋 (화면 너비 비율) +POSITIONS = {"left": 0.1, "center": 0.5, "right": 0.9} +DEFAULT_POSITION = "right" + +# 시선 추적 +TIMER_INTERVAL_MS = 30 +MAX_TILT_ANGLE = 15.0 + +# 태스크바 +TASKBAR_HEIGHT = 48 + +# 애니메이션 +JUMP_HEIGHT = 30 +JUMP_DURATION_MS = 300 +SHAKE_OFFSET = 10 +SHAKE_DURATION_MS = 400 + +# 에셋 경로 +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CHARACTER_DIR = os.path.join(BASE_DIR, "assets", "characters") +DEFAULT_CHARACTER = "박뚱냥.png" +``` + +- [ ] **Step 6: test_config.py 작성** + +```python +"""config 상수 검증.""" +from app.config import SIZES, POSITIONS, DEFAULT_SIZE, DEFAULT_POSITION +from app.config import TIMER_INTERVAL_MS, MAX_TILT_ANGLE, CHARACTER_DIR +import os + + +def test_sizes_has_three_presets(): + assert set(SIZES.keys()) == {"small", "medium", "large"} + assert all(isinstance(v, int) and v > 0 for v in SIZES.values()) + + +def test_default_size_is_valid(): + assert DEFAULT_SIZE in SIZES + + +def test_positions_has_three_presets(): + assert set(POSITIONS.keys()) == {"left", "center", "right"} + assert all(0.0 < v < 1.0 for v in POSITIONS.values()) + + +def test_default_position_is_valid(): + assert DEFAULT_POSITION in POSITIONS + + +def test_timer_interval_is_reasonable(): + assert 10 <= TIMER_INTERVAL_MS <= 100 + + +def test_max_tilt_angle_is_reasonable(): + assert 5.0 <= MAX_TILT_ANGLE <= 45.0 + + +def test_character_dir_exists(): + assert os.path.isdir(CHARACTER_DIR) +``` + +- [ ] **Step 7: 테스트 실행** + +```bash +cd "C:\Users\jaeoh\Desktop\workspace\pet-lab" +python -m pytest tests/test_config.py -v +``` + +Expected: 7 passed + +- [ ] **Step 8: .gitignore 생성 및 커밋** + +`.gitignore`: +``` +venv/ +__pycache__/ +*.pyc +.pytest_cache/ +dist/ +build/ +*.spec +``` + +```bash +git add . +git commit -m "feat: 프로젝트 초기화 — config, 캐릭터 에셋, 테스트" +``` + +--- + +### Task 2: eye_tracker.py — 시선 계산 모듈 + +**Files:** +- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\eye_tracker.py` +- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\tests\test_eye_tracker.py` + +- [ ] **Step 1: test_eye_tracker.py 작성** + +```python +"""eye_tracker 시선 계산 테스트.""" +import math +from app.eye_tracker import compute_gaze + + +def test_mouse_right_of_character(): + """마우스가 캐릭터 오른쪽 → 양수 기울기, flip=False.""" + angle, flip = compute_gaze( + char_center_x=500, char_center_y=900, + mouse_x=800, mouse_y=500, + max_angle=15.0, + ) + assert 0 < angle <= 15.0 + assert flip is False + + +def test_mouse_left_of_character(): + """마우스가 캐릭터 왼쪽 → 음수 기울기, flip=True.""" + angle, flip = compute_gaze( + char_center_x=500, char_center_y=900, + mouse_x=200, mouse_y=500, + max_angle=15.0, + ) + assert -15.0 <= angle < 0 + assert flip is True + + +def test_mouse_directly_above(): + """마우스가 캐릭터 바로 위 → 기울기 0, flip=False.""" + angle, flip = compute_gaze( + char_center_x=500, char_center_y=900, + mouse_x=500, mouse_y=100, + max_angle=15.0, + ) + assert angle == 0.0 + assert flip is False + + +def test_mouse_at_character_position(): + """마우스가 캐릭터 위치와 동일 → 기울기 0, flip=False.""" + angle, flip = compute_gaze( + char_center_x=500, char_center_y=500, + mouse_x=500, mouse_y=500, + max_angle=15.0, + ) + assert angle == 0.0 + assert flip is False + + +def test_angle_clamped_to_max(): + """기울기가 max_angle을 초과하지 않아야 한다.""" + angle, flip = compute_gaze( + char_center_x=500, char_center_y=500, + mouse_x=10000, mouse_y=500, + max_angle=15.0, + ) + assert abs(angle) <= 15.0 + + +def test_mouse_far_left(): + """마우스가 매우 왼쪽 → 기울기 -max_angle에 근접.""" + angle, flip = compute_gaze( + char_center_x=500, char_center_y=500, + mouse_x=0, mouse_y=500, + max_angle=15.0, + ) + assert angle < 0 + assert flip is True +``` + +- [ ] **Step 2: 테스트 실행 — 실패 확인** + +```bash +python -m pytest tests/test_eye_tracker.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app.eye_tracker'` + +- [ ] **Step 3: eye_tracker.py 구현** + +```python +"""마우스 위치 기반 시선/기울기 계산 — 순수 함수 모듈.""" +import math + + +def compute_gaze( + char_center_x: float, + char_center_y: float, + mouse_x: float, + mouse_y: float, + max_angle: float = 15.0, +) -> tuple[float, bool]: + """캐릭터 중심과 마우스 위치로 기울기 각도와 좌우 반전 여부를 계산한다. + + Returns: + (tilt_angle, flip_horizontal) + - tilt_angle: -max_angle ~ +max_angle (도). 양수=우측 기울기, 음수=좌측 기울기. + - flip_horizontal: True면 이미지를 좌우 반전 (마우스가 캐릭터 왼쪽). + """ + dx = mouse_x - char_center_x + dy = mouse_y - char_center_y + + if dx == 0 and dy == 0: + return 0.0, False + + # dx 방향의 비율로 기울기 결정 (atan2로 각도 → 비율 변환) + angle_rad = math.atan2(abs(dx), max(abs(dy), 1)) + ratio = angle_rad / (math.pi / 2) # 0~1 범위 + tilt = ratio * max_angle + + if dx < 0: + tilt = -tilt + + # max_angle 클램핑 + tilt = max(-max_angle, min(max_angle, tilt)) + + flip = dx < 0 + + return tilt, flip +``` + +- [ ] **Step 4: 테스트 실행 — 통과 확인** + +```bash +python -m pytest tests/test_eye_tracker.py -v +``` + +Expected: 6 passed + +- [ ] **Step 5: 커밋** + +```bash +git add app/eye_tracker.py tests/test_eye_tracker.py +git commit -m "feat: eye_tracker — 마우스 시선 기울기 계산 모듈" +``` + +--- + +### Task 3: pet_widget.py — 투명 윈도우 + 캐릭터 렌더링 + +**Files:** +- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\pet_widget.py` + +- [ ] **Step 1: pet_widget.py 작성** + +```python +"""투명 윈도우 위에 캐릭터를 렌더링하고 시선을 추적하는 메인 위젯.""" +from PyQt5.QtWidgets import QWidget, QLabel, QApplication +from PyQt5.QtCore import Qt, QTimer, QPoint +from PyQt5.QtGui import QPixmap, QCursor, QTransform +import os + +from app.config import ( + SIZES, DEFAULT_SIZE, POSITIONS, DEFAULT_POSITION, + TIMER_INTERVAL_MS, MAX_TILT_ANGLE, TASKBAR_HEIGHT, + CHARACTER_DIR, DEFAULT_CHARACTER, +) +from app.eye_tracker import compute_gaze + + +class PetWidget(QWidget): + def __init__(self): + super().__init__() + self._size_key = DEFAULT_SIZE + self._position_key = DEFAULT_POSITION + self._always_on_top = True + self._last_mouse_pos = None + self._base_y = 0 + + self._init_window() + self._load_character() + self._position_on_screen() + self._start_tracking() + + def _init_window(self): + flags = Qt.FramelessWindowHint | Qt.Tool + if self._always_on_top: + flags |= Qt.WindowStaysOnTopHint + self.setWindowFlags(flags) + self.setAttribute(Qt.WA_TranslucentBackground) + + def _load_character(self): + path = os.path.join(CHARACTER_DIR, DEFAULT_CHARACTER) + self._original_pixmap = QPixmap(path) + self._label = QLabel(self) + self._apply_size() + + def _apply_size(self): + height = SIZES[self._size_key] + scaled = self._original_pixmap.scaledToHeight(height, Qt.SmoothTransformation) + self._label.setPixmap(scaled) + self._label.setFixedSize(scaled.size()) + self.setFixedSize(scaled.size()) + + def _position_on_screen(self): + screen = QApplication.primaryScreen().geometry() + char_height = SIZES[self._size_key] + self._base_y = screen.height() - TASKBAR_HEIGHT - char_height + x_ratio = POSITIONS[self._position_key] + x = int(screen.width() * x_ratio) - self.width() // 2 + self.move(x, self._base_y) + + def _start_tracking(self): + self._timer = QTimer(self) + self._timer.timeout.connect(self._update_gaze) + self._timer.start(TIMER_INTERVAL_MS) + + def _update_gaze(self): + mouse_pos = QCursor.pos() + if self._last_mouse_pos == mouse_pos: + return + self._last_mouse_pos = mouse_pos + + center = self.geometry().center() + tilt, flip = compute_gaze( + center.x(), center.y(), + mouse_pos.x(), mouse_pos.y(), + MAX_TILT_ANGLE, + ) + + height = SIZES[self._size_key] + scaled = self._original_pixmap.scaledToHeight(height, Qt.SmoothTransformation) + + transform = QTransform() + if flip: + transform.scale(-1, 1) + transform.rotate(tilt) + + rotated = scaled.transformed(transform, Qt.SmoothTransformation) + self._label.setPixmap(rotated) + self._label.setFixedSize(rotated.size()) + self.setFixedSize(rotated.size()) + + # ── 크기/위치 변경 (interaction.py에서 호출) ── + + def set_size(self, size_key: str): + self._size_key = size_key + self._apply_size() + self._position_on_screen() + + def set_position(self, position_key: str): + self._position_key = position_key + self._position_on_screen() + + def toggle_always_on_top(self): + self._always_on_top = not self._always_on_top + flags = Qt.FramelessWindowHint | Qt.Tool + if self._always_on_top: + flags |= Qt.WindowStaysOnTopHint + self.setWindowFlags(flags) + self.show() + + @property + def always_on_top(self) -> bool: + return self._always_on_top + + @property + def base_y(self) -> int: + return self._base_y +``` + +- [ ] **Step 2: 수동 테스트 — 투명 윈도우에 캐릭터 표시 확인** + +임시 실행 스크립트: +```bash +cd "C:\Users\jaeoh\Desktop\workspace\pet-lab" +python -c " +import sys +from PyQt5.QtWidgets import QApplication +from app.pet_widget import PetWidget + +app = QApplication(sys.argv) +pet = PetWidget() +pet.show() +sys.exit(app.exec_()) +" +``` + +Expected: 화면 우하단에 박뚱냥이 표시되고, 마우스 이동 시 기울기/반전이 바뀜. + +- [ ] **Step 3: 커밋** + +```bash +git add app/pet_widget.py +git commit -m "feat: pet_widget — 투명 윈도우 + 시선 추적 렌더링" +``` + +--- + +### Task 4: interaction.py — 클릭 반응 + 우클릭 메뉴 + +**Files:** +- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\interaction.py` +- Modify: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\pet_widget.py` (마우스 이벤트 연결) + +- [ ] **Step 1: interaction.py 작성** + +```python +"""클릭 애니메이션 + 우클릭 컨텍스트 메뉴.""" +from PyQt5.QtWidgets import QMenu, QAction, QApplication +from PyQt5.QtCore import QPropertyAnimation, QEasingCurve, QPoint, QSequentialAnimationGroup + +from app.config import ( + JUMP_HEIGHT, JUMP_DURATION_MS, + SHAKE_OFFSET, SHAKE_DURATION_MS, + SIZES, POSITIONS, +) + + +def play_jump(widget): + """좌클릭 — 위로 점프 후 복귀.""" + start = widget.pos() + top = QPoint(start.x(), start.y() - JUMP_HEIGHT) + + anim = QPropertyAnimation(widget, b"pos") + anim.setDuration(JUMP_DURATION_MS) + anim.setStartValue(start) + anim.setKeyValueAt(0.4, top) + anim.setEndValue(start) + anim.setEasingCurve(QEasingCurve.OutBounce) + + # prevent garbage collection + widget._current_anim = anim + anim.start() + + +def play_shake(widget): + """더블클릭 — 좌우 흔들기.""" + start = widget.pos() + left = QPoint(start.x() - SHAKE_OFFSET, start.y()) + right = QPoint(start.x() + SHAKE_OFFSET, start.y()) + + group = QSequentialAnimationGroup(widget) + + for end_pos in [left, right, left, right, start]: + anim = QPropertyAnimation(widget, b"pos") + anim.setDuration(SHAKE_DURATION_MS // 5) + anim.setEndValue(end_pos) + group.addAnimation(anim) + + widget._current_anim = group + group.start() + + +def show_context_menu(widget, global_pos): + """우클릭 — 컨텍스트 메뉴 표시.""" + menu = QMenu() + + # 위치 서브메뉴 + pos_menu = menu.addMenu("위치") + for key, label in [("left", "좌"), ("center", "중앙"), ("right", "우")]: + action = pos_menu.addAction(label) + action.triggered.connect(lambda checked, k=key: widget.set_position(k)) + + # 크기 서브메뉴 + size_menu = menu.addMenu("크기") + for key, label in [("small", "소 (100px)"), ("medium", "중 (150px)"), ("large", "대 (200px)")]: + action = size_menu.addAction(label) + action.triggered.connect(lambda checked, k=key: widget.set_size(k)) + + # 항상 위 토글 + top_action = menu.addAction("항상 위" + (" ✓" if widget.always_on_top else "")) + top_action.triggered.connect(widget.toggle_always_on_top) + + menu.addSeparator() + + # 종료 + quit_action = menu.addAction("종료") + quit_action.triggered.connect(QApplication.quit) + + menu.exec_(global_pos) +``` + +- [ ] **Step 2: pet_widget.py에 마우스 이벤트 연결** + +`pet_widget.py`의 `PetWidget` 클래스에 다음 메서드를 추가: + +```python + # ── 마우스 이벤트 (파일 하단, toggle_always_on_top 뒤에 추가) ── + + def mousePressEvent(self, event): + if event.button() == Qt.RightButton: + from app.interaction import show_context_menu + show_context_menu(self, event.globalPos()) + + def mouseDoubleClickEvent(self, event): + if event.button() == Qt.LeftButton: + from app.interaction import play_shake + play_shake(self) + + def mouseReleaseEvent(self, event): + if event.button() == Qt.LeftButton: + from app.interaction import play_jump + play_jump(self) +``` + +파일 상단 import에 추가 필요 없음 (lazy import 사용). + +- [ ] **Step 3: 수동 테스트** + +```bash +cd "C:\Users\jaeoh\Desktop\workspace\pet-lab" +python -c " +import sys +from PyQt5.QtWidgets import QApplication +from app.pet_widget import PetWidget + +app = QApplication(sys.argv) +pet = PetWidget() +pet.show() +sys.exit(app.exec_()) +" +``` + +테스트 항목: +- 좌클릭 → 점프 애니메이션 +- 더블클릭 → 흔들기 애니메이션 +- 우클릭 → 메뉴 표시 (위치/크기/항상위/종료) +- 메뉴에서 위치 변경 → 캐릭터 이동 +- 메뉴에서 크기 변경 → 캐릭터 크기 변경 +- 종료 → 앱 종료 + +- [ ] **Step 4: 커밋** + +```bash +git add app/interaction.py app/pet_widget.py +git commit -m "feat: interaction — 클릭 점프/흔들기 + 우클릭 메뉴" +``` + +--- + +### Task 5: main.py — 엔트리포인트 + +**Files:** +- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\main.py` + +- [ ] **Step 1: main.py 작성** + +```python +"""pet-lab 엔트리포인트.""" +import sys +from PyQt5.QtWidgets import QApplication +from app.pet_widget import PetWidget + + +def main(): + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + + pet = PetWidget() + pet.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: 실행 확인** + +```bash +cd "C:\Users\jaeoh\Desktop\workspace\pet-lab" +python -m app.main +``` + +Expected: 박뚱냥이 화면 우하단에 표시되고, 시선 추적 + 클릭 반응 + 우클릭 메뉴 모두 동작. + +- [ ] **Step 3: 커밋** + +```bash +git add app/main.py +git commit -m "feat: main.py 엔트리포인트 — python -m app.main으로 실행" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** +- [x] 투명 윈도우 (Task 3: `FramelessWindowHint`, `WA_TranslucentBackground`, `Tool`) +- [x] 바닥 고정 (Task 3: `_position_on_screen`) +- [x] 시선 추적 (Task 2: `compute_gaze`, Task 3: `_update_gaze`) +- [x] 좌클릭 점프 (Task 4: `play_jump`) +- [x] 더블클릭 흔들기 (Task 4: `play_shake`) +- [x] 우클릭 메뉴 — 위치/크기/항상위/종료 (Task 4: `show_context_menu`) +- [x] config 상수 (Task 1: `config.py`) +- [x] 성능 최적화 — 마우스 변화 없으면 스킵 (Task 3: `_last_mouse_pos`) + +**Placeholder scan:** 없음. 모든 step에 실제 코드 포함. + +**Type consistency:** `compute_gaze` 시그니처 — Task 2 구현과 Task 3 호출 일치. `set_size`/`set_position` — Task 3 정의와 Task 4 호출 일치.