docs: pet-lab 구현 계획서 추가
5개 Task: config → eye_tracker → pet_widget → interaction → main Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
672
docs/superpowers/plans/2026-04-07-pet-lab.md
Normal file
672
docs/superpowers/plans/2026-04-07-pet-lab.md
Normal file
@@ -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 호출 일치.
|
||||||
Reference in New Issue
Block a user