# 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 호출 일치.
