Initial commit: courtlab tactical board with team management and MP4 export
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"mongodb": {
|
||||
"uri": "mongodb://172.16.0.7:27017",
|
||||
"db": "basket_utils"
|
||||
},
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 3000,
|
||||
"mode": "development",
|
||||
"allowedOrigins": [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://192.168.5.10:5173"
|
||||
],
|
||||
"secureCookie": false
|
||||
},
|
||||
"bootstrap": {
|
||||
"password": ""
|
||||
},
|
||||
"qa": {
|
||||
"db": "basket_utils_qa",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.config.json
|
||||
data/
|
||||
@@ -0,0 +1,13 @@
|
||||
# 프로젝트 작업 규칙
|
||||
|
||||
## 모델 역할 분담
|
||||
|
||||
- 실제 코드 작업(기능 구현, 버그 수정, 리팩터링, 테스트 코드 작성)은 **GPT-5.6 Luna (`gpt-5.6-luna`)**에게 위임한다.
|
||||
- 주 에이전트는 요구사항 정리, 조사, 설계, 작업 지시, 코드 검토 및 결과 검증을 담당한다. 프로젝트 문서 수정은 주 에이전트가 수행할 수 있다.
|
||||
- 코드 작업을 위임할 때 담당 파일과 완료 조건을 명시하고, 다른 작업자의 변경을 되돌리지 않도록 안내한다.
|
||||
- Luna를 사용할 수 없으면 임의로 다른 모델로 구현하지 말고 사용자에게 상황을 알린다.
|
||||
|
||||
## 변경과 검증
|
||||
|
||||
- 요청 범위 안에서 최소한으로 변경하며 기존 편집·재생 동작을 보존한다.
|
||||
- 변경에 맞는 검증을 수행하고, UI 변경은 가능한 경우 PC와 모바일 화면에서 확인한다.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Court Lab — UI redesign
|
||||
|
||||
2026-09-07
|
||||
|
||||
## Reference projects
|
||||
|
||||
- [CoachCanvas](https://coachcanvas.app/): concise basketball product presentation, neutral surfaces, a clear primary action, and court-centered authoring. Its public website and welcome screen were inspected; no account was created and no assets were copied.
|
||||
- [tldraw UI components](https://tldraw.dev/sdk-features/ui-components): separation of canvas, tool palette, property panel and navigation; properties move into a popover on mobile. This project uses the layout principles, not its SDK.
|
||||
- `../network_dashboard/web/src/styles.css`: local reference for light surfaces, restrained borders, semantic color tokens and compact application controls.
|
||||
- `../arena/src/styles/base.css` and `game-ui.css`: local reference for a dark central scene with unobtrusive tools. No files in either project were changed.
|
||||
|
||||
## Design decisions
|
||||
|
||||
- Warm white shell, dark evergreen canvas, terracotta primary actions, muted blue defense markers, procedural timber court.
|
||||
- Court overlays use deep blue (`#174a7e`) movement lines and deep purple (`#5b2a83`) dashed pass arrows to contrast with the timber surface. Team marker colors remain independent of action colors.
|
||||
- Header prioritizes identity, document name and save; setup, load, file operations and debug data live in the project menu.
|
||||
- Left column shows lineup and possession. Right column shows action list and only exposes properties when an action is selected.
|
||||
- Floating court tools use a consistent original SVG icon set and text labels. View controls stay at the top of the canvas.
|
||||
- Playback occupies its own bottom deck. Step thumbnails derive from the actual sequence starting positions and action counts.
|
||||
- Tactical view uses flat player markers and a diagram hoop. Physical hoop, backboard and articulated avatars remain available in Player POV.
|
||||
- Phone portrait uses switchable panels. Landscape puts panel navigation on the left and action tools on the right.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `src/ui.js`: markup, SVG icons and data-driven step thumbnails.
|
||||
- `src/style.css`: design tokens, components and responsive layouts; replaces the earlier dark-dashboard stylesheet.
|
||||
- `src/main.js`: existing editing handlers remain connected to the new layout; project menu dismissal and contextual properties.
|
||||
- `src/scene.js`: procedural court material, team palette, flat markers and view-specific hoop representation.
|
||||
|
||||
## Verification
|
||||
|
||||
- Existing 91 tests pass; production build succeeds. Existing Three.js bundle-size warning remains.
|
||||
- Browser inspection: 1440×1000 desktop, 390×844 portrait, 844×390 landscape.
|
||||
- Verified action selection, gaze updates, playback, pause, menu opening and Escape dismissal; no browser errors observed.
|
||||
- Landscape document dimensions match the viewport without horizontal or vertical overflow.
|
||||
- Visual assets were authored in code. No reference screenshots, product logos or proprietary illustrations are embedded.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Fastify + MongoDB 서비스 전환
|
||||
|
||||
2026-09-08
|
||||
|
||||
## 확정 사항
|
||||
|
||||
- 사용자 지정 백엔드: Fastify.
|
||||
- 사용자 최종 선택 데이터베이스: MongoDB `172.16.0.7:27017`. 이전 SQLite 선택을 대체한다.
|
||||
- Fastify 인스턴스당 하나의 MongoClient 연결 풀을 유지하고 서버 종료 시 닫는다. 요청마다 연결·해제하지 않는다.
|
||||
- 연결 URI와 DB 이름은 프로젝트 루트 `.config.json`으로 설정한다. `.config.json.sample`을 예시로 제공한다. 전용 DB `basket_utils` 사용은 사용자가 확인했으며 기존 다른 프로젝트 DB를 수정하지 않는다.
|
||||
- 실제 구현: GPT-5.6 Luna, xhigh. 주 에이전트는 요구사항 정리·검토·검증·문서 담당.
|
||||
- 범위: 로그인, 운영자 사용 승인, 여러 팀 생성/전환, 팀별 전술 저장/불러오기, 기존 기기 전술의 명시적 가져오기.
|
||||
- 기존 Three.js 편집/재생과 MP4 파일 직접 공유는 유지.
|
||||
- 외부 로그인 서비스 키가 없는 초기 구현은 이메일/비밀번호 로그인. 카카오톡 파일 공유와 서비스 로그인 수단은 독립적이다.
|
||||
|
||||
## 사용자 흐름
|
||||
|
||||
1. 로그인 또는 회원가입.
|
||||
2. 미승인 사용자는 승인 대기 화면. 승인된 사용자는 내 팀으로 진입.
|
||||
3. 팀이 없으면 첫 팀 생성. 계정 하나로 여러 팀 생성 가능.
|
||||
4. 팀 선택 후 해당 팀의 전술 목록과 새 전술 작성.
|
||||
5. 편집 화면에서 현재 팀 이름을 확인하며 저장·불러오기·MP4 생성/공유.
|
||||
6. 팀 전환 시 다른 팀의 목록/임시 저장과 섞이지 않음.
|
||||
7. 로그아웃 시 편집/공유 상태를 정리하고 로그인 화면으로 복귀.
|
||||
|
||||
## 검토 기준
|
||||
|
||||
- 회원가입이 자동 운영자 권한이나 자동 사용 승인을 부여하지 않는다.
|
||||
- 운영자는 로컬 설정 절차로 초기화하며 공개 기본 비밀번호를 제공하지 않는다.
|
||||
- 비밀번호는 해시로 저장하고 세션은 HttpOnly 쿠키로 관리한다.
|
||||
- 서버가 매 요청의 승인 상태와 팀 소속을 확인한다.
|
||||
- 팀 정보와 전술은 MongoDB에 저장되어 Fastify 재시작 후에도 남는다.
|
||||
- 사용자·팀·전술명은 화면에서 데이터로 출력한다.
|
||||
- 팀 변경/로그아웃 후 이전 요청 응답이 새 화면을 덮어쓰지 않는다.
|
||||
- 저장 요청은 요청 시작 당시 팀에만 적용된다.
|
||||
- 임시 저장은 사용자와 팀별로 분리한다.
|
||||
- 기존 로컬 데이터는 사용자가 대상 팀을 고른 뒤 가져오고, 성공 전후 원본을 임의로 삭제하지 않는다.
|
||||
|
||||
## 검증 시나리오
|
||||
|
||||
- 비로그인 → 로그인 필요; 미승인 로그인 → 대기; 승인 후 → 팀 화면.
|
||||
- 팀 A/B 생성 → 각각 다른 전술 저장 → 왕복 전환/새로고침 후 분리 확인.
|
||||
- 승인된 별도 계정은 멤버가 아닌 팀의 목록/전술에 접근 불가.
|
||||
- 세션 로그아웃/승인 중단 후 서버 작업 거부.
|
||||
- 기존 로컬 전술 가져오기 후 서버에서 다시 열기; 원본 로컬 데이터 보존.
|
||||
- 편집/Undo/재생/선수 시점/MP4 공유 기능 회귀 확인.
|
||||
- PC 및 모바일 화면 폭에서 로그인·대기·팀 선택·편집 상단 컨트롤 확인.
|
||||
- 실제 테스트 계정은 격리된 QA 데이터베이스만 사용. 사용자 운영 DB에 임의 계정을 남기지 않는다. 기존 DB/컬렉션을 삭제하지 않는다.
|
||||
|
||||
## 실행 구조
|
||||
|
||||
개발 중 Vite의 LAN 접속 주소를 유지하고 `/api` 요청을 Fastify에 프록시한다. 운영 모드에서는 Fastify가 빌드된 화면과 API를 제공한다. 구체적인 명령·환경변수·초기 운영자 절차는 구현 검토 후 실행 문서에 기록한다.
|
||||
|
||||
휴대폰 HTTP LAN 접속과 OS 파일 공유 지원은 다르다. Web Share가 필요한 실제 공유 검증에는 지원 브라우저와 신뢰할 수 있는 HTTPS 환경이 필요하다. 이번 서버 전환이 카카오톡 실기기 전송 검증을 대신하지 않는다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 2026-09-08: 사용자 지정 MongoDB에 드라이버 연결 및 `basket_utils` ping 성공.
|
||||
- MongoDB 연결을 사용하는 Fastify 초기화, 프로젝트 인덱스 준비, `/api/health` 200 응답, `app.close()` 성공. 연결 풀 최대 크기 10 확인.
|
||||
- 기존 102개와 Fastify 계약 테스트 4개를 포함해 106개 테스트 통과. 비로그인·승인 대기, 팀별 저장/조회, 다른 사용자 및 viewer의 저장 제한, 로그아웃·승인 중단 후 접근 제한, Secure 쿠키를 확인했다.
|
||||
- `npm run dev`로 Fastify `0.0.0.0:3000`, Vite `0.0.0.0:5173` 실행 확인. localhost와 `192.168.5.10` 경유 API 정상 응답.
|
||||
- 이 프로젝트의 예전 IPv6 전용 Vite 인스턴스가 localhost 요청을 가로채던 문제를 확인하고 해당 중복 프로세스만 종료.
|
||||
- 브라우저 1440×1000 및 390×844에서 로그인·가입 화면 확인. 모바일 가로 넘침 없음. 휴대폰 실기기의 연결 확인과는 구별한다.
|
||||
- 격리된 `basket_utils_qa_20260908review` DB에서 실제 화면으로 QA-A/QA-B 팀 생성·저장·왕복 전환·각 팀 임시 전술 복원 확인. 서버 재시작 후 세션 및 팀 데이터 유지 확인.
|
||||
- 테스트 미승인 계정의 대기 화면, 운영자 승인 버튼, 승인 후 로그인, 새 계정에 기존 사용자의 팀이 나타나지 않음을 확인.
|
||||
- 모바일 상단 버튼 줄바꿈 수정 후 현재 팀 이름과 공유·저장 아이콘이 한 줄로 표시됨을 확인.
|
||||
- Fastify가 제공하는 빌드 화면에서 MP4 생성·재생 확인: 1280×720, 0.7252초, readyState 4, 끝까지 재생 완료. 팀 변경 후 이전 MP4가 제거되고 공유 버튼이 비활성화됨을 확인. 카카오톡 전송은 실행하지 않았다.
|
||||
- 빌드 성공. 기존 500KB 초과 번들 경고는 남아 있다.
|
||||
- 실행 및 최초 운영자 설정: [SERVER-SETUP.md](SERVER-SETUP.md).
|
||||
@@ -0,0 +1,48 @@
|
||||
# Court Lab 개선 기록
|
||||
|
||||
2026-09-07
|
||||
|
||||
## 적용 내용
|
||||
|
||||
- PC 코트 영역 확대, 개발용 JSON 접기, 한글 행동 이름.
|
||||
- 모바일 세로 화면의 코트/선수/행동 패널, 가로 화면의 축소 도구와 전술 메뉴.
|
||||
- 선택한 이동의 좌표 수정, 선택 선수 드래그로 시작 위치/이동 종점 수정.
|
||||
- 시작 배치, 공 소유자, 단계 추가·삭제를 포함하는 전체 편집 Undo/Redo (최근 50개).
|
||||
- 자동/공/림/이동 방향/선수/코트 지점 시선 선택. 모바일 대상 선택 취소 버튼.
|
||||
- 패서의 자동 수신자 주시, 수신자의 비행 중 공 주시, 사용자 시선 우선.
|
||||
- 재생 위치 탐색, 이전/다음 단계, 0.5~2배속, 전체 반복, 재생 완료 후 다시 시작.
|
||||
- 기기 내 자동 임시 저장·새로고침 복원, JSON 입출력 (가져오기 2MB 제한).
|
||||
- 슛을 유지한 기존 이동·시선 수정, 연결된 다음 단계 시작점 및 정지 패스·슛 위치 재계산.
|
||||
- 림 3.05m, 공 소유/슈팅 높이, 시점 높이 1.75m. 세로 FOV 제한과 카메라 회전 속도 제한.
|
||||
- 작전판은 마커, 선수 시점은 간단한 관절형 대체 모델. 선수 자신의 모델은 POV에서 숨김.
|
||||
|
||||
## 검증
|
||||
|
||||
- `npm test`: 91개 테스트 통과.
|
||||
- `npm run build`: 성공. Three.js를 포함한 단일 번들 크기 경고는 남아 있음.
|
||||
- 인앱 브라우저: 390×844 모바일 화면의 이동 생성, 시선/좌표 수정, Undo, 패스, 반복 재생, POV, 저장 및 새로고침 복원 확인.
|
||||
- 브라우저 개발 로그에서 오류 없음 확인.
|
||||
- 1440×900 PC에서 코트 전체 표시와 선택 선수 드래그 확인. 844×390 가로 화면에서 코트/측면 도구/시간바 및 전술 메뉴 확인. 모바일 두 방향에서 문서 가로 넘침 없음.
|
||||
- 실물 스마트폰 GPU/터치 성능은 별도 검증이 필요함.
|
||||
|
||||
## 현재 제약과 후속 작업
|
||||
|
||||
- 단계당 패스 1회, 마지막 단계의 슛 1회 규칙은 유지. 슛이 있으면 새 행동·단계 추가는 슛 삭제 후 가능.
|
||||
- 선수 및 시선 대상은 고정 5대5. 풀코트, 대기/드리블 독립 행동, 곡선, 단계 복제/정렬은 후속.
|
||||
- 임시 저장은 현재 브라우저에만 저장. 공유 서버·계정·클라우드 동기화·PDF/영상 출력은 미구현.
|
||||
- 3D 모형은 코드로 구성한 경량 대체 모델이며 Meshy/Tripo로 생성한 결과가 아님. 정밀 캐치·드리블·발 접지와 리타기팅은 미구현.
|
||||
- 자유 시점 회전, POV 미니맵, 시선 전환 구간 세부 편집은 후속.
|
||||
|
||||
## 외부 3D 제작 준비
|
||||
|
||||
첫 후보는 Meshy 공식 MCP로 원형 생성/리깅 후 Blender MCP로 보정. 외부 서비스 연결·API 키와 생성 크레딧이 필요하므로 이번 작업에서 설치나 유료 호출은 하지 않음.
|
||||
|
||||
- Meshy: https://github.com/meshy-dev/meshy-mcp-server
|
||||
- Blender MCP: https://github.com/ahujasid/blender-mcp
|
||||
- Tripo 비교 후보: https://github.com/VAST-AI-Research/tripo-mcp
|
||||
|
||||
납품 모델 권장 조건: GLB, 미터 단위, 바닥에 발 원점, 정면 축 확인, 하나의 일관된 휴머노이드 리그, 목/머리 분리 제어, 공은 별도 메시, 색상 변경 가능한 유니폼. 최초 검증은 한 명으로 진행하며 idle/run/defensive slide/screen/pass/shoot의 관절 변형과 공 이벤트 동기화를 확인한 뒤 10명으로 확장.
|
||||
|
||||
시작 프롬프트 예시:
|
||||
|
||||
> 성인 농구 선수, 단순하고 일관된 스포츠 게임 스타일. 민소매 유니폼과 반바지, 운동화. 공이나 소품 없음. 전신 중립 A-pose, 팔과 다리가 몸통에서 분리되어 보이며 리깅 가능한 구조. 읽을 수 있는 로고나 번호는 생성하지 않음. 정면·측면·후면 레퍼런스의 비율과 복장을 일치시킬 것.
|
||||
@@ -0,0 +1,82 @@
|
||||
# 선수 시점 재생 정책 검토
|
||||
|
||||
2026-09-07 · 조사 및 권장 설계. 시점 주체 분리 등 아래 후속 정책은 아직 구현된 기능이 아니다.
|
||||
|
||||
## 판단
|
||||
|
||||
기본은 사용자가 지정한 선수 한 명에게 시점을 고정한다. 동시에 움직이는 선수 수, 공 소유 변화, 단계 전환에 따라 자동으로 다른 선수에게 전환하지 않는다. 전술 전체는 작전판으로 보고, 선수 시점은 특정 역할에서 무엇을 보고 판단해야 하는지 확인하는 용도로 구분한다.
|
||||
|
||||
| 선택 기준 | 장점 | 문제 | 권장 용도 |
|
||||
| --- | --- | --- | --- |
|
||||
| 움직이는 선수 자동 선택 | 별도 선택이 적음 | 동시 이동 우선순위가 자의적이고 정지한 스크리너·수비자의 역할을 놓침 | 기본 기능에서 제외 |
|
||||
| 공 소유자 자동 선택 | 공 진행을 따라감 | 패스할 때마다 관찰 위치가 바뀌고 공중 구간의 주체가 애매함 | 추후 별도 관전 모드 |
|
||||
| 사용자가 지정한 선수 고정 | 역할과 공간 관계가 일관됨 | 시야 밖 사건은 별도 보조가 필요 | 기본 선수 시점 |
|
||||
| 단계별 지정 선수 | 코치가 의도한 설명 순서를 구성 | 설정 부담과 전환 규칙 필요 | 추후 설명용 재생 |
|
||||
|
||||
## 외부 근거와 적용 범위
|
||||
|
||||
- [Epic 공식 리플레이 문서](https://dev.epicgames.com/documentation/fortnite/replays-feature-in-fortnite-creative?lang=en-US)는 선택한 선수를 따르는 Third Person, 해당 선수 카메라를 재생하는 Gameplay, 자유롭게 움직이는 Drone 계열을 분리한다. 시점 주체와 카메라 방식을 별도로 설계하는 참고 사례다. 농구 교육 효과를 입증하는 근거는 아니다.
|
||||
- [VisionCoach 농구 패스 시각 훈련 연구](https://www.cs.ucf.edu/courses/cap6121/spr2025/readings/Liu2024.pdf)는 선수의 1인칭 관점에서 패스 기회를 찾는 훈련을 다룬다. 특정 역할의 시각적 판단을 돕는 용도에 부합한다. 이 연구는 본 웹앱의 자동 시점 전환 규칙이나 최적 화각을 검증하지 않았다.
|
||||
- [Three.js PerspectiveCamera 문서](https://threejs.org/docs/pages/PerspectiveCamera.html)는 수직 FOV와 화면 종횡비로 원근 투영을 구성한다. 키와 화각은 서로 다른 변수이며, 세로 화면에서는 수직 화각 제한 때문에 가로 시야가 줄어들 수 있다.
|
||||
|
||||
이 문서의 구체적인 UX 및 자동 시선 우선순위는 위 사례와 현재 코드 검토를 바탕으로 한 프로젝트 설계 제안이다.
|
||||
|
||||
## 현재 코드의 의미와 문제
|
||||
|
||||
- `src/scene.js`는 `selectedPlayerId`의 위치와 시선 샘플로 POV를 계산한다. 현재도 움직이는 선수 자동 전환 방식은 아니다.
|
||||
- `src/main.js`는 같은 선택값을 편집 대상에도 사용한다. 명단에서 선수를 바꾸면 재생 세션을 벗어나 편집 미리보기로 돌아가고, 패스 도구를 선택하면 공 소유자로 선택값을 바꾼다. 이 결합 때문에 시점 기준이 불명확하게 느껴질 수 있다.
|
||||
- `src/domain.js`의 자동 시선은 패스·스크린 대상 선수, 슛의 림, 오프볼 공격자의 공, 대인 수비자의 매치업을 따른다. 이는 '누구의 눈인가'와 별개의 '무엇을 보는가' 규칙이다.
|
||||
- 눈높이는 이미 1.75m이다. 키 180~190cm 선수를 가정한 초기 눈높이로 유지할 수 있으나 개인별 신체 계측값은 아니다. 키를 올리는 것만으로 좌우 시야가 넓어지지 않는다.
|
||||
|
||||
## 권장 동작
|
||||
|
||||
1. 편집 대상 `selectedPlayerId`와 관찰 대상 `povPlayerId`를 분리한다. 처음 선수 시점에 진입할 때 선택한 선수를 관찰 대상으로 복사하고, 이후에는 명시적인 시점 선수 선택만 이를 변경한다.
|
||||
2. 상단에 `O2 시점 · 선수 고정`을 항상 표시한다. PC에서는 선수 선택 드롭다운, 모바일에서는 같은 기능의 간결한 선택 패널을 제공한다.
|
||||
3. 선택 선수의 행동이 없거나 이동이 끝나도 시점은 유지한다. 정지 상태에서 공과 다른 선수의 움직임을 관찰하는 것도 전술의 일부다.
|
||||
4. 재생 중 다른 시점 선수를 명시적으로 선택하면 일시정지하고 현재 재생 시간을 유지한다. 새 위치로 즉시 전환하며 짧은 페이드와 선수명으로 전환을 알린다. 선수 사이를 카메라가 날아가는 연출은 피한다. 재생 버튼으로 이어 본다.
|
||||
5. 단계 전환·반복 재생·패스 완료에도 관찰 대상을 유지한다. 관찰 선수가 데이터에서 사라진 경우 조용히 대체하지 말고 정지 후 재선택을 안내한다.
|
||||
6. 작전판↔선수 시점 전환은 시간과 재생/정지 상태를 보존한다. 미니맵은 이후 보조 기능으로 제공하며 자신의 위치·시야 방향·공 위치를 표시한다.
|
||||
|
||||
## 관찰 대상과 독립적인 시선 규칙
|
||||
|
||||
| 행동/상황 | 자동 시선 권장값 |
|
||||
| --- | --- |
|
||||
| 명시적으로 지정한 시선 | 해당 지시를 우선 적용 |
|
||||
| 패스 준비·릴리스 | 패스 받을 선수 |
|
||||
| 패스 수신 중 | 날아오는 공 |
|
||||
| 슛 | 림 |
|
||||
| 오프볼 이동 | 공을 기본으로 하되 이동 방향·특정 선수 지정 허용 |
|
||||
| 공 소유 이동 | 림/전방을 기본으로 하되 전술별 명시 지정 허용 |
|
||||
| 스크린 | 접근 중 이동 방향, 세팅 시 지정 수비자 등 단계별 구분을 후속 검토 |
|
||||
| 대인 수비 | 매치업 기본, 공 주시는 명시 지정; 자동 양쪽 번갈아 보기는 초기 범위 제외 |
|
||||
|
||||
눈길과 이동 방향을 동일하게 강제하지 않는다. 공을 보며 컷하거나 옆걸음으로 수비할 수 있어야 한다. 단, 현재 앱은 실제 눈동자·머리 움직임을 측정한 재현이 아니라 지정된 시선을 시뮬레이션한다.
|
||||
|
||||
## 좁은 시야 개선 방향
|
||||
|
||||
- 코트의 물리 크기(15×14m)와 이동 거리를 늘리면 전술 자체가 달라진다. 카메라가 담는 범위와 화면 구성을 넓히는 것이 우선이다.
|
||||
- 눈높이 1.75m를 기본으로 사용하고, 가로 화각을 완만하게 넓힌다. 이는 신체 키로 계산한 정답이 아니라 화면 가독성을 위한 초기 설계값이다.
|
||||
- 선수의 시선 목표를 바꾸지 않으면서 화면에서 목표를 약간 위에 배치하여 바닥·주변 선수가 더 보이게 한다.
|
||||
- 가까운 선수의 이름표 크기를 제한한다. 실제 가림은 유지하되 이름표 때문에 추가로 장면을 가리지 않도록 한다.
|
||||
- 세로 화면에서 과도한 원근 왜곡 없이 가로 화면과 동일한 범위를 담는 데는 한계가 있다. 가로 보기와 후속 미니맵을 보조 수단으로 사용한다.
|
||||
|
||||
## 후속 구현 검증 기준
|
||||
|
||||
- O1/O2/O3가 동시에 이동해도 O2 시점 유지.
|
||||
- O1→O2 패스 동안 O3 시점 유지, O3는 기존 시선 규칙에 따라 공을 관찰.
|
||||
- 정지한 스크리너·수비자를 선택해도 임의 전환 없음.
|
||||
- 시점 선수 변경 시 같은 타임스탬프에서 일시정지; 다른 선수 위치로 전환한 뒤 이어 재생.
|
||||
- 편집용 패스 도구·선수 선택이 관찰 대상을 덮어쓰지 않음.
|
||||
- 단계 전환/탐색/반복/화면 회전 후 대상과 시간 일관성 확인.
|
||||
- PC·모바일 세로·가로에서 대상 가시성, 가까운 이름표, 코트 바닥 범위를 확인.
|
||||
|
||||
실제 코드 작업은 AGENTS.md에 따라 GPT-5.6 Luna에게 위임한다.
|
||||
|
||||
## 이번에 적용한 시야 개선
|
||||
|
||||
- 기준 가로 화각 100° → 110°. 세로 화각 상한 85° → 100°로 완화. 화면 비율에 따라 실제 가로 범위는 제한될 수 있다.
|
||||
- 눈높이 1.75m 유지. 코트 물리 크기 유지.
|
||||
- 투영 영역을 높이의 8%만큼 아래로 옮겨, 시선 목표를 바꾸지 않고 바닥 영역을 더 표시.
|
||||
- 선수 시점 이름표 축소 및 가까운 이름표의 화면 크기 제한. 이름표가 머리와 겹치지 않도록 위치 조정.
|
||||
- 시점 주체 분리는 위 권장 설계로 기록했으며 이번 시야 개선에 포함하지 않았다.
|
||||
- 검증: 테스트 93개 통과, 프로덕션 빌드 성공(기존 번들 크기 경고 유지). 1440×1000 PC, 390×844 세로, 844×390 가로 화면과 POV 재생 확인. 브라우저 오류 로그 없음.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Fastify · MongoDB 실행 안내
|
||||
|
||||
## 저장소
|
||||
|
||||
사용자가 지정한 MongoDB 서버는 `172.16.0.7:27017`, 프로젝트 DB는 `basket_utils`다. SQLite 선택은 이 설정으로 대체한다. 서버는 MongoClient 연결 풀을 재사용한다.
|
||||
|
||||
프로젝트 루트 `.config.json`에서 설정을 관리한다. 예시는 `.config.json.sample`에 있으며 새 환경에서는 복사해 사용한다. 기존 설정 파일이 있으면 덮어쓰지 않는다.
|
||||
|
||||
```powershell
|
||||
Copy-Item .config.json.sample .config.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mongodb": {
|
||||
"uri": "mongodb://172.16.0.7:27017",
|
||||
"db": "basket_utils"
|
||||
},
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 3000,
|
||||
"mode": "development",
|
||||
"allowedOrigins": [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://192.168.5.10:5173"
|
||||
],
|
||||
"secureCookie": false
|
||||
},
|
||||
"bootstrap": { "password": "" },
|
||||
"qa": { "db": "basket_utils_qa", "password": "" }
|
||||
}
|
||||
```
|
||||
|
||||
MongoDB 인증 정보가 필요하면 `mongodb.uri`에 설정한다. 실제 `.config.json`은 Git 추적에서 제외하고 예시 파일에는 비밀번호를 넣지 않는다. 설정 변경 후 서버를 재시작한다.
|
||||
|
||||
## 개발 실행
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite 화면은 PC에서 `http://localhost:5173`, 같은 네트워크의 휴대폰에서 `http://192.168.5.10:5173`으로 접근한다. PC의 IP가 바뀌면 주소도 바뀐다. Vite의 `/api` 요청은 `server.port`에 지정한 Fastify 포트로 전달된다.
|
||||
|
||||
LAN 접속에는 Windows 방화벽과 공유기의 기기 간 통신 허용도 필요하다. MongoDB 주소와 휴대폰이 접속할 웹 주소는 별개다.
|
||||
|
||||
## 최초 운영자
|
||||
|
||||
공개 기본 운영자 계정은 만들지 않는다. 사용할 이메일로 회원가입한 뒤, 서버 PC에서 해당 계정을 명시적으로 승격할 수 있다.
|
||||
|
||||
```powershell
|
||||
node .\server\bootstrap-admin.js --email=your-email@example.com --promote-existing
|
||||
```
|
||||
|
||||
`your-email@example.com`을 실제 가입한 이메일로 바꾼다. 기존 비밀번호는 유지된다. 이후 로그인하여 다른 사용자의 가입 신청을 승인한다.
|
||||
|
||||
PowerShell에서는 `npm.ps1`을 통해 실행할 때 옵션이 npm 자체 설정으로 해석되어 스크립트에 전달되지 않을 수 있으므로 위처럼 Node로 직접 실행한다. 명령 끝에 역슬래시(`\`)를 붙이지 않는다.
|
||||
|
||||
새 운영자를 직접 만들 때는 `.config.json`의 `bootstrap.password`에 사용할 비밀번호를 일시적으로 설정하고 아래 명령을 실행한다. 실행 후 비밀번호 값은 다시 빈 문자열로 바꾼다.
|
||||
|
||||
```powershell
|
||||
node .\server\bootstrap-admin.js --email=your-email@example.com
|
||||
```
|
||||
|
||||
## 운영 실행
|
||||
|
||||
`.config.json`의 `server.mode`를 `production`, `server.allowedOrigins`를 실제 HTTPS 서비스 주소 배열, `server.secureCookie`를 `true`로 설정한다.
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
npm run server
|
||||
```
|
||||
|
||||
Fastify가 `dist` 화면과 API를 제공한다. 실제 도메인을 허용 출처로 설정하고 HTTPS 프록시 뒤에서 실행한다. 운영 세션 쿠키는 Secure를 사용한다.
|
||||
|
||||
휴대폰의 HTTP LAN 화면 접속만으로 OS 파일 공유 조건이 충족되지는 않는다. 카카오톡으로 MP4 파일을 직접 공유하는 실기기 검증에는 지원 브라우저와 신뢰할 수 있는 HTTPS가 필요하다.
|
||||
|
||||
## 검증
|
||||
|
||||
```powershell
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
테스트 계정은 격리된 테스트 저장소에서 사용한다. 운영 `basket_utils` DB에 QA용 계정을 임의로 넣거나 기존 컬렉션을 삭제하지 않는다.
|
||||
|
||||
2026-09-08 검증 결과: 106개 테스트 및 빌드 통과. 별도 QA DB에서 로그인·승인·다중 팀 저장/전환과 서버 재시작 후 데이터 유지, PC/모바일 화면, MP4 생성·재생을 확인했다. 기존 번들 크기 경고는 남아 있다.
|
||||
|
||||
JSON 설정 전환 후에는 설정 로더 검증 5개와 정적 파일 보호 검증 1개를 추가해 총 112개 테스트와 빌드가 통과했다. 설정 파일이 없거나 JSON 구문·값 타입이 잘못되면 시작 시 오류를 안내하며, JSON 구문 오류에 설정 원문을 출력하지 않는다. 운영 모드에서는 Secure 쿠키가 강제되고 개발용 HTTP 출처는 허용하지 않는다.
|
||||
|
||||
QA seed 명령은 `basket_utils_qa` 또는 `basket_utils_qa_` 뒤에 영문·숫자·하이픈이 붙은 DB 이름만 허용한다. 이번 검증 DB는 `basket_utils_qa_20260908review`이며 테스트 데이터는 운영 DB와 분리되어 있다. QA 서버는 검증 후 종료했다.
|
||||
@@ -0,0 +1,130 @@
|
||||
# 승인 사용자·다중 팀·전술 영상 공유 설계 조사
|
||||
|
||||
2026-09-07 · 조사/설계 제안. 이번 작업은 문서 작성이며 서비스 구현·배포·외부 API 등록은 수행하지 않았다.
|
||||
|
||||
## 권장 결론
|
||||
|
||||
승인된 계정이 여러 팀을 소유하거나 참여하고, 각 팀에 전술을 저장한다. 공유 원본은 MP4로 생성한다. 사용자가 확정한 핵심 요구는 **방금 만든 전술을 MP4 첨부영상으로 카카오톡 단체방에 보내고, 수신자가 링크 이동 없이 카카오톡에서 재생하는 것**이다. 주 동작은 '영상 공유'이며 링크 공유는 요구를 충족하는 대안이나 fallback으로 취급하지 않는다. GIF는 후순위 비교 후보다.
|
||||
|
||||
## 사용자 확정 요구에 따른 설계 변경
|
||||
|
||||
이 절이 아래 초기 비교/조사 내용보다 우선한다. 아래 링크 공유 설명은 조사 기록으로 남기며 제품 기본 경로로 채택하지 않는다.
|
||||
|
||||
- 완료 흐름: 전술 작성 → 영상 공유 → 현재 버전 MP4 생성/준비 → 공유창에서 카카오톡·단체방 선택 → 영상 첨부 전송 → 수신자가 카카오톡에서 재생.
|
||||
- 전송할 내용은 URL이나 JSON이 아닌 MP4 파일이다. 수신자의 웹사이트 로그인·팀 가입·외부 웹 플레이어 진입을 요구하지 않는다.
|
||||
- 브라우저에서 Web Share 파일 전송을 먼저 검증한다. OS 공유창과 카카오톡 대화방 선택은 사용자 조작이다. 기존 단체방을 앱이 자동으로 지정하거나 무인 전송하는 요구로 해석하지 않는다.
|
||||
- 파일 생성/준비에는 시간이 필요할 수 있다. 준비 완료 후 새 사용자 탭으로 OS 공유창을 열어 사용자 활성화 제한을 만족한다. 실제 측정 없이 최초 공유를 즉시 완료한다고 약속하지 않는다.
|
||||
- 파일 공유 미지원 환경에서 다운로드 후 수동 첨부는 임시 보조 경로일 뿐, 핵심 UX 완료로 간주하지 않는다. 주요 대상 환경에서 직접 공유가 불안정하면 네이티브 앱 또는 하이브리드 앱의 파일 공유 브리지를 검토한다. 이때도 실제 카카오톡 수신 영상으로 검증해야 한다.
|
||||
- MVP 통과 기준은 iOS/Android 실제 기기에서 생성 MP4가 기존 카카오톡 단체방에 첨부되고, 수신자가 외부 링크 없이 카카오톡에서 재생하는 것이다. 공유 API 호출 성공이나 파일 전송 가능 검사만으로 완료 처리하지 않는다.
|
||||
- 검증 시 MP4가 일반 파일로만 표시되는지, 영상 썸네일과 재생 UI를 제공하는지 확인한다. 링크 없이 탭하여 재생하는 것이 요구이며 무조건 자동 재생을 의미하지 않는다.
|
||||
- 발신자는 서비스 승인/팀 권한을 검사한다. 수신자는 카카오톡 영상 수신자이며 서비스 접근권한과 별개다. 전송한 파일은 앱에서 회수하거나 만료시킬 수 없다.
|
||||
|
||||
카카오톡 메시지에 웹사이트 링크를 보낸 것과 MP4 첨부파일을 보낸 것은 다른 경험이다. 일반 Kakao Share 템플릿을 사용해 임의 MP4를 카카오톡 채팅방 안에서 자동 재생시키는 기능을 전제로 설계하면 안 된다.
|
||||
|
||||
## 현재 프로젝트와 필요한 변화
|
||||
|
||||
현재는 Vite + Vanilla JS + Three.js의 로컬 편집 앱이며 `src/playRepository.js`에서 브라우저 localStorage에 전술을 저장한다. 사용자 인증, 사용 승인, 팀, 서버 저장, 공유 링크, 영상 렌더링 서버는 없다.
|
||||
|
||||
새 UI에 팀 이름만 추가해서는 기기 간 동기화나 팀별 접근 제어가 성립하지 않는다. 인증/승인 API, 데이터베이스, 파일 저장소, 영상 생성 작업 처리가 필요하다. 현 전술 JSON 구조는 유지하고 서버 저장 레코드에 소유 팀과 버전 정보를 덧붙이는 방향이 적합하다.
|
||||
|
||||
## 계정 및 팀 UX
|
||||
|
||||
흐름: 로그인 → 서비스 사용 승인 확인 → 내 팀 → 팀 전술함 → 편집/재생 → 공유.
|
||||
|
||||
- 로그인 수단은 카카오 로그인을 우선 후보로 제안한다. 카카오톡 공유를 하기 위해 서비스 로그인도 반드시 카카오여야 하는 것은 아니다.
|
||||
- 로그인 성공과 서비스 사용 승인을 분리한다. 운영자 초대 또는 가입 후 승인으로 `pending/approved/suspended`를 관리한다. 미승인 사용자는 승인 대기 화면까지만 접근한다.
|
||||
- 첫 진입에는 '첫 팀 만들기', 기존 사용자는 최근 사용 팀을 바로 열고 상단 팀 전환기를 제공한다.
|
||||
- 계정 하나로 여러 팀을 만들고 전환할 수 있다. 팀명·선택적 색상/로고만으로 생성 가능하게 한다. 한 팀을 만든 뒤에도 항상 '+ 팀 만들기'를 제공한다.
|
||||
- 팀 전술함은 전술 썸네일, 이름, 수정일, 태그, 영상 준비 여부를 표시한다. '새 전술'은 현재 팀에 자동 귀속된다.
|
||||
- 편집 상단에 `팀명 / 전술명`을 표시해 다른 팀에 저장하는 실수를 줄인다. 다른 팀으로는 이동보다 '복사'를 기본으로 제공하며 목적지 편집 권한을 검사한다.
|
||||
- PC는 좌측 팀 전환/전술함 + 넓은 편집 화면. 모바일은 상단 팀 전환기 + 전술 카드 목록 + 새 전술 버튼, 편집은 별도 화면으로 구성한다.
|
||||
- 기존 기기 전술은 사용자가 선택한 팀으로 '이 기기의 전술 가져오기'를 제공한다. 서버 저장 성공 전 기존 데이터를 지우지 않으며 중복 가져오기를 방지한다.
|
||||
|
||||
## 권한과 데이터
|
||||
|
||||
초기 역할 제안: 팀 소유자(팀/멤버 관리), 편집자(전술 작성·영상 생성·허용된 공유), 열람자(팀 전술 보기). 서비스 운영자의 승인 권한은 팀 소유자 역할과 별도다. 팀 초대는 서비스 이용 승인을 우회하지 않는다.
|
||||
|
||||
| 레코드 | 핵심 관계/필드 |
|
||||
| --- | --- |
|
||||
| User | 로그인 제공자 식별자, 서비스 승인 상태 |
|
||||
| Team | 팀 ID, 이름, 생성자 |
|
||||
| TeamMember | 팀 ID + 사용자 ID, 역할, 가입 상태 |
|
||||
| Play | 팀 ID, 전술 JSON, 현재 버전, 작성/수정자 |
|
||||
| PlayRevision | 변경하지 않는 전술 스냅샷 |
|
||||
| VideoExport | 전술 버전, 카메라/선수/화질 설정, 대기·처리·완료·실패 상태, 영상/썸네일 경로 |
|
||||
| ShareLink | 공유 영상/버전, 열람 정책, 만료, 철회 상태, 추측하기 어려운 토큰 |
|
||||
|
||||
모든 팀 데이터·영상 생성·공유 설정 API에서 서비스 승인 및 해당 팀 권한을 서버가 확인한다. 클라이언트의 teamId나 숨긴 버튼을 권한으로 신뢰하지 않는다. 기존 전술의 offense/defense는 작전판 진영이며, 새 Team 엔터티와 구분한다.
|
||||
|
||||
## GIF / MP4 / 링크 비교
|
||||
|
||||
| 형식 | 적합한 점 | 제약 | 판단 |
|
||||
| --- | --- | --- | --- |
|
||||
| GIF | 짧은 반복 미리보기 | 3D 장면·텍스트의 화질/용량 부담, 재생 위치·속도 조절에 부적합, 수신 앱의 재생 방식에 종속 | 후순위 옵션 |
|
||||
| MP4 파일 | 휴대폰에서 공유·저장하는 일반 영상, 작전판/POV 모두 가능 | 생성·전송 시간, 배포한 파일은 회수 불가 | 기본 영상 포맷 |
|
||||
| 영상 재생 링크 | 작은 메시지, 열람 권한·만료·철회 가능, 웹 재생 컨트롤 제공 | 네트워크 필요, 채팅방 안 첨부영상과 다르게 링크를 열어 재생 | 기본 카카오 공유 방식 |
|
||||
|
||||
MP4와 링크는 대안 관계가 아니다. 같은 MP4 자산을 링크 재생과 파일 공유 양쪽에서 사용한다. GIF 대비 영상의 용량 효율은 장면마다 다르므로 실제 작전판·POV 클립으로 비교한다. [Google web.dev](https://web.dev/articles/replace-gifs-with-videos?hl=en)
|
||||
|
||||
## 카카오톡 링크 공유
|
||||
|
||||
권장 흐름: 공유 패널 → 썸네일/전술명/열람 범위 확인 → '카카오톡으로 링크 보내기' → 카카오톡에서 대화방 선택 → 수신자가 '전술 보기' 클릭 → 모바일 웹 플레이어.
|
||||
|
||||
- Kakao JavaScript SDK의 `Kakao.Share.sendDefault()` 등으로 피드 카드를 보낸다. 썸네일·제목·재생 페이지 URL을 사용한다.
|
||||
- Kakao Developers 앱, JavaScript 키와 사용 도메인 설정, 접근 가능한 HTTPS 재생 페이지가 필요하다. 비밀 키는 프런트엔드에 넣지 않는다.
|
||||
- SDK의 공유 API와 친구 메시지 API는 다르다. 사용자가 기존 단체방을 골라 공유하는 요구에는 Share가 맞다. 우리 앱의 Team과 카카오톡 단체방은 자동 연결되지 않는다.
|
||||
- 일반 템플릿은 링크 카드다. 썸네일에 재생 아이콘을 넣어도 임의 MP4의 채팅방 내 자동 재생을 보장하지 않는다.
|
||||
- 재생 페이지는 `<video controls playsinline>`와 포스터 이미지를 사용한다. 자동 재생에 의존하지 않고 눈에 띄는 재생 버튼을 제공한다.
|
||||
- 영상 준비 완료 후 공유한다. 공유된 링크가 생성 실패 화면으로 열리지 않도록 한다. 썸네일 캐시는 수정 가능성을 고려해 버전별 주소를 사용한다.
|
||||
|
||||
근거: [Kakao Share 이해하기](https://developers.kakao.com/docs/ko/kakaotalk-share/common), [JavaScript 연동](https://developers.kakao.com/docs/ko/kakaotalk-share/js-link), [FAQ](https://developers.kakao.com/docs/en/kakaotalk-share/faq).
|
||||
|
||||
## 영상 파일을 카카오톡으로 바로 공유
|
||||
|
||||
권장 흐름: 생성된 MP4 준비 → '영상 파일 공유' 탭 → OS 공유창 → 카카오톡 선택 → 대화방 선택 → 전송.
|
||||
|
||||
- 웹에서는 MP4를 `File`로 준비한 후 `navigator.canShare({ files: [file] })`를 검사하고 `navigator.share({ files: [file] })`로 OS 공유창을 연다.
|
||||
- 표준 Web Share API에서 목적 앱이나 채팅방을 카카오톡으로 강제 지정할 수 없다. 사용자가 공유 대상을 선택한다.
|
||||
- 웹사이트 HTTPS, 브라우저의 파일 공유 지원, 사용자 탭이 필요하다. `canShare` 통과는 카카오톡 설치·대상 노출·전송 완료·채팅방 렌더링을 모두 보장하지 않는다.
|
||||
- 영상 생성이나 다운로드를 오래 기다린 뒤 같은 최초 탭으로 공유창을 열려고 하면 사용자 활성화가 만료될 수 있다. 파일까지 준비된 다음 버튼을 활성화하고 새 탭에서 공유 호출한다.
|
||||
- 지원하지 않으면 '영상 다운로드'와 '카카오톡 링크 공유'를 제공한다. 카카오톡 인앱브라우저에서 막히는 경우 외부 브라우저에서 여는 안내도 제공한다.
|
||||
- 생성 완료 상태와 파일 다운로드 완료 상태를 구분한다. 첫 생성은 즉시 끝나지 않는다. 수정본·설정별 결과를 재사용하여 반복 공유의 대기 시간을 줄인다.
|
||||
- Share Promise 종료를 대화방 전송 완료로 단정하지 않는다. 취소와 실패도 분리한다.
|
||||
|
||||
카카오 공식 문서도 템플릿 없이 파일을 공유하려면 OS 공유 기능을 사용하라고 안내한다. 다만 웹의 OS 공유 연결은 브라우저마다 차이가 있으므로 실기기 검증이 필요하다. [Kakao OS 공유 안내](https://developers.kakao.com/docs/ko/kakaotalk-share/common), [W3C Web Share](https://www.w3.org/TR/web-share/), [파일 공유 지원 검사](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share).
|
||||
|
||||
## 영상 생성 방식
|
||||
|
||||
중요 기능이므로 최종 배포 구조는 서버 작업으로 렌더링하는 방식을 권장한다. 모바일 성능·백그라운드 전환·브라우저 코덱 차이에 영향을 덜 받는다. 아래는 제안 구조이며 실제 처리 속도는 측정해야 한다.
|
||||
|
||||
1. 승인/팀 권한 확인 후 전술 버전과 출력 설정을 스냅샷으로 고정한다.
|
||||
2. 렌더링 작업을 등록한다. 동일 버전 + 설정 + 렌더러 버전의 완료 결과는 재사용한다.
|
||||
3. 전용 Three.js 렌더 화면에서 고정 시간 간격으로 전술을 샘플링해 프레임을 생성한다. DOM 편집 UI가 아닌 코트/선수/영상용 표기만 출력한다.
|
||||
4. FFmpeg로 MP4(H.264, yuv420p, 빠른 재생 시작을 위한 faststart)와 포스터를 만든다. 기본 제안은 가로 1280×720, 30fps, 무음이며 실제 글자 크기·파일 크기를 보고 조정한다.
|
||||
5. 비공개 파일 저장소에 저장하고 열람 정책에 따라 미디어 URL을 발급한다. 재생 페이지의 범위 요청 지원으로 탐색을 제공한다.
|
||||
6. 공유 패널에 준비 완료와 미리보기를 표시한다.
|
||||
|
||||
초기 기술 검증은 `canvas.captureStream()` + `MediaRecorder`로 가능하지만 브라우저별 출력 코덱 지원을 검사해야 한다. WebM 파일 확장자만 MP4로 바꾸는 것은 변환이 아니다. 모바일에서 FFmpeg WASM으로 모든 변환을 처리하는 방식을 기본 경로로 삼지 않는다. 서버 렌더링은 운영비와 WebGL 실행 환경 확인이 필요하므로 짧은 실제 클립으로 먼저 검증한다.
|
||||
|
||||
출력 카메라는 기본 '작전판 전체', 선택 옵션 '지정 선수 시점'을 제안한다. POV는 선수 ID를 출력 설정에 고정하고 공유 카드에 명시한다. 화면을 세로 영상으로 단순 크롭해 양쪽 선수를 잃지 않도록 한다. 공유한 영상은 특정 버전으로 고정하고 이후 편집으로 조용히 내용이 바뀌지 않게 한다.
|
||||
|
||||
근거: [MediaRecorder](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder), [FFmpeg MP4 문서](https://ffmpeg.org/ffmpeg-formats.html).
|
||||
|
||||
## 승인제와 공유 열람 정책
|
||||
|
||||
기본 전술함/공유는 승인된 팀 멤버만 볼 수 있도록 한다. 로그인 후 원래 공유 페이지로 복귀해야 한다.
|
||||
|
||||
수신자가 가입 없이 곧바로 보게 하려면 별도 '링크를 가진 사람은 열람' 정책을 팀 소유자가 명시적으로 허용해야 한다. 이 정책은 서비스 전체 승인제의 의도적 예외이므로 구현 전에 제품 정책을 확정해야 한다. 편집권이나 팀 목록까지 공개하지 않고 해당 버전 영상만 공개한다.
|
||||
|
||||
민감한 팀 전술은 공개 썸네일에 노출하지 않는다. 팀 전용 링크의 카카오 카드에는 일반 코트 이미지를 사용할 수 있다. 파일 저장소는 비공개로 두고, 공유 페이지에서 정책 검사 후 짧은 유효기간의 재생 URL을 발급한다. 철회는 이후 열람을 막는 기능이며 이미 다운로드되거나 카카오톡으로 전송된 MP4/GIF는 회수할 수 없다.
|
||||
|
||||
## 구현 순서와 완료 기준
|
||||
|
||||
1. **공유 기술 검증:** 대표 작전판/POV MP4 1개씩으로 iPhone Safari, Android Chrome, iOS/Android 카카오톡 인앱브라우저에서 링크·파일 공유·수신 재생을 확인한다. 파일 크기와 생성/준비/전송 시간을 기록한다. 문서만으로 '모든 모바일 즉시 공유'를 약속하지 않는다.
|
||||
2. **서비스 기반:** 로그인·운영자 승인·여러 팀 생성/전환·팀별 전술 저장·역할 검사·로컬 데이터 가져오기.
|
||||
3. **기본 공유:** 버전별 MP4 생성, 상태/재시도, 재생 페이지, 팀 전용 링크, 카카오 카드 공유, 파일 공유 및 다운로드 fallback.
|
||||
4. **선택 확장:** 명시적으로 허용된 외부 열람 링크, 만료·철회, 짧은 GIF, 화질/시점 프리셋.
|
||||
|
||||
검증에는 미승인 사용자, 비멤버의 팀 ID 접근, 승인 취소, 중복 영상 생성 요청, 수정 중 영상 생성, 공유 취소, 앱 미설치, 느린 네트워크, 만료 링크, 세션 만료, 기존 카카오 미리보기 캐시, 파일 다운로드 후 재공유를 포함한다.
|
||||
|
||||
사용자가 팀 A/B를 생성하고 각 전술이 올바른 팀에 저장되는지, 다른 팀으로 전환해도 섞이지 않는지, 한 계정의 다른 기기에서 같은 내용을 보는지를 확인한다. 코드 구현은 AGENTS.md에 따라 GPT-5.6 Luna에게 위임한다.
|
||||
@@ -0,0 +1,43 @@
|
||||
# MP4 파일 공유 구현 및 검증
|
||||
|
||||
2026-09-07
|
||||
|
||||
## 적용 범위
|
||||
|
||||
GPT-5.6 Luna(xhigh)에 구현을 위임하고 주 에이전트가 코드 검토 및 브라우저 QA를 수행했다.
|
||||
|
||||
- 상단 '영상 공유'에서 관리 항목을 제외한 공유 전용 패널을 연다.
|
||||
- 작전판 전체 또는 선택 선수 POV로 1280×720 MP4를 만든다. POV 선택 시 선수 번호를 표시한다.
|
||||
- MediaRecorder가 지원하는 실제 MP4 MIME을 선택하며 WebM을 MP4로 이름만 바꾸지 않는다.
|
||||
- 별도 WebGL 출력 캔버스에서 전술 스냅샷을 렌더링하고 진행률·취소·영상 미리보기를 제공한다.
|
||||
- 벽시계 시간 기준으로 프레임을 예약해 화면 주사율에 따라 영상이 빨라지는 문제를 방지한다.
|
||||
- 생성 완료 후 별도의 사용자 클릭에서 Web Share 파일 공유를 호출한다. 카카오톡·대화방은 사용자가 OS 및 카카오톡 UI에서 선택한다.
|
||||
- 파일 공유가 안 되는 환경에는 MP4 다운로드를 보조 수단으로 제공한다. 링크 공유는 구현하지 않았다.
|
||||
- 전술·이름·출력 시점이 바뀌면 이전 영상의 공유를 차단한다. 생성/메타데이터 확인 중 취소와 리소스 정리를 처리한다.
|
||||
- 미리보기 키보드 재생과 편집기 재생 단축키를 분리한다.
|
||||
|
||||
## 실제 브라우저 확인
|
||||
|
||||
- 최종 `npm test`: 102개 통과. `npm run build`: 성공. 기존 단일 번들 크기 경고는 유지.
|
||||
- 현재 인앱 Chromium 환경에서 작전판 MP4가 1280×720, 약 1.65초로 생성됐다. 원본 전술은 UI 기준 약 1.7초다.
|
||||
- O1 선수 시점도 1280×720, 약 1.64초 영상으로 생성됐다. 두 영상 모두 미리보기에서 끝까지 재생되어 ended 상태에 도달했다.
|
||||
- 390×844 모바일 뷰포트와 1440×1000 PC에서 공유 패널 표시를 확인했다. 이는 화면 크기 검증이며 실제 모바일 OS 테스트가 아니다.
|
||||
- 생성 취소 후 공유 버튼 비활성화, 전술 이름 변경 후 기존 영상 무효화, 미리보기 Space 재생 후 편집기 시간 0 유지 확인.
|
||||
- OS 파일 공유 호출 후 취소 상태를 확인했다. 실제 카카오톡 대화방으로 메시지를 전송하지 않았다.
|
||||
- 브라우저 오류 로그 없음.
|
||||
- 최종 카메라 보존 수정 후 POV 영상을 다시 생성하고, 닫은 뒤 원래 선수 시점과 재생 시간 0으로 돌아오는 것을 확인했다.
|
||||
|
||||
## 제약 및 남은 완료 조건
|
||||
|
||||
- 실제 iOS/Android 카카오톡 단체방 전송과 수신 영상 UI/재생은 검증하지 못했다. 이것이 제품의 최종 인수 기준이며 브라우저 생성 성공으로 대체할 수 없다.
|
||||
- 직접 MP4 녹화를 지원하지 않는 브라우저에서는 영상 생성이 안 된다. 파일 공유 지원과 영상 생성 지원은 각각 다르다. 주요 대상 기기에서 불안정하면 서버 인코딩이나 네이티브 파일 공유 브리지로 보완해야 한다.
|
||||
- 브라우저 캡처는 실시간 녹화다. 생성에는 영상 길이 정도의 시간이 필요하고, 느린 GPU나 프레임 드롭으로 실제 파일 길이·프레임 간격에 작은 차이가 생길 수 있다. 생성 중 탭을 숨기면 실패로 처리한다.
|
||||
- 저장소·승인 로그인·다중 팀·서버 렌더링·HTTPS 배포는 이번 MP4 공유 변경에 포함되지 않았다.
|
||||
- 배포한 MP4는 회수/만료할 수 없다. 공유 API 응답만으로 단체방 전송 완료를 단정하지 않는다.
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `src/videoExport.js`: MP4 생성·검증·취소·파일 공유·다운로드.
|
||||
- `src/videoExport.test.js`: 녹화 시간, 지원 검사, stale/cancel/메타데이터 정리, 공유 동작.
|
||||
- `src/scene.js`: 별도 영상 출력 캔버스와 카메라 처리.
|
||||
- `src/main.js`, `src/ui.js`, `src/style.css`: 공유 패널과 기존 편집기 통합.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Court Lab · 농구 전술보드</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3013
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "basket-utils-tactical-board",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"node server/index.js\" \"vite --host 0.0.0.0\"",
|
||||
"server": "node server/index.js",
|
||||
"bootstrap-admin": "node server/bootstrap-admin.js",
|
||||
"seed-qa": "node server/seed-qa.js",
|
||||
"build": "vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/static": "^10.1.3",
|
||||
"fastify": "^5.12.3",
|
||||
"mongodb": "^7.6.0",
|
||||
"three": "^0.178.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.4",
|
||||
"vite": "^7.1.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { isValidPlay } from '../src/playRepository.js';
|
||||
import { createMongoStore, DEFAULT_MONGODB_DB, DEFAULT_MONGODB_URI } from './db.js';
|
||||
import { isDevelopmentOrigin, loadConfig } from './config.js';
|
||||
import { hashPassword, hashToken, newSessionToken, sessionExpiry, SESSION_COOKIE, verifyPassword, validEmail } from './security.js';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(here, '..');
|
||||
const defaultStaticRoot = path.join(projectRoot, 'dist');
|
||||
|
||||
function nowDate() { return new Date(); }
|
||||
function iso(value) { return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); }
|
||||
function safeText(value, fallback = '') { return String(value ?? '').trim() || fallback; }
|
||||
function publicUser(row) { return { id: row.id, email: row.email, displayName: row.displayName, status: row.status, isOperator: Boolean(row.isOperator) }; }
|
||||
function publicTeam(row) { return { id: row.id, name: row.name, ownerUserId: row.ownerUserId, role: row.role, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) }; }
|
||||
function errorPayload(code, message) { return { error: { code, message } }; }
|
||||
function fail(reply, status, code, message) { return reply.code(status).send(errorPayload(code, message)); }
|
||||
function parsePositiveId(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value); }
|
||||
function isProtectedConfigPath(url) { return /(?:^|\/)\.config\.json(?:\.[^/]*)?$/.test(String(url).split('?')[0]); }
|
||||
function requestOriginAllowed(request, allowedOrigins, mode) {
|
||||
const origin = request.headers.origin;
|
||||
if (!origin) return true;
|
||||
if (origin === 'null') return false;
|
||||
if (mode === 'production' && isDevelopmentOrigin(origin)) return false;
|
||||
if (allowedOrigins.includes(origin)) return true;
|
||||
if (mode !== 'production' && isDevelopmentOrigin(origin)) return true;
|
||||
return false;
|
||||
}
|
||||
function bodySchema(properties, required = []) { return { type: 'object', additionalProperties: false, required, properties }; }
|
||||
const emailSchema = { type: 'string', minLength: 3, maxLength: 320 };
|
||||
const passwordSchema = { type: 'string', minLength: 8, maxLength: 200 };
|
||||
|
||||
export async function buildServer(options = {}) {
|
||||
const config = options.config || await loadConfig();
|
||||
const mode = options.mode || config.server.mode;
|
||||
const store = options.db || await createMongoStore({ config, uri: options.mongoUri || config.mongodb.uri, dbName: options.mongoDb || config.mongodb.db, client: options.mongoClient, serverSelectionTimeoutMS: options.serverSelectionTimeoutMs });
|
||||
const staticRoot = options.staticRoot || defaultStaticRoot;
|
||||
const allowedOrigins = options.allowedOrigins || (options.allowedOrigin ? String(options.allowedOrigin).split(',').map((origin) => origin.trim()).filter(Boolean) : config.server.allowedOrigins);
|
||||
const secureCookie = mode === 'production' ? true : options.secureCookie ?? config.server.secureCookie;
|
||||
const app = Fastify({ logger: options.logger ?? false, bodyLimit: 2_500_000 });
|
||||
const loginAttempts = new Map(); const registerAttempts = new Map();
|
||||
|
||||
app.decorate('courtStore', store);
|
||||
app.decorateRequest('user', null);
|
||||
app.register(cookie);
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (isProtectedConfigPath(request.url)) return fail(reply, 404, 'not_found', '요청한 경로를 찾을 수 없습니다');
|
||||
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method) && !requestOriginAllowed(request, allowedOrigins, mode)) return fail(reply, 403, 'csrf_origin', '허용되지 않은 요청 출처입니다');
|
||||
const token = request.cookies[SESSION_COOKIE];
|
||||
if (!token) return;
|
||||
const session = await store.sessions.findOne({ tokenHash: hashToken(token), expiresAt: { $gt: nowDate() } });
|
||||
if (!session) return;
|
||||
const row = await store.users.findOne({ id: session.userId });
|
||||
if (!row || row.status === 'suspended') return;
|
||||
request.user = row;
|
||||
});
|
||||
|
||||
app.addHook('onClose', async () => { await store.close(); });
|
||||
|
||||
const requireUser = async (request, reply) => {
|
||||
if (!request.user) return fail(reply, 401, 'unauthenticated', '로그인이 필요합니다');
|
||||
if (request.user.status !== 'approved') return fail(reply, 403, 'approval_required', '운영자 승인을 기다리는 계정입니다');
|
||||
};
|
||||
const requireOperator = async (request, reply) => {
|
||||
const auth = await requireUser(request, reply); if (auth) return auth;
|
||||
if (!request.user.isOperator) return fail(reply, 403, 'operator_required', '운영자 권한이 필요합니다');
|
||||
};
|
||||
async function teamMember(teamId, userId) {
|
||||
const member = await store.teamMembers.findOne({ teamId, userId });
|
||||
if (!member) return null;
|
||||
const team = await store.teams.findOne({ id: teamId });
|
||||
return team ? { ...team, role: member.role } : null;
|
||||
}
|
||||
const requireTeam = (minimum = 'viewer') => async (request, reply) => {
|
||||
const auth = await requireUser(request, reply); if (auth) return auth;
|
||||
const teamId = request.params.teamId;
|
||||
if (!parsePositiveId(teamId)) return fail(reply, 400, 'invalid_team', '팀 식별자가 올바르지 않습니다');
|
||||
const member = await teamMember(teamId, request.user.id);
|
||||
if (!member) return fail(reply, 404, 'team_not_found', '팀을 찾을 수 없습니다');
|
||||
const rank = { viewer: 1, editor: 2, owner: 3 };
|
||||
if ((rank[member.role] || 0) < (rank[minimum] || 1)) return fail(reply, 403, 'team_forbidden', '팀 권한이 부족합니다');
|
||||
request.team = member;
|
||||
};
|
||||
|
||||
app.get('/api/health', async () => ({ ok: true }));
|
||||
|
||||
app.post('/api/auth/register', {
|
||||
schema: { body: bodySchema({ email: emailSchema, password: passwordSchema, displayName: { type: 'string', minLength: 1, maxLength: 80 } }, ['email', 'password']) },
|
||||
}, async (request, reply) => {
|
||||
const email = safeText(request.body.email).toLowerCase(); const password = String(request.body.password || ''); const displayName = safeText(request.body.displayName, email.split('@')[0]).slice(0, 80);
|
||||
const key = `${request.ip}:${email}`; const currentTime = Date.now(); const attempt = registerAttempts.get(key);
|
||||
for (const [entry, value] of registerAttempts) if (value.resetAt <= currentTime) registerAttempts.delete(entry);
|
||||
if (attempt && attempt.until > currentTime) return fail(reply, 429, 'register_throttled', '가입 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요');
|
||||
const nextAttempt = attempt && attempt.resetAt > currentTime ? { count: attempt.count + 1, resetAt: attempt.resetAt } : { count: 1, resetAt: currentTime + 15 * 60 * 1000 };
|
||||
if (nextAttempt.count >= 5) nextAttempt.until = currentTime + 60 * 1000;
|
||||
if (registerAttempts.size >= 10_000) registerAttempts.delete(registerAttempts.keys().next().value); registerAttempts.set(key, nextAttempt);
|
||||
if (!validEmail(email)) return fail(reply, 400, 'invalid_email', '이메일 형식이 올바르지 않습니다');
|
||||
if (password.length < 8) return fail(reply, 400, 'invalid_password', '비밀번호는 8자 이상이어야 합니다');
|
||||
if (await store.users.findOne({ email }, { projection: { id: 1 } })) return fail(reply, 409, 'email_exists', '이미 가입된 이메일입니다');
|
||||
const createdAt = nowDate(); const id = randomUUID(); const passwordHash = await hashPassword(password);
|
||||
try { await store.users.insertOne({ id, email, displayName, passwordHash, status: 'pending', isOperator: false, createdAt, updatedAt: createdAt }); }
|
||||
catch (error) { if (error?.code === 11000) return fail(reply, 409, 'email_exists', '이미 가입된 이메일입니다'); throw error; }
|
||||
return reply.code(201).send({ user: { id, email, displayName, status: 'pending', isOperator: false }, message: '가입 신청이 접수되었습니다. 운영자 승인을 기다려 주세요.' });
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', {
|
||||
schema: { body: bodySchema({ email: emailSchema, password: { type: 'string', minLength: 1, maxLength: 200 } }, ['email', 'password']) },
|
||||
}, async (request, reply) => {
|
||||
const email = safeText(request.body.email).toLowerCase(); const password = String(request.body.password || ''); const key = `${request.ip}:${email}`; const currentTime = Date.now();
|
||||
for (const [entry, value] of loginAttempts) if (value.resetAt <= currentTime) loginAttempts.delete(entry);
|
||||
const attempt = loginAttempts.get(key);
|
||||
if (attempt && attempt.until > currentTime) return fail(reply, 429, 'login_throttled', '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요');
|
||||
const row = await store.users.findOne({ email }); const valid = row ? await verifyPassword(password, row.passwordHash) : false;
|
||||
if (!valid) {
|
||||
const next = attempt && attempt.resetAt > currentTime ? { count: attempt.count + 1, resetAt: attempt.resetAt } : { count: 1, resetAt: currentTime + 15 * 60 * 1000 };
|
||||
if (next.count >= 10) next.until = currentTime + 30 * 1000;
|
||||
if (loginAttempts.size >= 10_000) loginAttempts.delete(loginAttempts.keys().next().value);
|
||||
loginAttempts.set(key, next); return fail(reply, 401, 'invalid_credentials', '이메일 또는 비밀번호가 올바르지 않습니다');
|
||||
}
|
||||
loginAttempts.delete(key);
|
||||
if (row.status === 'pending') return fail(reply, 403, 'approval_required', '운영자 승인을 기다리는 계정입니다');
|
||||
if (row.status === 'suspended') return fail(reply, 403, 'account_suspended', '사용이 중지된 계정입니다');
|
||||
const token = newSessionToken(); const createdAt = nowDate();
|
||||
await store.sessions.deleteMany({ expiresAt: { $lte: createdAt } });
|
||||
await store.sessions.insertOne({ tokenHash: hashToken(token), userId: row.id, expiresAt: new Date(sessionExpiry()), createdAt });
|
||||
reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: 7 * 24 * 60 * 60 });
|
||||
return { user: publicUser(row) };
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (request, reply) => {
|
||||
const token = request.cookies[SESSION_COOKIE]; if (token) await store.sessions.deleteOne({ tokenHash: hashToken(token) });
|
||||
reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', { preHandler: requireUser }, async (request) => ({ user: publicUser(request.user) }));
|
||||
|
||||
app.get('/api/teams', { preHandler: requireUser }, async (request) => {
|
||||
const memberships = await store.teamMembers.find({ userId: request.user.id }).toArray();
|
||||
const teams = await store.teams.find({ id: { $in: memberships.map((membership) => membership.teamId) } }).sort({ updatedAt: -1, name: 1 }).toArray();
|
||||
const roles = new Map(memberships.map((membership) => [membership.teamId, membership.role]));
|
||||
return { teams: teams.map((team) => publicTeam({ ...team, role: roles.get(team.id) })) };
|
||||
});
|
||||
|
||||
app.post('/api/teams', {
|
||||
preHandler: requireUser,
|
||||
schema: { body: bodySchema({ name: { type: 'string', minLength: 1, maxLength: 100 } }, ['name']) },
|
||||
}, async (request, reply) => {
|
||||
const name = safeText(request.body.name); if (!name) return fail(reply, 400, 'invalid_team_name', '팀 이름을 입력해 주세요');
|
||||
const id = randomUUID(); const createdAt = nowDate();
|
||||
await store.teams.insertOne({ id, name, ownerUserId: request.user.id, createdAt, updatedAt: createdAt });
|
||||
try { await store.teamMembers.insertOne({ teamId: id, userId: request.user.id, role: 'owner', createdAt }); }
|
||||
catch (error) { await store.teams.deleteOne({ id, ownerUserId: request.user.id }); throw error; }
|
||||
return reply.code(201).send({ team: publicTeam({ id, name, ownerUserId: request.user.id, role: 'owner', createdAt, updatedAt: createdAt }) });
|
||||
});
|
||||
|
||||
app.get('/api/teams/:teamId', { preHandler: requireTeam('viewer') }, async (request) => ({ team: publicTeam(request.team) }));
|
||||
|
||||
app.get('/api/teams/:teamId/plays', { preHandler: requireTeam('viewer') }, async (request) => {
|
||||
const rows = await store.plays.find({ teamId: request.params.teamId }, { projection: { _id: 0, id: 1, name: 1, updatedAt: 1 } }).sort({ updatedAt: -1, name: 1 }).toArray();
|
||||
return { plays: rows.map((row) => ({ id: row.id, name: row.name, updatedAt: iso(row.updatedAt) })) };
|
||||
});
|
||||
|
||||
app.get('/api/teams/:teamId/plays/:playId', { preHandler: requireTeam('viewer') }, async (request, reply) => {
|
||||
if (!parsePositiveId(request.params.playId)) return fail(reply, 400, 'invalid_play', '전술 식별자가 올바르지 않습니다');
|
||||
const row = await store.plays.findOne({ teamId: request.params.teamId, id: request.params.playId }, { projection: { _id: 0, data: 1 } });
|
||||
if (!row) return fail(reply, 404, 'play_not_found', '전술을 찾을 수 없습니다');
|
||||
return { play: row.data };
|
||||
});
|
||||
|
||||
app.put('/api/teams/:teamId/plays/:playId', {
|
||||
preHandler: requireTeam('editor'),
|
||||
schema: { body: bodySchema({ play: { type: 'object' } }, ['play']) },
|
||||
}, async (request, reply) => {
|
||||
const { play } = request.body; let valid = false; try { valid = Boolean(play && isValidPlay(play)); } catch { valid = false; }
|
||||
if (!parsePositiveId(request.params.playId) || !play || play.id !== request.params.playId || !valid) return fail(reply, 400, 'invalid_play', '저장할 전술 데이터가 올바르지 않습니다');
|
||||
const name = safeText(play.name, '새 전술').slice(0, 160); const copy = structuredClone(play); copy.name = name; const updatedAt = nowDate();
|
||||
const result = await store.plays.findOneAndUpdate({ teamId: request.params.teamId, id: request.params.playId }, { $set: { name, data: copy, updatedAt, updatedBy: request.user.id }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true, returnDocument: 'after', includeResultMetadata: true });
|
||||
return reply.code(result?.lastErrorObject?.upserted ? 201 : 200).send({ id: copy.id, name, updatedAt: updatedAt.toISOString(), play: copy });
|
||||
});
|
||||
|
||||
app.get('/api/admin/users', { preHandler: requireOperator }, async (request) => {
|
||||
const status = ['pending', 'approved', 'suspended'].includes(request.query?.status) ? request.query.status : 'pending';
|
||||
const rows = await store.users.find({ status }).sort({ createdAt: 1 }).toArray(); return { users: rows.map(publicUser) };
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:userId/approve', { preHandler: requireOperator }, async (request, reply) => {
|
||||
const result = await store.users.findOneAndUpdate({ id: request.params.userId, status: 'pending' }, { $set: { status: 'approved', updatedAt: nowDate() } }, { returnDocument: 'after' });
|
||||
const user = result?.value || result; if (!user) return fail(reply, 404, 'user_not_found', '승인 대기 사용자를 찾을 수 없습니다'); return { user: publicUser(user) };
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:userId/suspend', { preHandler: requireOperator, schema: { body: bodySchema({}, []) } }, async (request, reply) => {
|
||||
const result = await store.users.updateOne({ id: request.params.userId }, { $set: { status: 'suspended', updatedAt: nowDate() } });
|
||||
if (!result.modifiedCount) return fail(reply, 404, 'user_not_found', '사용자를 찾을 수 없습니다');
|
||||
await store.sessions.deleteMany({ userId: request.params.userId }); return { ok: true };
|
||||
});
|
||||
|
||||
if (options.serveStatic !== false) {
|
||||
app.register(fastifyStatic, { root: staticRoot, prefix: '/', decorateReply: true });
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
if (request.method === 'GET' && !request.url.startsWith('/api/')) return reply.sendFile('index.html');
|
||||
return fail(reply, 404, 'not_found', '요청한 경로를 찾을 수 없습니다');
|
||||
});
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
export { DEFAULT_MONGODB_DB, DEFAULT_MONGODB_URI };
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createInitialPlay } from '../src/domain.js';
|
||||
import { buildServer } from './app.js';
|
||||
import { hashPassword } from './security.js';
|
||||
|
||||
function clone(value) { return structuredClone(value); }
|
||||
function matches(document, filter) {
|
||||
return Object.entries(filter).every(([key, expected]) => {
|
||||
const actual = document[key];
|
||||
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
|
||||
if ('$gt' in expected && !(actual > expected.$gt)) return false;
|
||||
if ('$lte' in expected && !(actual <= expected.$lte)) return false;
|
||||
if ('$in' in expected && !expected.$in.includes(actual)) return false;
|
||||
return true;
|
||||
}
|
||||
return actual === expected;
|
||||
});
|
||||
}
|
||||
function project(document, projection = {}) {
|
||||
if (!projection || !Object.keys(projection).length) return clone(document);
|
||||
const include = Object.entries(projection).filter(([, value]) => value === 1).map(([key]) => key);
|
||||
const result = include.length ? Object.fromEntries(include.filter((key) => key in document).map((key) => [key, clone(document[key])])) : clone(document);
|
||||
if (projection._id === 0) delete result._id;
|
||||
return result;
|
||||
}
|
||||
function collection() {
|
||||
const documents = [];
|
||||
return {
|
||||
documents,
|
||||
async createIndex() { return 'index'; },
|
||||
async findOne(filter, options) { const document = documents.find((item) => matches(item, filter)); return document ? project(document, options?.projection) : null; },
|
||||
find(filter, options) {
|
||||
let result = documents.filter((item) => matches(item, filter)).map((item) => project(item, options?.projection));
|
||||
return { sort(spec) { result.sort((left, right) => { for (const [key, direction] of Object.entries(spec)) { const a = left[key]; const b = right[key]; if (a === b) continue; return (a > b ? 1 : -1) * direction; } return 0; }); return this; }, toArray: async () => result };
|
||||
},
|
||||
async insertOne(document) { if (documents.some((item) => item.email && item.email === document.email)) { const error = new Error('duplicate'); error.code = 11000; throw error; } documents.push(clone(document)); return { acknowledged: true }; },
|
||||
async updateOne(filter, update) { const document = documents.find((item) => matches(item, filter)); if (!document) return { modifiedCount: 0 }; Object.assign(document, clone(update.$set || {})); return { modifiedCount: 1 }; },
|
||||
async findOneAndUpdate(filter, update, options) { let document = documents.find((item) => matches(item, filter)); let upserted = false; if (!document && options?.upsert) { document = { ...clone(filter), ...clone(update.$setOnInsert || {}) }; documents.push(document); upserted = true; } if (!document) return null; Object.assign(document, clone(update.$set || {})); const value = clone(document); return options?.includeResultMetadata ? { value, lastErrorObject: upserted ? { upserted: document.id } : {} } : value; },
|
||||
async deleteOne(filter) { const index = documents.findIndex((item) => matches(item, filter)); if (index < 0) return { deletedCount: 0 }; documents.splice(index, 1); return { deletedCount: 1 }; },
|
||||
async deleteMany(filter) { let deletedCount = 0; for (let index = documents.length - 1; index >= 0; index -= 1) if (matches(documents[index], filter)) { documents.splice(index, 1); deletedCount += 1; } return { deletedCount }; },
|
||||
};
|
||||
}
|
||||
function memoryStore() { return { users: collection(), sessions: collection(), teams: collection(), teamMembers: collection(), plays: collection(), async close() {} }; }
|
||||
function testConfig() { return { mongodb: { uri: 'mongodb://172.16.0.7:27017', db: 'basket_utils' }, server: { host: '0.0.0.0', port: 3000, mode: 'development', allowedOrigins: ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://192.168.5.10:5173'], secureCookie: false }, bootstrap: { password: '' }, qa: { db: 'basket_utils_qa', password: '' } }; }
|
||||
async function seedUser(store, { id, email, displayName, isOperator = false, status = 'approved', password = 'correct horse battery staple' }) {
|
||||
const at = new Date(); await store.users.insertOne({ id, email, displayName, passwordHash: await hashPassword(password), status, isOperator, createdAt: at, updatedAt: at }); return { id, email, password };
|
||||
}
|
||||
async function login(app, email, password) { const response = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { email, password } }); const setCookie = response.headers['set-cookie']; const cookieHeader = Array.isArray(setCookie) ? setCookie[0] : setCookie; return { response, cookie: cookieHeader?.split(';')[0] }; }
|
||||
|
||||
describe('Fastify Mongo contract', () => {
|
||||
let app;
|
||||
const temporaryRoots = [];
|
||||
afterEach(async () => { await app?.close(); await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); });
|
||||
|
||||
it('rejects unauthenticated and pending access', async () => {
|
||||
const store = memoryStore(); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/teams' })).statusCode).toBe(401);
|
||||
await app.inject({ method: 'POST', url: '/api/auth/register', payload: { email: 'pending@example.com', password: 'password123', displayName: 'Pending' } });
|
||||
const pending = await login(app, 'pending@example.com', 'password123'); expect(pending.response.statusCode).toBe(403); expect(pending.response.json().error.code).toBe('approval_required');
|
||||
});
|
||||
|
||||
it('enforces team ownership and isolates multi-team play save/load', async () => {
|
||||
const store = memoryStore(); await seedUser(store, { id: 'u1', email: 'one@example.com', displayName: 'One' }); await seedUser(store, { id: 'u2', email: 'two@example.com', displayName: 'Two' });
|
||||
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const one = await login(app, 'one@example.com', 'correct horse battery staple'); expect(one.response.statusCode).toBe(200);
|
||||
const teamAResponse = await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: one.cookie }, payload: { name: 'Team A' } }); expect(teamAResponse.statusCode, teamAResponse.body).toBe(201); const teamA = teamAResponse.json().team; const teamBResponse = await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: one.cookie }, payload: { name: 'Team B' } }); expect(teamBResponse.statusCode, teamBResponse.body).toBe(201); const teamB = teamBResponse.json().team;
|
||||
const play = createInitialPlay('A play'); const saved = await app.inject({ method: 'PUT', url: `/api/teams/${teamA.id}/plays/${play.id}`, headers: { cookie: one.cookie }, payload: { play } }); expect(saved.statusCode).toBe(201);
|
||||
const loaded = await app.inject({ method: 'GET', url: `/api/teams/${teamA.id}/plays/${play.id}`, headers: { cookie: one.cookie } }); expect(loaded.statusCode).toBe(200); expect(loaded.json().play).toEqual(play);
|
||||
const other = await login(app, 'two@example.com', 'correct horse battery staple'); const forbidden = await app.inject({ method: 'PUT', url: `/api/teams/${teamA.id}/plays/${play.id}`, headers: { cookie: other.cookie }, payload: { play } }); expect(forbidden.statusCode).toBe(404);
|
||||
const teamBList = await app.inject({ method: 'GET', url: `/api/teams/${teamB.id}/plays`, headers: { cookie: one.cookie } }); expect(teamBList.json().plays).toEqual([]);
|
||||
});
|
||||
|
||||
it('invalidates the session on logout and returns malformed plays as 400', async () => {
|
||||
const store = memoryStore(); await seedUser(store, { id: 'u1', email: 'one@example.com', displayName: 'One' }); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const one = await login(app, 'one@example.com', 'correct horse battery staple');
|
||||
const teamResponse = await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: one.cookie }, payload: { name: 'Team' } }); expect(teamResponse.statusCode, teamResponse.body).toBe(201); const team = teamResponse.json().team; const bad = await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/play-1`, headers: { cookie: one.cookie }, payload: { play: { id: 'play-1', players: [] } } }); expect(bad.statusCode).toBe(400);
|
||||
expect((await app.inject({ method: 'POST', url: '/api/auth/logout', headers: { cookie: one.cookie }, payload: {} })).statusCode).toBe(200); expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: one.cookie } })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('supports operator approval, viewer read access, editor denial, and secure cookies', async () => {
|
||||
const store = memoryStore(); await seedUser(store, { id: 'operator', email: 'operator@example.com', displayName: 'Operator', isOperator: true }); await seedUser(store, { id: 'viewer', email: 'viewer@example.com', displayName: 'Viewer', status: 'pending' });
|
||||
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: true }); const operator = await login(app, 'operator@example.com', 'correct horse battery staple'); const operatorCookie = operator.response.headers['set-cookie']; expect((Array.isArray(operatorCookie) ? operatorCookie : [operatorCookie]).some((value) => value.includes('Secure'))).toBe(true);
|
||||
const pending = await app.inject({ method: 'GET', url: '/api/admin/users?status=pending', headers: { cookie: operator.cookie } }); expect(pending.json().users.map((user) => user.id)).toContain('viewer'); expect((await app.inject({ method: 'POST', url: '/api/admin/users/viewer/approve', headers: { cookie: operator.cookie }, payload: {} })).statusCode).toBe(200);
|
||||
const team = (await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: operator.cookie, origin: 'http://192.168.5.10:5173' }, payload: { name: 'Shared' } })).json().team; const play = createInitialPlay('Shared play'); expect((await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/${play.id}`, headers: { cookie: operator.cookie }, payload: { play } })).statusCode).toBe(201);
|
||||
await store.teamMembers.insertOne({ teamId: team.id, userId: 'viewer', role: 'viewer', createdAt: new Date() }); const viewer = await login(app, 'viewer@example.com', 'correct horse battery staple'); expect((await app.inject({ method: 'GET', url: `/api/teams/${team.id}/plays/${play.id}`, headers: { cookie: viewer.cookie } })).statusCode).toBe(200); expect((await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/${play.id}`, headers: { cookie: viewer.cookie }, payload: { play } })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'POST', url: '/api/admin/users/viewer/suspend', headers: { cookie: operator.cookie }, payload: {} })).statusCode).toBe(200); expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: viewer.cookie } })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does not expose config files through the static handler', async () => {
|
||||
const staticRoot = await mkdtemp(path.join(os.tmpdir(), 'basket-utils-static-')); temporaryRoots.push(staticRoot);
|
||||
await writeFile(path.join(staticRoot, 'index.html'), 'safe'); await writeFile(path.join(staticRoot, '.config.json'), '{"bootstrap":{"password":"static-secret"}}');
|
||||
app = await buildServer({ config: testConfig(), db: memoryStore(), staticRoot });
|
||||
for (const url of ['/.config.json', '/.config.json.sample', '/nested/.config.json.bak']) {
|
||||
const response = await app.inject({ method: 'GET', url }); expect(response.statusCode).toBe(404); expect(response.body).not.toContain('static-secret');
|
||||
}
|
||||
});
|
||||
});
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createMongoStore } from './db.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { hashPassword, validEmail } from './security.js';
|
||||
|
||||
function argument(name) { const prefix = `--${name}=`; const item = process.argv.find((value) => value.startsWith(prefix)); return item ? item.slice(prefix.length) : ''; }
|
||||
const config = await loadConfig();
|
||||
const email = argument('email').trim().toLowerCase(); const password = argument('password') || config.bootstrap.password || ''; const displayName = (argument('display-name') || email.split('@')[0]).trim(); const promoteExisting = process.argv.includes('--promote-existing');
|
||||
if (!validEmail(email) || !displayName) throw new Error('사용법: npm run bootstrap-admin -- --email=admin@example.com [--password=긴비밀번호] [--display-name=이름]');
|
||||
const store = await createMongoStore({ config, uri: config.mongodb.uri, dbName: config.mongodb.db });
|
||||
try {
|
||||
const existing = await store.users.findOne({ email }); const at = new Date();
|
||||
if (existing) {
|
||||
if (!promoteExisting) throw new Error('사용자가 이미 존재합니다. 비밀번호를 바꾸지 않고 운영자로 승격하려면 --promote-existing를 사용하세요');
|
||||
await store.users.updateOne({ id: existing.id }, { $set: { status: 'approved', isOperator: true, updatedAt: at } });
|
||||
} else {
|
||||
if (password.length < 8) throw new Error('새 운영자 계정은 --password 또는 .config.json의 bootstrap.password에 8자 이상 비밀번호를 지정해야 합니다');
|
||||
const at = new Date();
|
||||
await store.users.insertOne({ id: randomUUID(), email, displayName, passwordHash: await hashPassword(password), status: 'approved', isOperator: true, createdAt: at, updatedAt: at });
|
||||
}
|
||||
} finally { await store.close(); }
|
||||
console.log(`Bootstrap operator ready for ${email}`);
|
||||
@@ -0,0 +1,96 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(here, '..');
|
||||
export const CONFIG_FILENAME = '.config.json';
|
||||
|
||||
function invalid(filePath, detail) {
|
||||
throw new Error(`Invalid configuration in ${filePath}: ${detail}`);
|
||||
}
|
||||
|
||||
function requireObject(value, field, filePath) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(filePath, `${field} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireString(value, field, filePath) {
|
||||
if (typeof value !== 'string' || !value.trim()) invalid(filePath, `${field} must be a non-empty string`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requirePassword(value, field, filePath) {
|
||||
if (typeof value !== 'string') invalid(filePath, `${field} must be a string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function isDevelopmentOrigin(origin) {
|
||||
let url;
|
||||
try { url = new URL(origin); } catch { return false; }
|
||||
if (url.protocol !== 'http:') return false;
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') return true;
|
||||
const octets = hostname.split('.').map((part) => Number(part));
|
||||
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
||||
return octets[0] === 10
|
||||
|| (octets[0] === 192 && octets[1] === 168)
|
||||
|| (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31);
|
||||
}
|
||||
|
||||
function validateConfig(value, filePath) {
|
||||
const root = requireObject(value, 'root', filePath);
|
||||
const mongodb = requireObject(root.mongodb, 'mongodb', filePath);
|
||||
const server = requireObject(root.server, 'server', filePath);
|
||||
const bootstrap = requireObject(root.bootstrap, 'bootstrap', filePath);
|
||||
const qa = requireObject(root.qa, 'qa', filePath);
|
||||
const uri = requireString(mongodb.uri, 'mongodb.uri', filePath);
|
||||
const db = requireString(mongodb.db, 'mongodb.db', filePath);
|
||||
const host = requireString(server.host, 'server.host', filePath);
|
||||
if (!Number.isInteger(server.port) || server.port < 1 || server.port > 65_535) invalid(filePath, 'server.port must be an integer between 1 and 65535');
|
||||
if (server.mode !== 'development' && server.mode !== 'production') invalid(filePath, 'server.mode must be development or production');
|
||||
if (!Array.isArray(server.allowedOrigins) || server.allowedOrigins.some((origin) => typeof origin !== 'string' || !origin.trim())) invalid(filePath, 'server.allowedOrigins must be an array of non-empty strings');
|
||||
const allowedOrigins = server.allowedOrigins.map((origin) => origin.trim());
|
||||
if (typeof server.secureCookie !== 'boolean') invalid(filePath, 'server.secureCookie must be a boolean');
|
||||
const bootstrapPassword = requirePassword(bootstrap.password, 'bootstrap.password', filePath);
|
||||
const qaDb = requireString(qa.db, 'qa.db', filePath);
|
||||
const qaPassword = requirePassword(qa.password, 'qa.password', filePath);
|
||||
const production = server.mode === 'production';
|
||||
return {
|
||||
mongodb: { uri, db },
|
||||
server: {
|
||||
host,
|
||||
port: server.port,
|
||||
mode: server.mode,
|
||||
allowedOrigins: production ? allowedOrigins.filter((origin) => !isDevelopmentOrigin(origin)) : allowedOrigins,
|
||||
secureCookie: production || server.secureCookie,
|
||||
},
|
||||
bootstrap: { password: bootstrapPassword },
|
||||
qa: { db: qaDb, password: qaPassword },
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveConfigPath(configPath = CONFIG_FILENAME, root = projectRoot) {
|
||||
return path.isAbsolute(configPath) ? configPath : path.resolve(root, configPath);
|
||||
}
|
||||
|
||||
export async function loadConfig(options = {}) {
|
||||
const requestedPath = typeof options === 'string' ? options : options.path || options.filePath || CONFIG_FILENAME;
|
||||
const filePath = resolveConfigPath(requestedPath, typeof options === 'object' ? options.projectRoot || projectRoot : projectRoot);
|
||||
let source;
|
||||
try {
|
||||
source = await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') throw new Error(`Configuration file not found at ${filePath}. Copy .config.json.sample to .config.json and set the required values.`);
|
||||
throw new Error(`Unable to read configuration file ${filePath}: ${error?.code || 'unknown error'}`);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(source.charCodeAt(0) === 0xFEFF ? source.slice(1) : source);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON in configuration file ${filePath}; check its JSON syntax.`);
|
||||
}
|
||||
return validateConfig(parsed, filePath);
|
||||
}
|
||||
|
||||
export { isDevelopmentOrigin, projectRoot };
|
||||
@@ -0,0 +1,69 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { loadConfig } from './config.js';
|
||||
|
||||
const temporaryRoots = [];
|
||||
|
||||
function validConfig(overrides = {}) {
|
||||
return {
|
||||
mongodb: { uri: 'mongodb://172.16.0.7:27017', db: 'basket_utils' },
|
||||
server: { host: '0.0.0.0', port: 3000, mode: 'development', allowedOrigins: ['http://localhost:5173'], secureCookie: false },
|
||||
bootstrap: { password: '' },
|
||||
qa: { db: 'basket_utils_qa', password: '' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeConfig(source) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'basket-utils-config-')); temporaryRoots.push(root);
|
||||
await writeFile(path.join(root, '.config.json'), source); return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('JSON configuration', () => {
|
||||
it('loads relative to the project root and accepts a UTF-8 BOM', async () => {
|
||||
const root = await writeConfig(`\uFEFF${JSON.stringify(validConfig())}`);
|
||||
const config = await loadConfig({ projectRoot: root });
|
||||
expect(config.mongodb.db).toBe('basket_utils');
|
||||
expect(config.server.port).toBe(3000);
|
||||
});
|
||||
|
||||
it('reports missing and malformed files without exposing source values', async () => {
|
||||
const missingRoot = await mkdtemp(path.join(os.tmpdir(), 'basket-utils-config-')); temporaryRoots.push(missingRoot);
|
||||
await expect(loadConfig({ projectRoot: missingRoot })).rejects.toThrow(/not found.*\.config\.json\.sample/i);
|
||||
const secret = 'config-only-secret'; const malformedRoot = await writeConfig(`{"bootstrap":{"password":"${secret}"},`);
|
||||
await expect(loadConfig({ projectRoot: malformedRoot })).rejects.toThrow(/Invalid JSON.*check its JSON syntax/i);
|
||||
await expect(loadConfig({ projectRoot: malformedRoot })).rejects.not.toThrow(secret);
|
||||
});
|
||||
|
||||
it('rejects invalid port types with a field-specific message', async () => {
|
||||
const root = await writeConfig(JSON.stringify(validConfig({ server: { ...validConfig().server, port: '3000' } })));
|
||||
await expect(loadConfig({ projectRoot: root })).rejects.toThrow(/server\.port.*integer/i);
|
||||
});
|
||||
|
||||
it('forces secure cookies and removes private HTTP origins in production', async () => {
|
||||
const root = await writeConfig(JSON.stringify(validConfig({ server: {
|
||||
...validConfig().server,
|
||||
mode: 'production',
|
||||
secureCookie: false,
|
||||
allowedOrigins: ['http://localhost:5173', 'http://192.168.5.10:5173', 'https://coach.example.com'],
|
||||
} })));
|
||||
const config = await loadConfig({ projectRoot: root });
|
||||
expect(config.server.secureCookie).toBe(true);
|
||||
expect(config.server.allowedOrigins).toEqual(['https://coach.example.com']);
|
||||
});
|
||||
|
||||
it('keeps the sample schema and empty sample passwords', async () => {
|
||||
const sample = JSON.parse(await readFile(path.resolve(import.meta.dirname, '..', '.config.json.sample'), 'utf8'));
|
||||
expect(sample.mongodb).toEqual({ uri: 'mongodb://172.16.0.7:27017', db: 'basket_utils' });
|
||||
expect(sample.server).toMatchObject({ host: '0.0.0.0', port: 3000, mode: 'development', secureCookie: false });
|
||||
expect(sample.server.allowedOrigins).toEqual(['http://localhost:5173', 'http://127.0.0.1:5173', 'http://192.168.5.10:5173']);
|
||||
expect(sample.bootstrap.password).toBe('');
|
||||
expect(sample.qa).toEqual({ db: 'basket_utils_qa', password: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { loadConfig } from './config.js';
|
||||
|
||||
export const DEFAULT_MONGODB_URI = 'mongodb://172.16.0.7:27017';
|
||||
export const DEFAULT_MONGODB_DB = 'basket_utils';
|
||||
|
||||
export async function createMongoStore(options = {}) {
|
||||
const config = options.config || (options.uri && options.dbName ? null : await loadConfig());
|
||||
const uri = options.uri || config?.mongodb?.uri || DEFAULT_MONGODB_URI;
|
||||
const dbName = options.dbName || config?.mongodb?.db || DEFAULT_MONGODB_DB;
|
||||
const client = options.client || new MongoClient(uri, { maxPoolSize: 10, serverSelectionTimeoutMS: options.serverSelectionTimeoutMS || 5000 });
|
||||
try { await client.connect(); } catch (error) { if (!options.client) await client.close().catch(() => {}); throw error; }
|
||||
const database = client.db(dbName);
|
||||
const users = database.collection('users');
|
||||
const sessions = database.collection('sessions');
|
||||
const teams = database.collection('teams');
|
||||
const teamMembers = database.collection('team_members');
|
||||
const plays = database.collection('plays');
|
||||
try {
|
||||
await Promise.all([
|
||||
users.createIndex({ email: 1 }, { unique: true }),
|
||||
sessions.createIndex({ tokenHash: 1 }, { unique: true }),
|
||||
sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }),
|
||||
teamMembers.createIndex({ teamId: 1, userId: 1 }, { unique: true }),
|
||||
teamMembers.createIndex({ userId: 1 }),
|
||||
plays.createIndex({ teamId: 1, id: 1 }, { unique: true }),
|
||||
plays.createIndex({ teamId: 1, updatedAt: -1 }),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!options.client) await client.close().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
client, database, users, sessions, teams, teamMembers, plays,
|
||||
async close() { if (!options.client) await client.close(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { buildServer } from './app.js';
|
||||
import { loadConfig } from './config.js';
|
||||
|
||||
const config = await loadConfig();
|
||||
const { port, host } = config.server;
|
||||
const app = await buildServer({ config });
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
console.log(`court.lab server listening on http://${host}:${port}`);
|
||||
const shutdown = async () => { try { await app.close(); } finally { process.exit(0); } };
|
||||
process.once('SIGTERM', shutdown); process.once('SIGINT', shutdown);
|
||||
} catch (error) {
|
||||
app.log.error(error); await app.close().catch(() => {});
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const scryptAsync = promisify(scrypt);
|
||||
const N = 16_384;
|
||||
const R = 8;
|
||||
const P = 1;
|
||||
|
||||
export const SESSION_COOKIE = 'court_session';
|
||||
export const SESSION_DAYS = 7;
|
||||
|
||||
export function hashToken(token) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function hashPassword(password) {
|
||||
const salt = randomBytes(16);
|
||||
const derived = await scryptAsync(password, salt, 64, { N, r: R, p: P, maxmem: 32 * 1024 * 1024 });
|
||||
return `scrypt$${N},${R},${P}$${salt.toString('base64url')}$${Buffer.from(derived).toString('base64url')}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password, encoded) {
|
||||
try {
|
||||
const [algorithm, params, saltText, hashText] = String(encoded).split('$');
|
||||
if (algorithm !== 'scrypt' || !params || !saltText || !hashText) return false;
|
||||
const [nText, rText, pText] = params.split(',');
|
||||
const n = Number(nText); const r = Number(rText); const p = Number(pText);
|
||||
if (n !== N || r !== R || p !== P) return false;
|
||||
const expected = Buffer.from(hashText, 'base64url');
|
||||
const derived = Buffer.from(await scryptAsync(password, Buffer.from(saltText, 'base64url'), expected.length, { N: n, r, p, maxmem: 32 * 1024 * 1024 }));
|
||||
if (derived.length !== expected.length) return false;
|
||||
return timingSafeEqual(derived, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function newSessionToken() {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
export function sessionExpiry(now = Date.now()) {
|
||||
return new Date(now + SESSION_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
export function validEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createMongoStore } from './db.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { hashPassword, validEmail } from './security.js';
|
||||
|
||||
function argument(name) { const prefix = `--${name}=`; const item = process.argv.find((value) => value.startsWith(prefix)); return item ? item.slice(prefix.length) : ''; }
|
||||
const config = await loadConfig();
|
||||
const email = argument('email').trim().toLowerCase(); const password = argument('password') || config.qa.password || ''; const displayName = (argument('display-name') || 'QA Operator').trim(); const teamName = (argument('team-name') || 'QA Team').trim();
|
||||
const dbName = argument('db') || config.qa.db; const uri = config.mongodb.uri;
|
||||
if (!/^basket_utils_qa(?:_[A-Za-z0-9-]+)?$/.test(dbName)) throw new Error('QA seed는 basket_utils_qa 또는 basket_utils_qa_* 데이터베이스만 사용할 수 있습니다');
|
||||
if (!validEmail(email) || password.length < 8 || !displayName || !teamName) throw new Error('사용법: npm run seed-qa -- --email=qa@example.com --password=긴비밀번호 [--db=basket_utils_qa]');
|
||||
const store = await createMongoStore({ config, uri, dbName });
|
||||
try {
|
||||
const at = new Date(); let user = await store.users.findOne({ email });
|
||||
if (user) { await store.users.updateOne({ id: user.id }, { $set: { displayName, passwordHash: await hashPassword(password), status: 'approved', isOperator: true, updatedAt: at } }); }
|
||||
else { user = { id: randomUUID(), email, displayName }; await store.users.insertOne({ ...user, passwordHash: await hashPassword(password), status: 'approved', isOperator: true, createdAt: at, updatedAt: at }); }
|
||||
const team = { id: randomUUID(), name: teamName, ownerUserId: user.id, createdAt: at, updatedAt: at }; await store.teams.insertOne(team); await store.teamMembers.insertOne({ teamId: team.id, userId: user.id, role: 'owner', createdAt: at });
|
||||
console.log(JSON.stringify({ dbName, email, teamId: team.id, teamName }, null, 2));
|
||||
} finally { await store.close(); }
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(status, code, message) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; }
|
||||
}
|
||||
|
||||
export async function apiRequest(url, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (options.body !== undefined && !headers.has('content-type')) headers.set('content-type', 'application/json');
|
||||
const response = await fetch(url, { ...options, credentials: 'same-origin', headers });
|
||||
let payload = null;
|
||||
try { payload = await response.json(); } catch { /* empty response */ }
|
||||
if (!response.ok) {
|
||||
const error = payload?.error || {};
|
||||
throw new ApiError(response.status, error.code || 'request_failed', error.message || `요청에 실패했습니다 (${response.status})`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createServerPlayRepository(teamId) {
|
||||
const root = `/api/teams/${encodeURIComponent(teamId)}/plays`;
|
||||
return {
|
||||
async save(play) {
|
||||
const payload = await apiRequest(`${root}/${encodeURIComponent(play.id)}`, { method: 'PUT', body: JSON.stringify({ play }) });
|
||||
return { id: payload.id, name: payload.name, updatedAt: payload.updatedAt, play: structuredClone(payload.play || play) };
|
||||
},
|
||||
async list() {
|
||||
const payload = await apiRequest(root); return payload.plays || [];
|
||||
},
|
||||
async get(id) {
|
||||
try { const payload = await apiRequest(`${root}/${encodeURIComponent(id)}`); return payload?.play || null; }
|
||||
catch (error) { if (error.code === 'play_not_found') return null; throw error; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentUser() { return apiRequest('/api/auth/me'); }
|
||||
export async function loginAccount(email, password) { return apiRequest('/api/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }); }
|
||||
export async function registerAccount(email, password, displayName) { return apiRequest('/api/auth/register', { method: 'POST', body: JSON.stringify({ email, password, displayName }) }); }
|
||||
export async function logoutAccount() { return apiRequest('/api/auth/logout', { method: 'POST', body: '{}' }); }
|
||||
export async function listTeams() { return apiRequest('/api/teams'); }
|
||||
export async function createTeam(name) { return apiRequest('/api/teams', { method: 'POST', body: JSON.stringify({ name }) }); }
|
||||
export async function listPendingUsers() { return apiRequest('/api/admin/users?status=pending'); }
|
||||
export async function approveUser(userId) { return apiRequest(`/api/admin/users/${encodeURIComponent(userId)}/approve`, { method: 'POST', body: '{}' }); }
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export const POV_EYE_HEIGHT = 1.75;
|
||||
export const HORIZONTAL_FOV = 110;
|
||||
export const POV_VERTICAL_FOV_MAX = 100;
|
||||
export const POV_VERTICAL_OFFSET = 0.08;
|
||||
export function verticalFov(horizontalFov, aspect) { return THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(horizontalFov) / 2) / aspect)); }
|
||||
export function povFov(aspect) { return Math.min(POV_VERTICAL_FOV_MAX, verticalFov(HORIZONTAL_FOV, Math.max(0.1, aspect))); }
|
||||
|
||||
function applyPovProjection(camera, aspect) {
|
||||
camera.aspect = aspect;
|
||||
camera.fov = povFov(aspect);
|
||||
// Shift the rendered sub-frustum down slightly to reduce empty sky without
|
||||
// changing the action target used by updatePovCamera.
|
||||
camera.setViewOffset(aspect, 1, 0, POV_VERTICAL_OFFSET, aspect, 1);
|
||||
}
|
||||
|
||||
export function createCameras(aspect = 1) {
|
||||
const tactical = new THREE.PerspectiveCamera(42, aspect, 0.1, 100);
|
||||
tactical.position.set(0, 20, 8);
|
||||
tactical.lookAt(0, 0, 7);
|
||||
const pov = new THREE.PerspectiveCamera(povFov(aspect), aspect, 0.05, 100);
|
||||
pov.position.set(0, POV_EYE_HEIGHT, 0);
|
||||
applyPovProjection(pov, aspect);
|
||||
return { tactical, pov };
|
||||
}
|
||||
|
||||
export function resizeCameras(cameras, aspect) {
|
||||
cameras.tactical.aspect = aspect;
|
||||
cameras.tactical.position.y = Math.max(22, 22 / Math.max(0.1, aspect));
|
||||
cameras.tactical.far = Math.max(100, cameras.tactical.position.y + 40);
|
||||
cameras.tactical.lookAt(0, 0, 7);
|
||||
cameras.tactical.updateProjectionMatrix();
|
||||
applyPovProjection(cameras.pov, aspect);
|
||||
}
|
||||
|
||||
export function updatePovCamera(camera, location, target, facing = 0, options = {}) {
|
||||
const previous = camera.quaternion.clone();
|
||||
camera.position.set(location.x, POV_EYE_HEIGHT, location.z);
|
||||
const hasTarget = target && Math.hypot(target.x - location.x, target.z - location.z) > 1e-3;
|
||||
const look = hasTarget ? target : { x: location.x + Math.sin(facing), z: location.z + Math.cos(facing) };
|
||||
camera.lookAt(look.x, hasTarget ? (target.y ?? 1.5) : POV_EYE_HEIGHT, look.z);
|
||||
if (options.smooth) {
|
||||
const desired = camera.quaternion.clone();
|
||||
camera.quaternion.copy(previous).rotateTowards(desired, Math.PI * Math.min(0.1, Math.max(0, options.delta || 0)));
|
||||
}
|
||||
if (options.yaw || options.pitch) {
|
||||
camera.rotateY(options.yaw || 0); camera.rotateX(options.pitch || 0);
|
||||
}
|
||||
}
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
export const COURT = { width: 15, length: 14, boundaryX: 7.5, minZ: 0, maxZ: 14, rim: { x: 0, z: 13.25 } };
|
||||
export const GRID_SIZE = 0.5;
|
||||
export const PLAYER_SPEED = 4.5;
|
||||
export const DEFENSE_REACTION_DELAY = 0.20;
|
||||
export const PASS_DURATION = 0.35;
|
||||
export const SCREEN_HOLD = 0.60;
|
||||
export const SHOOT_DURATION = 0.80;
|
||||
export const RIM_HEIGHT = 3.05;
|
||||
export const BALL_HOLD_HEIGHT = 1.15;
|
||||
export const DEFENSE_TYPES = ['man-to-man', '2-3', '3-2'];
|
||||
|
||||
const OFFENSE_START = [
|
||||
{ x: 0, z: 2.1 }, { x: -5.8, z: 1.2 }, { x: 5.8, z: 1.2 },
|
||||
{ x: -2.1, z: 7.2 }, { x: 2.1, z: 7.2 },
|
||||
];
|
||||
const DEFENSE_START = {
|
||||
'man-to-man': [{ x: 0, z: 5.2 }, { x: -4.2, z: 3.9 }, { x: 4.2, z: 3.9 }, { x: -1.8, z: 9 }, { x: 1.8, z: 9 }],
|
||||
'2-3': [{ x: -3.2, z: 5 }, { x: 3.2, z: 5 }, { x: -4.8, z: 9 }, { x: 0, z: 10.1 }, { x: 4.8, z: 9 }],
|
||||
'3-2': [{ x: -4.7, z: 6.3 }, { x: 0, z: 6.5 }, { x: 4.7, z: 6.3 }, { x: -2.7, z: 10.3 }, { x: 2.7, z: 10.3 }],
|
||||
};
|
||||
|
||||
export const copyLocation = (location) => ({ x: Number(location?.x) || 0, z: Number(location?.z) || 0 });
|
||||
export const clampLocation = (location) => ({
|
||||
x: Math.max(-COURT.boundaryX, Math.min(COURT.boundaryX, Number(location?.x) || 0)),
|
||||
z: Math.max(COURT.minZ, Math.min(COURT.maxZ, Number(location?.z) || 0)),
|
||||
});
|
||||
export const snapLocation = (location, grid = GRID_SIZE) => clampLocation({ x: Math.round((Number(location?.x) || 0) / grid) * grid, z: Math.round((Number(location?.z) || 0) / grid) * grid });
|
||||
export const distance = (a, b) => Math.hypot((b?.x || 0) - (a?.x || 0), (b?.z || 0) - (a?.z || 0));
|
||||
export const locationEqual = (a, b) => Boolean(a && b && a.x === b.x && a.z === b.z);
|
||||
|
||||
export function makePlayer(id, team, number, location) {
|
||||
return { id, team, number, location: clampLocation(location) };
|
||||
}
|
||||
|
||||
export function createInitialPlay(name = '새 전술', defenseType = 'man-to-man') {
|
||||
const defense = DEFENSE_TYPES.includes(defenseType) ? defenseType : 'man-to-man';
|
||||
const players = [
|
||||
...OFFENSE_START.map((p, i) => makePlayer(`offense-${i + 1}`, 'offense', i + 1, p)),
|
||||
...DEFENSE_START[defense].map((p, i) => makePlayer(`defense-${i + 1}`, 'defense', i + 1, p)),
|
||||
];
|
||||
return {
|
||||
id: `play-${Date.now().toString(36)}`,
|
||||
name: String(name || '새 전술').trim() || '새 전술',
|
||||
defenseType: defense,
|
||||
players,
|
||||
sequences: [createSequence('Sequence 1', players)],
|
||||
};
|
||||
}
|
||||
|
||||
export function createSequence(name, players, previous = null) {
|
||||
const tracks = players.map((player) => {
|
||||
const previousTrack = previous?.tracks?.find((track) => track.playerId === player.id);
|
||||
return { playerId: player.id, startLocation: copyLocation(previousTrack?.actions?.at(-1)?.location || previousTrack?.startLocation || player.location), actions: [] };
|
||||
});
|
||||
return { id: `sequence-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, name, ballOwnerId: previous ? finalBallOwner(previous) : players.find((player) => player.team === 'offense')?.id || null, ballOwnerInherited: Boolean(previous), tracks };
|
||||
}
|
||||
|
||||
export function finalTrackLocation(track) { return copyLocation(track?.actions?.at(-1)?.location || track?.startLocation); }
|
||||
export function finalBallOwner(sequence) { return sequence?.tracks?.find((track) => track.actions.some((action) => action.type === 'pass'))?.actions.findLast((action) => action.type === 'pass')?.targetPlayerId || sequence?.ballOwnerId || null; }
|
||||
export function hasShoot(play) { return Boolean(play?.sequences?.some((sequence) => sequence.tracks?.some((track) => track.actions?.some((action) => action.type === 'shoot')))); }
|
||||
function shootActions(play) { return (play?.sequences || []).flatMap((sequence, sequenceIndex) => (sequence.tracks || []).flatMap((track) => (track.actions || []).filter((action) => action.type === 'shoot').map((action) => ({ action, sequence, sequenceIndex, track })))); }
|
||||
export function shootInvariantsValid(play) {
|
||||
const shots = shootActions(play); if (!shots.length) return true; if (shots.length !== 1) return false;
|
||||
const { action, sequence, sequenceIndex, track } = shots[0]; const ownerId = finalBallOwner(sequence); const ownerTrack = sequence.tracks.find((candidate) => candidate.playerId === ownerId);
|
||||
const previousLocation = track.actions.length > 1 ? track.actions.at(-2).location : track.startLocation;
|
||||
return sequenceIndex === play.sequences.length - 1 && track.playerId === ownerId && track.actions.at(-1) === action && play.players.find((player) => player.id === track.playerId)?.team === 'offense' && action.targetPlayerId === null && action.facing === null && action.lookAt?.type === 'rim' && ownerTrack === track && locationEqual(action.location, previousLocation);
|
||||
}
|
||||
export function isDefenseTrack(track, players = []) { return players.find((player) => player.id === track?.playerId)?.team === 'defense' || track?.playerId?.startsWith('defense-'); }
|
||||
|
||||
export function hasScreen(sequence, playerId) { return Boolean(sequence?.tracks?.find((track) => track.playerId === playerId)?.actions.some((action) => action.type === 'screen')); }
|
||||
function relinkFollowingOwners(play, firstSequenceIndex) {
|
||||
const first = play.sequences[firstSequenceIndex]; let owner = finalBallOwner(first); if (hasScreen(first, owner)) return false;
|
||||
const inherited = [];
|
||||
for (let index = firstSequenceIndex + 1; index < play.sequences.length; index += 1) {
|
||||
const sequence = play.sequences[index];
|
||||
if (sequence.ballOwnerInherited !== false) {
|
||||
if (hasScreen(sequence, owner)) return false;
|
||||
inherited.push({ sequence, owner });
|
||||
owner = finalBallOwner({ ...sequence, ballOwnerId: owner });
|
||||
} else owner = finalBallOwner(sequence);
|
||||
if (hasScreen(sequence, owner)) return false;
|
||||
}
|
||||
for (const assignment of inherited) { assignment.sequence.ballOwnerId = assignment.owner; assignment.sequence.ballOwnerInherited = true; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function relinkFollowingStarts(play, playerId, firstSequenceIndex) {
|
||||
for (let index = Math.max(1, firstSequenceIndex); index < play.sequences.length; index += 1) {
|
||||
const previousTrack = play.sequences[index - 1].tracks.find((candidate) => candidate.playerId === playerId);
|
||||
const track = play.sequences[index].tracks.find((candidate) => candidate.playerId === playerId);
|
||||
if (track) track.startLocation = copyLocation(previousTrack?.actions.at(-1)?.location || previousTrack?.startLocation || play.players.find((player) => player.id === playerId)?.location);
|
||||
}
|
||||
}
|
||||
|
||||
export function trackActionSnapshot(play, sequenceId, playerId) {
|
||||
const sequence = play?.sequences?.find((candidate) => candidate.id === sequenceId); const track = sequence?.tracks?.find((candidate) => candidate.playerId === playerId);
|
||||
return track ? { sequenceId, playerId, actions: structuredClone(track.actions) } : null;
|
||||
}
|
||||
|
||||
function sequenceActionsValid(play, sequence) {
|
||||
if (!sequence) return false;
|
||||
let passCount = 0;
|
||||
for (const track of sequence.tracks || []) {
|
||||
const player = play.players.find((candidate) => candidate.id === track.playerId);
|
||||
for (const action of track.actions || []) {
|
||||
if (action.type === 'pass') {
|
||||
passCount += 1; const target = play.players.find((candidate) => candidate.id === action.targetPlayerId); const targetTrack = sequence.tracks.find((candidate) => candidate.playerId === action.targetPlayerId);
|
||||
if (player?.team !== 'offense' || sequence.ballOwnerId !== track.playerId || !target || target.team !== 'offense' || target.id === track.playerId || targetTrack?.actions.some((candidate) => candidate.type === 'screen')) return false;
|
||||
}
|
||||
if (action.type === 'screen') {
|
||||
const target = play.players.find((candidate) => candidate.id === action.targetPlayerId); if (player?.team !== 'offense' || !target || target.team !== 'defense' || !sequence.tracks.some((candidate) => candidate.playerId === target.id)) return false;
|
||||
}
|
||||
if (action.type === 'shoot' && (player?.team !== 'offense' || action.targetPlayerId !== null || action.facing !== null || action.lookAt?.type !== 'rim')) return false;
|
||||
}
|
||||
}
|
||||
return passCount <= 1 && !hasScreen(sequence, finalBallOwner(sequence)) && shootInvariantsValid(play);
|
||||
}
|
||||
|
||||
export function restoreTrackActionSnapshot(play, snapshot) {
|
||||
if (hasShoot(play)) {
|
||||
const currentShoot = shootActions(play)[0];
|
||||
if (!currentShoot || snapshot?.sequenceId !== currentShoot.sequence.id || snapshot?.playerId !== currentShoot.track.playerId || snapshot.actions?.some((action) => action.type === 'shoot')) return play;
|
||||
}
|
||||
const next = structuredClone(play); const sequenceIndex = next.sequences.findIndex((sequence) => sequence.id === snapshot?.sequenceId); const sequence = next.sequences[sequenceIndex]; const track = sequence?.tracks?.find((candidate) => candidate.playerId === snapshot?.playerId);
|
||||
if (!sequence || !track || !snapshot) return play;
|
||||
track.actions = structuredClone(snapshot.actions || []);
|
||||
if (!sequenceActionsValid(next, sequence)) return play;
|
||||
relinkFollowingStarts(next, snapshot.playerId, sequenceIndex + 1);
|
||||
if (!relinkFollowingOwners(next, Math.max(0, sequenceIndex)) || !shootInvariantsValid(next)) return play;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function setPlayerStartLocation(play, playerId, location) {
|
||||
if (hasShoot(play)) return play;
|
||||
const next = structuredClone(play);
|
||||
const player = next.players.find((candidate) => candidate.id === playerId);
|
||||
if (!player) return next;
|
||||
player.location = snapLocation(location);
|
||||
const firstTrack = next.sequences[0]?.tracks.find((candidate) => candidate.playerId === playerId);
|
||||
if (firstTrack) firstTrack.startLocation = copyLocation(player.location);
|
||||
relinkFollowingStarts(next, playerId, 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function setSequenceBallOwner(play, sequenceIndex, playerId) {
|
||||
const player = play.players.find((candidate) => candidate.id === playerId && candidate.team === 'offense');
|
||||
const sequence = play.sequences[sequenceIndex];
|
||||
const playerTrack = sequence?.tracks.find((track) => track.playerId === playerId);
|
||||
if (!player || !sequence || hasShoot(play) || playerTrack?.actions.some((action) => action.type === 'screen') || (sequence.tracks.some((track) => track.actions.some((action) => action.type === 'pass')) && playerId !== sequence.ballOwnerId)) return play;
|
||||
const next = structuredClone(play);
|
||||
next.sequences[sequenceIndex].ballOwnerId = playerId;
|
||||
next.sequences[sequenceIndex].ballOwnerInherited = false;
|
||||
if (!relinkFollowingOwners(next, sequenceIndex)) return play;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function addAction(play, sequenceIndex, playerId, location, options = {}) {
|
||||
if (options.type === 'pass') return addPassAction(play, sequenceIndex, playerId, options.targetPlayerId);
|
||||
if (options.type === 'screen') return addScreenAction(play, sequenceIndex, playerId, options.targetPlayerId);
|
||||
if (options.type === 'shoot') return addShootAction(play, sequenceIndex, playerId);
|
||||
if (hasShoot(play)) return play;
|
||||
const sequence = play.sequences[sequenceIndex];
|
||||
const player = play.players.find((candidate) => candidate.id === playerId);
|
||||
const track = sequence?.tracks.find((candidate) => candidate.playerId === playerId);
|
||||
if (!track) return play;
|
||||
const next = structuredClone(play);
|
||||
const nextSequence = next.sequences[sequenceIndex];
|
||||
const nextTrack = nextSequence.tracks.find((candidate) => candidate.playerId === playerId);
|
||||
const safeLocation = snapLocation(location);
|
||||
const safeLookAt = normalizeLookAt(options.lookAt, playerId, next.players);
|
||||
nextTrack.actions.push({
|
||||
id: `action-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
location: safeLocation,
|
||||
facing: options.facing ?? null,
|
||||
lookAt: safeLookAt,
|
||||
type: options.type || 'move',
|
||||
targetPlayerId: options.targetPlayerId ?? null,
|
||||
});
|
||||
relinkFollowingStarts(next, playerId, sequenceIndex + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function addPassAction(play, sequenceIndex, passerId, targetPlayerId) {
|
||||
if (hasShoot(play)) return play;
|
||||
const next = structuredClone(play);
|
||||
const sequence = next.sequences[sequenceIndex];
|
||||
const passer = next.players.find((player) => player.id === passerId);
|
||||
const target = next.players.find((player) => player.id === targetPlayerId);
|
||||
const targetTrack = sequence?.tracks.find((track) => track.playerId === targetPlayerId);
|
||||
if (!sequence || !passer || passer.team !== 'offense' || sequence.ballOwnerId !== passerId || !target || target.team !== 'offense' || target.id === passerId || targetTrack?.actions.some((action) => action.type === 'screen') || sequence.tracks.some((track) => track.actions.some((action) => action.type === 'pass'))) return play;
|
||||
const track = sequence.tracks.find((candidate) => candidate.playerId === passerId);
|
||||
track.actions.push({ id: `action-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, location: finalTrackLocation(track), facing: null, lookAt: null, type: 'pass', targetPlayerId });
|
||||
if (!relinkFollowingOwners(next, sequenceIndex)) return play;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function addScreenAction(play, sequenceIndex, screenerId, defenderId) {
|
||||
if (hasShoot(play)) return play;
|
||||
const sequence = play.sequences[sequenceIndex]; const screener = play.players.find((player) => player.id === screenerId); const defender = play.players.find((player) => player.id === defenderId);
|
||||
const screenerTrack = sequence?.tracks.find((track) => track.playerId === screenerId); const defenderTrack = sequence?.tracks.find((track) => track.playerId === defenderId); const ownerId = sequence ? finalBallOwner(sequence) : null;
|
||||
if (!sequence || !screener || screener.team !== 'offense' || screenerId === ownerId || !defender || defender.team !== 'defense' || !screenerTrack || !defenderTrack) return play;
|
||||
const screenerFinal = finalTrackLocation(screenerTrack); const defenderFinal = finalTrackLocation(defenderTrack); const length = distance(defenderFinal, screenerFinal);
|
||||
if (length < 0.76) return play;
|
||||
const location = length <= 0.85 ? screenerFinal : { x: defenderFinal.x + (screenerFinal.x - defenderFinal.x) * 0.85 / length, z: defenderFinal.z + (screenerFinal.z - defenderFinal.z) * 0.85 / length };
|
||||
const next = structuredClone(play); const track = next.sequences[sequenceIndex].tracks.find((candidate) => candidate.playerId === screenerId); const nextDefenderFinal = finalTrackLocation(next.sequences[sequenceIndex].tracks.find((candidate) => candidate.playerId === defenderId));
|
||||
track.actions.push({ id: `action-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, location: { ...location }, facing: Math.atan2(nextDefenderFinal.x - location.x, nextDefenderFinal.z - location.z), lookAt: { type: 'player', targetId: defenderId }, type: 'screen', targetPlayerId: defenderId });
|
||||
relinkFollowingStarts(next, screenerId, sequenceIndex + 1); return next;
|
||||
}
|
||||
|
||||
export function addShootAction(play, sequenceIndex, shooterId) {
|
||||
if (hasShoot(play) || sequenceIndex !== play.sequences.length - 1) return play;
|
||||
const sequence = play.sequences[sequenceIndex]; const ownerId = finalBallOwner(sequence); if (shooterId !== ownerId) return play;
|
||||
const track = sequence?.tracks.find((candidate) => candidate.playerId === shooterId); const shooter = play.players.find((player) => player.id === shooterId); if (!track || shooter?.team !== 'offense' || track.actions.at(-1)?.type === 'shoot') return play;
|
||||
const next = structuredClone(play); const nextTrack = next.sequences[sequenceIndex].tracks.find((candidate) => candidate.playerId === shooterId); nextTrack.actions.push({ id: `action-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, location: finalTrackLocation(nextTrack), facing: null, lookAt: { type: 'rim' }, type: 'shoot', targetPlayerId: null });
|
||||
return shootInvariantsValid(next) ? next : play;
|
||||
}
|
||||
|
||||
export function removeAction(play, sequenceIndex, playerId, actionIndex) {
|
||||
const originalAction = play.sequences[sequenceIndex]?.tracks.find((candidate) => candidate.playerId === playerId)?.actions[actionIndex];
|
||||
if (hasShoot(play) && originalAction?.type !== 'shoot') return play;
|
||||
const next = structuredClone(play);
|
||||
const track = next.sequences[sequenceIndex]?.tracks.find((candidate) => candidate.playerId === playerId);
|
||||
if (track && actionIndex >= 0 && actionIndex < track.actions.length) {
|
||||
track.actions.splice(actionIndex, 1);
|
||||
relinkFollowingStarts(next, playerId, sequenceIndex + 1);
|
||||
if (!relinkFollowingOwners(next, sequenceIndex) || !shootInvariantsValid(next)) return play;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function addSequence(play) {
|
||||
if (hasShoot(play)) return play;
|
||||
const next = structuredClone(play);
|
||||
const previous = next.sequences.at(-1);
|
||||
next.sequences.push(createSequence(`Sequence ${next.sequences.length + 1}`, next.players, previous));
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeSequence(play, sequenceIndex) {
|
||||
if (hasShoot(play)) return play;
|
||||
const next = structuredClone(play);
|
||||
if (next.sequences.length <= 1 || sequenceIndex < 0 || sequenceIndex >= next.sequences.length) return next;
|
||||
next.sequences.splice(sequenceIndex, 1);
|
||||
for (const player of next.players) relinkFollowingStarts(next, player.id, sequenceIndex);
|
||||
if (next.sequences[sequenceIndex] && !relinkFollowingOwners(next, Math.max(0, sequenceIndex - 1))) return play;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function trackPoints(track) { return [track.startLocation, ...track.actions.map((action) => action.location)]; }
|
||||
|
||||
export function actionExtraDuration(action) { return action?.type === 'pass' ? PASS_DURATION : action?.type === 'screen' ? SCREEN_HOLD : 0; }
|
||||
|
||||
export function trackDuration(track, speed = PLAYER_SPEED, players = []) {
|
||||
if (!track || !speed || speed < 0) return 0;
|
||||
return trackNaturalDuration(track, speed) + (isDefenseTrack(track, players) && track.actions.length ? DEFENSE_REACTION_DELAY : 0);
|
||||
}
|
||||
|
||||
export function trackNaturalDuration(track, speed = PLAYER_SPEED) {
|
||||
if (!track || !speed || speed < 0) return 0;
|
||||
let current = track.startLocation; let total = 0;
|
||||
for (const action of track.actions || []) { if (action.type === 'shoot') continue; total += distance(current, action.location) / speed + actionExtraDuration(action); current = action.location; }
|
||||
return total;
|
||||
}
|
||||
|
||||
export function offensePhaseDuration(sequence, speed = PLAYER_SPEED, players = []) {
|
||||
return Math.max(0, ...(sequence?.tracks || []).filter((track) => !isDefenseTrack(track, players)).map((track) => trackNaturalDuration(track, speed)));
|
||||
}
|
||||
|
||||
export function scheduledTrackDuration(sequence, track, speed = PLAYER_SPEED, players = []) {
|
||||
const natural = trackNaturalDuration(track, speed);
|
||||
if (!isDefenseTrack(track, players)) return natural;
|
||||
if (!track?.actions?.length) return 0;
|
||||
if (natural <= 0) return DEFENSE_REACTION_DELAY;
|
||||
return DEFENSE_REACTION_DELAY + Math.max(natural, offensePhaseDuration(sequence, speed, players) - DEFENSE_REACTION_DELAY);
|
||||
}
|
||||
|
||||
export function sequenceDuration(sequence, speed = PLAYER_SPEED, players = []) {
|
||||
return sequenceNormalDuration(sequence, speed, players) + (sequence?.tracks?.some((track) => track.actions.some((action) => action.type === 'shoot')) ? SHOOT_DURATION : 0);
|
||||
}
|
||||
export function sequenceNormalDuration(sequence, speed = PLAYER_SPEED, players = []) { return Math.max(0, ...(sequence?.tracks || []).map((track) => scheduledTrackDuration(sequence, track, speed, players))); }
|
||||
|
||||
export function allSequenceDurations(play, speed = PLAYER_SPEED) {
|
||||
return (play.sequences || []).map((sequence) => sequenceDuration(sequence, speed, play.players));
|
||||
}
|
||||
|
||||
const directionFacing = (from, to) => Math.atan2((to?.x || 0) - (from?.x || 0), (to?.z || 0) - (from?.z || 0));
|
||||
|
||||
export function resolveFacing(track, actionIndex, speed = PLAYER_SPEED) {
|
||||
if (!track) return 0;
|
||||
const action = track.actions[actionIndex];
|
||||
if (action?.facing !== null && action?.facing !== undefined && Number.isFinite(action.facing)) return action.facing;
|
||||
const points = trackPoints(track);
|
||||
const from = points[actionIndex];
|
||||
const to = points[actionIndex + 1];
|
||||
if (from && to && distance(from, to) > 1e-6) return directionFacing(from, to);
|
||||
for (let i = actionIndex - 1; i >= 0; i -= 1) {
|
||||
if (track.actions[i]?.facing !== null && Number.isFinite(track.actions[i].facing)) return track.actions[i].facing;
|
||||
if (distance(points[i], points[i + 1]) > 1e-6) return directionFacing(points[i], points[i + 1]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function normalizeLookAt(lookAt, playerId, players = []) {
|
||||
if (!lookAt || typeof lookAt !== 'object') return null;
|
||||
if (lookAt.type === 'rim' || lookAt.type === 'ball') return { type: lookAt.type };
|
||||
if (lookAt.type === 'movement') return { type: 'movement' };
|
||||
if (lookAt.type === 'location' && Number.isFinite(Number(lookAt.x)) && Number.isFinite(Number(lookAt.z))) {
|
||||
return { type: 'location', ...clampLocation(lookAt) };
|
||||
}
|
||||
if (lookAt.type === 'player' && lookAt.targetId && lookAt.targetId !== playerId && players.some((player) => player.id === lookAt.targetId)) {
|
||||
return { type: 'player', targetId: lookAt.targetId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveLookAt(play, sequence, playerId, actionIndex, currentLocation = null) {
|
||||
const player = play.players.find((candidate) => candidate.id === playerId);
|
||||
const track = sequence?.tracks.find((candidate) => candidate.playerId === playerId);
|
||||
const action = track?.actions[actionIndex];
|
||||
const override = normalizeLookAt(action?.lookAt, playerId, play.players);
|
||||
if (override?.type === 'movement') {
|
||||
const facing = resolveFacing(track, actionIndex);
|
||||
const origin = currentLocation || action.location;
|
||||
return { type: 'location', x: origin.x + Math.sin(facing) * 4, z: origin.z + Math.cos(facing) * 4 };
|
||||
}
|
||||
if (override) return override;
|
||||
if (action?.type === 'pass' || action?.type === 'screen') return { type: 'player', targetId: action.targetPlayerId };
|
||||
if (action?.type === 'shoot') return { type: 'rim' };
|
||||
const ballOwnerId = sequence?.ballOwnerId || play.players.find((candidate) => candidate.team === 'offense')?.id;
|
||||
if (player?.team === 'offense') return playerId === ballOwnerId ? { type: 'rim' } : { type: 'ball' };
|
||||
if (play.defenseType === 'man-to-man') {
|
||||
const match = play.players.find((candidate) => candidate.team === 'offense' && candidate.number === player?.number);
|
||||
if (match) return { type: 'player', targetId: match.id };
|
||||
}
|
||||
return { type: 'ball' };
|
||||
}
|
||||
|
||||
export function lookAtLocation(lookAt, play, locations = {}, sequence = null) {
|
||||
if (!lookAt) return null;
|
||||
if (lookAt.type === 'rim') return copyLocation(COURT.rim);
|
||||
if (lookAt.type === 'location') return copyLocation(lookAt);
|
||||
if (lookAt.type === 'ball') { if (sequence?.ballLocation) return copyLocation(sequence.ballLocation); const ownerId = sequence?.ballOwnerId || play.sequences?.[0]?.ballOwnerId || play.players.find((player) => player.team === 'offense')?.id; return copyLocation(locations[ownerId] || play.players.find((player) => player.id === ownerId)?.location || COURT.rim); }
|
||||
if (lookAt.type === 'player') return copyLocation(locations[lookAt.targetId] || play.players.find((p) => p.id === lookAt.targetId)?.location || COURT.rim);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shortestAngleLerp(from, to, amount) {
|
||||
const twoPi = Math.PI * 2;
|
||||
let delta = ((to - from + Math.PI) % twoPi + twoPi) % twoPi - Math.PI;
|
||||
return from + delta * Math.max(0, Math.min(1, amount));
|
||||
}
|
||||
|
||||
export { OFFENSE_START, DEFENSE_START };
|
||||
@@ -0,0 +1,412 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { addAction, addPassAction, addScreenAction, addShootAction, addSequence, createInitialPlay, DEFENSE_REACTION_DELAY, distance, finalBallOwner, hasShoot, normalizeLookAt, offensePhaseDuration, PASS_DURATION, removeAction, resolveFacing, resolveLookAt, restoreTrackActionSnapshot, SCREEN_HOLD, scheduledTrackDuration, sequenceDuration, setPlayerStartLocation, setSequenceBallOwner, shortestAngleLerp, SHOOT_DURATION, snapLocation, trackActionSnapshot, trackNaturalDuration, trackDuration } from './domain.js';
|
||||
import { createPlaybackController, sampleEditingSequence, samplePlay, sampleSequence, selectRenderSample } from './playback.js';
|
||||
import { HORIZONTAL_FOV, POV_EYE_HEIGHT, createCameras, updatePovCamera } from './cameras.js';
|
||||
import { addMoveAction, addPlaySequence, canRedoActionEdit, canUndoActionEdit, commitActionEdit, createAppState, deletePlaySequence, deleteSelectedAction, redoActionEdit, setSelectedPlayerStart, undoActionEdit } from './state.js';
|
||||
import { passArrowPoints, passArrowVisualGeometry, pathSignature, possessionVisualState, screenMarkerState } from './scene.js';
|
||||
import * as THREE from 'three';
|
||||
|
||||
describe('전술 데이터 모델', () => {
|
||||
it.each(['man-to-man', '2-3', '3-2'])('수비 형태 %s가 10명과 Sequence 1을 만든다', (defenseType) => {
|
||||
const play = createInitialPlay('테스트', defenseType);
|
||||
expect(play.players).toHaveLength(10);
|
||||
expect(play.sequences).toHaveLength(1);
|
||||
expect(JSON.parse(JSON.stringify(play))).toEqual(play);
|
||||
});
|
||||
|
||||
it('선택 선수의 action을 순서대로 추가한다', () => {
|
||||
let play = createInitialPlay();
|
||||
play = addAction(play, 0, 'offense-1', { x: 1, z: 3 });
|
||||
play = addAction(play, 0, 'offense-1', { x: 2, z: 5 });
|
||||
expect(play.sequences[0].tracks[0].actions).toHaveLength(2);
|
||||
expect(play.sequences[0].tracks[0].actions[1].type).toBe('move');
|
||||
});
|
||||
|
||||
it('새 전술은 Starting 단계에서 시작 위치만 변경하고 Action을 만들지 않는다', () => {
|
||||
let state = createAppState();
|
||||
expect(state.mode).toBe('start');
|
||||
state = setSelectedPlayerStart(state, { x: 2.24, z: 4.26 });
|
||||
const player = state.play.players.find((candidate) => candidate.id === 'offense-1');
|
||||
const track = state.play.sequences[0].tracks.find((candidate) => candidate.playerId === 'offense-1');
|
||||
expect(player.location).toEqual({ x: 2, z: 4.5 });
|
||||
expect(track.startLocation).toEqual({ x: 2, z: 4.5 });
|
||||
expect(track.actions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Starting 위치 변경은 뒤 Sequence의 상속 시작점도 다시 연결한다', () => {
|
||||
let play = addSequence(createInitialPlay());
|
||||
play = setPlayerStartLocation(play, 'offense-1', { x: 3, z: 4 });
|
||||
expect(play.sequences[0].tracks[0].startLocation).toEqual({ x: 3, z: 4 });
|
||||
expect(play.sequences[1].tracks[0].startLocation).toEqual({ x: 3, z: 4 });
|
||||
});
|
||||
|
||||
it('Action의 LookAt을 복사하고 self/invalid target은 저장하지 않는다', () => {
|
||||
const lookAt = { type: 'location', x: 1, z: 3 };
|
||||
const play = addAction(createInitialPlay(), 0, 'offense-1', { x: 1, z: 3 }, { lookAt });
|
||||
lookAt.x = 99;
|
||||
expect(play.sequences[0].tracks[0].actions[0].lookAt).toEqual({ type: 'location', x: 1, z: 3 });
|
||||
const safe = addAction(play, 0, 'offense-1', { x: 2, z: 3 }, { lookAt: { type: 'player', targetId: 'offense-1' } });
|
||||
expect(safe.sequences[0].tracks[0].actions[1].lookAt).toBeNull();
|
||||
});
|
||||
|
||||
it('코트 좌표를 반 칸 grid로 snap하고 경계를 넘지 않는다', () => {
|
||||
expect(snapLocation({ x: 2.24, z: 4.26 })).toEqual({ x: 2, z: 4.5 });
|
||||
expect(snapLocation({ x: 99, z: -3 })).toEqual({ x: 7.5, z: 0 });
|
||||
});
|
||||
|
||||
it('새 Sequence가 마지막 위치를 deep-copy한다', () => {
|
||||
let play = addAction(createInitialPlay(), 0, 'offense-1', { x: 3, z: 4 });
|
||||
const next = addSequence(play);
|
||||
expect(next.sequences[1].tracks[0].startLocation).toEqual({ x: 3, z: 4 });
|
||||
next.sequences[1].tracks[0].startLocation.x = 99;
|
||||
expect(play.sequences[0].tracks[0].actions[0].location.x).toBe(3);
|
||||
});
|
||||
|
||||
it('이전 Sequence에 action이 없어도 시작 위치를 상속한다', () => {
|
||||
const play = createInitialPlay();
|
||||
play.sequences[0].tracks[0].startLocation = { x: 2, z: 8 };
|
||||
const next = addSequence(play);
|
||||
expect(next.sequences[1].tracks[0].startLocation).toEqual({ x: 2, z: 8 });
|
||||
});
|
||||
|
||||
it('이전 Sequence의 D5 Action 변경을 이미 생성된 다음 Sequence 시작점에 반영한다', () => {
|
||||
let play = createInitialPlay();
|
||||
play = addSequence(addSequence(addSequence(play)));
|
||||
play = addAction(play, 2, 'defense-5', { x: 2, z: 6 });
|
||||
expect(play.sequences[3].tracks.find((track) => track.playerId === 'defense-5').startLocation).toEqual({ x: 2, z: 6 });
|
||||
play = removeAction(play, 2, 'defense-5', 0);
|
||||
expect(play.sequences[3].tracks.find((track) => track.playerId === 'defense-5').startLocation).toEqual(play.sequences[2].tracks.find((track) => track.playerId === 'defense-5').startLocation);
|
||||
});
|
||||
|
||||
it('Sequence별 공 소유자를 공격 선수로 지정하고 다음 Sequence가 상속한다', () => {
|
||||
let play = createInitialPlay();
|
||||
expect(play.sequences[0].ballOwnerId).toBe('offense-1');
|
||||
play = setSequenceBallOwner(play, 0, 'offense-3');
|
||||
play = addSequence(play);
|
||||
expect(play.sequences[0].ballOwnerId).toBe('offense-3');
|
||||
expect(play.sequences[1].ballOwnerId).toBe('offense-3');
|
||||
expect(resolveLookAt(play, play.sequences[0], 'offense-3', 0)).toEqual({ type: 'rim' });
|
||||
expect(resolveLookAt(play, play.sequences[0], 'offense-1', 0)).toEqual({ type: 'ball' });
|
||||
const sample = sampleSequence(play, play.sequences[0], 0);
|
||||
expect(sample.samples['offense-1'].lookAtLocation).toEqual(sample.locations['offense-3']);
|
||||
expect(setSequenceBallOwner(play, 0, 'defense-1')).toEqual(play);
|
||||
});
|
||||
});
|
||||
|
||||
describe('공격 우선권과 추가 Action', () => {
|
||||
it('수비 이동 track만 Sequence당 한 번 반응 지연을 가진다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'defense-1', { x: 0, z: 8 });
|
||||
const defense = play.sequences[0].tracks.find((track) => track.playerId === 'defense-1');
|
||||
expect(trackDuration(defense, 4.5, play.players)).toBeCloseTo(DEFENSE_REACTION_DELAY + distance(defense.startLocation, defense.actions[0].location) / 4.5);
|
||||
expect(trackDuration(play.sequences[0].tracks.find((track) => track.playerId === 'defense-2'), 4.5, play.players)).toBe(0);
|
||||
});
|
||||
|
||||
it('pass는 시작 ballOwner의 공격 대상에게 Sequence당 한 번만 생성되고 owner를 상속한다', () => {
|
||||
let play = createInitialPlay(); play = addPassAction(play, 0, 'offense-1', 'offense-2');
|
||||
expect(play.sequences[0].tracks[0].actions[0]).toMatchObject({ type: 'pass', targetPlayerId: 'offense-2' });
|
||||
expect(finalBallOwner(play.sequences[0])).toBe('offense-2'); expect(addPassAction(play, 0, 'offense-1', 'offense-3')).toBe(play);
|
||||
play = addSequence(play); expect(play.sequences[1].ballOwnerId).toBe('offense-2'); expect(play.sequences[1].ballOwnerInherited).toBe(true);
|
||||
const manual = setSequenceBallOwner(play, 1, 'offense-3'); play = addAction(manual, 0, 'offense-1', null, { type: 'move' });
|
||||
expect(play.sequences[1].ballOwnerId).toBe('offense-3'); expect(play.sequences[1].ballOwnerInherited).toBe(false);
|
||||
expect(removeAction(manual, 0, 'offense-1', 0).sequences[1].ballOwnerId).toBe('offense-3');
|
||||
});
|
||||
|
||||
it('pass가 있는 Sequence는 시작 owner 변경을 거부한다', () => {
|
||||
const play = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2');
|
||||
expect(setSequenceBallOwner(play, 0, 'offense-3')).toBe(play);
|
||||
});
|
||||
|
||||
it('screen은 off-ball 공격만 허용하고 hold duration을 더한다', () => {
|
||||
let play = createInitialPlay(); play = addScreenAction(play, 0, 'offense-2', 'defense-1');
|
||||
const track = play.sequences[0].tracks.find((candidate) => candidate.playerId === 'offense-2');
|
||||
const defender = play.sequences[0].tracks.find((candidate) => candidate.playerId === 'defense-1'); const screen = track.actions[0];
|
||||
expect(screen.type).toBe('screen'); expect(screen.targetPlayerId).toBe('defense-1'); expect(screen.lookAt).toEqual({ type: 'player', targetId: 'defense-1' }); expect(screen.facing).toBeCloseTo(Math.atan2(defender.startLocation.x - screen.location.x, defender.startLocation.z - screen.location.z)); expect(distance(defender.startLocation, screen.location)).toBeCloseTo(0.85); expect(trackDuration(track)).toBeCloseTo(distance(track.startLocation, screen.location) / 4.5 + SCREEN_HOLD);
|
||||
expect(addScreenAction(play, 0, 'offense-1', 'defense-1')).toBe(play);
|
||||
});
|
||||
|
||||
it('screen은 수비자 final 위치에서 0.85m 떨어지고 grid snap하지 않는다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'defense-1', { x: 0, z: 8.3 }); play = addScreenAction(play, 0, 'offense-2', 'defense-1'); const screen = play.sequences[0].tracks.find((track) => track.playerId === 'offense-2').actions[0]; const defenderFinal = { x: 0, z: 8.5 }; const screenerFinal = { x: -5.8, z: 1.2 }; const length = distance(defenderFinal, screenerFinal);
|
||||
expect(screen.location.x).toBeCloseTo(defenderFinal.x + (screenerFinal.x - defenderFinal.x) * 0.85 / length); expect(screen.location.z).toBeCloseTo(defenderFinal.z + (screenerFinal.z - defenderFinal.z) * 0.85 / length); expect(distance(defenderFinal, screen.location)).toBeCloseTo(0.85); expect(screen.location.x % 0.5).not.toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('screen은 0.76 미만 겹침을 거부하고 0.76~0.85는 screener final에서 hold한다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'defense-1', { x: -5.5, z: 1.2 }); expect(addScreenAction(play, 0, 'offense-2', 'defense-1')).toBe(play);
|
||||
play = createInitialPlay(); play = addAction(play, 0, 'defense-1', { x: -5, z: 1.2 }); const screened = addScreenAction(play, 0, 'offense-2', 'defense-1'); expect(screened).not.toBe(play); expect(screened.sequences[0].tracks.find((track) => track.playerId === 'offense-2').actions[0].location).toEqual({ x: -5.8, z: 1.2 });
|
||||
});
|
||||
|
||||
it('pass 후 final owner는 screen을 만들 수 없고 이전 owner는 만들 수 있다', () => {
|
||||
const play = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2'); expect(addScreenAction(play, 0, 'offense-2', 'defense-1')).toBe(play); expect(addScreenAction(play, 0, 'offense-1', 'defense-1')).not.toBe(play);
|
||||
});
|
||||
|
||||
it('Shoot은 마지막 Sequence의 final owner track에 하나만 terminal로 추가된다', () => {
|
||||
let play = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2'); const shot = addShootAction(play, 0, 'offense-2');
|
||||
expect(shot).not.toBe(play); expect(hasShoot(shot)).toBe(true); const action = shot.sequences[0].tracks.find((track) => track.playerId === 'offense-2').actions.at(-1); expect(action).toMatchObject({ type: 'shoot', location: { x: -5.8, z: 1.2 }, facing: null, lookAt: { type: 'rim' }, targetPlayerId: null });
|
||||
expect(addShootAction(shot, 0, 'offense-2')).toBe(shot); expect(addAction(shot, 0, 'offense-1', { x: 0, z: 8 })).toBe(shot); expect(addSequence(shot)).toBe(shot); expect(setSequenceBallOwner(shot, 0, 'offense-3')).toBe(shot);
|
||||
expect(addPassAction(shot, 0, 'offense-2', 'offense-3')).toBe(shot); expect(addScreenAction(shot, 0, 'offense-3', 'defense-1')).toBe(shot); expect(removeAction(shot, 0, 'offense-1', 0)).toBe(shot); expect(removeAction(shot, 0, 'offense-2', 0)).not.toBe(shot);
|
||||
});
|
||||
|
||||
it('upstream pass 삭제는 downstream Shoot owner 충돌을 만들지 않는다', () => {
|
||||
let play = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2'); play = addSequence(play); play = addShootAction(play, 1, 'offense-2');
|
||||
expect(removeAction(play, 0, 'offense-1', 0)).toBe(play);
|
||||
});
|
||||
|
||||
it('upstream pass와 owner 지정은 downstream inherited Screen 충돌 시 원본을 유지한다', () => {
|
||||
let play = addSequence(createInitialPlay()); play = addScreenAction(play, 1, 'offense-2', 'defense-1'); const before = play;
|
||||
expect(addPassAction(play, 0, 'offense-1', 'offense-2')).toBe(before); expect(setSequenceBallOwner(play, 0, 'offense-2')).toBe(before);
|
||||
});
|
||||
|
||||
it('Pass 삭제로 downstream Screen owner가 충돌하면 원본을 유지한다', () => {
|
||||
let play = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2'); play = addSequence(play); play = addScreenAction(play, 1, 'offense-1', 'defense-1');
|
||||
expect(removeAction(play, 0, 'offense-1', 0)).toBe(play);
|
||||
});
|
||||
|
||||
it('충돌하는 owner 복원도 cloned play를 적용하지 않는다', () => {
|
||||
const source = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2'); const snapshot = trackActionSnapshot(source, source.sequences[0].id, 'offense-1'); let current = addSequence(createInitialPlay()); current.sequences[0].id = source.sequences[0].id; current = addScreenAction(current, 1, 'offense-2', 'defense-1');
|
||||
expect(restoreTrackActionSnapshot(current, snapshot)).toBe(current);
|
||||
});
|
||||
|
||||
it('screen screener에게 pass하거나 소유자를 바꾸면 off-ball 불변식을 지킨다', () => {
|
||||
const play = addScreenAction(createInitialPlay(), 0, 'offense-2', 'defense-1'); expect(addPassAction(play, 0, 'offense-1', 'offense-2')).toBe(play); expect(setSequenceBallOwner(play, 0, 'offense-2')).toBe(play);
|
||||
});
|
||||
|
||||
it('screen 위치는 후속 Sequence screener start로 상속된다', () => {
|
||||
const play = addSequence(addScreenAction(createInitialPlay(), 0, 'offense-2', 'defense-1')); const screenLocation = play.sequences[0].tracks.find((track) => track.playerId === 'offense-2').actions[0].location; expect(play.sequences[1].tracks.find((track) => track.playerId === 'offense-2').startLocation).toEqual(screenLocation);
|
||||
});
|
||||
|
||||
it('수비 scheduled duration은 공격 phase에 맞춰 time-stretch한다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'offense-1', { x: 0, z: 11 }); play = addAction(play, 0, 'defense-1', { x: 0, z: 6 });
|
||||
const sequence = play.sequences[0]; const offense = sequence.tracks.find((track) => track.playerId === 'offense-1'); const defense = sequence.tracks.find((track) => track.playerId === 'defense-1');
|
||||
expect(trackNaturalDuration(defense)).toBeCloseTo(0.8 / 4.5); expect(offensePhaseDuration(sequence, 4.5, play.players)).toBeCloseTo(8.9 / 4.5); expect(scheduledTrackDuration(sequence, defense, 4.5, play.players)).toBeCloseTo(1.9777, 3);
|
||||
});
|
||||
|
||||
it('O=2,N=1 수비는 0.2초 hold 후 배속되어 2초에 끝난다', () => {
|
||||
let play = createInitialPlay(); const offense = play.sequences[0].tracks.find((track) => track.playerId === 'offense-1'); const defense = play.sequences[0].tracks.find((track) => track.playerId === 'defense-1'); offense.startLocation = { x: 0, z: 0 }; defense.startLocation = { x: 0, z: 0 }; play = addAction(play, 0, 'offense-1', { x: 0, z: 9 }); play = addAction(play, 0, 'defense-1', { x: 0, z: 4.5 });
|
||||
expect(sequenceDuration(play.sequences[0], 4.5, play.players)).toBeCloseTo(2); expect(sampleSequence(play, play.sequences[0], 0.2).locations['defense-1']).toEqual({ x: 0, z: 0 }); expect(sampleSequence(play, play.sequences[0], 1.1).locations['defense-1'].z).toBeCloseTo(2.25); expect(sampleSequence(play, play.sequences[0], 2).locations['defense-1'].z).toBeCloseTo(4.5);
|
||||
});
|
||||
|
||||
it('O=2,N=2.5 수비는 자연 속도로 2.7초에 끝나며 zero track도 finite하다', () => {
|
||||
let play = createInitialPlay(); const offense = play.sequences[0].tracks.find((track) => track.playerId === 'offense-1'); const defense = play.sequences[0].tracks.find((track) => track.playerId === 'defense-1'); offense.startLocation = { x: 0, z: 0 }; defense.startLocation = { x: 0, z: 0 }; play = addAction(play, 0, 'offense-1', { x: 0, z: 9 }); play = addAction(play, 0, 'defense-1', { x: 0, z: 11.25 }); play.sequences[0].tracks.find((track) => track.playerId === 'defense-1').actions[0].location = { x: 0, z: 11.25 };
|
||||
expect(sequenceDuration(play.sequences[0], 4.5, play.players)).toBeCloseTo(2.7); expect(Number.isFinite(sampleSequence(play, play.sequences[0], 0).locations['defense-2'].z)).toBe(true);
|
||||
});
|
||||
|
||||
it('공격 Action 없이 동일 위치인 수비 Action도 0.2초 reaction만 스케줄한다', () => {
|
||||
let play = createInitialPlay(); const defense = play.sequences[0].tracks.find((track) => track.playerId === 'defense-1'); defense.startLocation = { x: 0, z: 0 }; play = addAction(play, 0, 'defense-1', { x: 0, z: 0 });
|
||||
expect(sequenceDuration(play.sequences[0], 4.5, play.players)).toBeCloseTo(0.2); expect(sampleSequence(play, play.sequences[0], 0.1).samples['defense-1'].phase).toBe('reaction'); expect(sampleSequence(play, play.sequences[0], 0.1).locations['defense-1']).toEqual({ x: 0, z: 0 }); expect(sampleSequence(play, play.sequences[0], 0.21).samples['defense-1'].phase).toBe('final');
|
||||
});
|
||||
});
|
||||
|
||||
describe('편집 history', () => {
|
||||
it('같은 Sequence의 O1/O2와 다른 Sequence의 O1 history를 격리한다', () => {
|
||||
let state = addMoveAction(createAppState(), { x: 2, z: 4 }); state = { ...state, selectedPlayerId: 'offense-2' }; state = addMoveAction(state, { x: -4, z: 4 }); state = addPlaySequence({ ...state, selectedPlayerId: 'offense-1' }); state = addMoveAction(state, { x: 0, z: 8 });
|
||||
const firstId = state.play.sequences[0].id; const secondId = state.play.sequences[1].id; expect(state.history.bySequenceId[firstId]['offense-1'].past).toHaveLength(1); expect(state.history.bySequenceId[firstId]['offense-2'].past).toHaveLength(1); expect(state.history.bySequenceId[secondId]['offense-1'].past).toHaveLength(1);
|
||||
state = { ...state, selectedSequence: 0, selectedPlayerId: 'offense-1' }; const beforeSelection = { selectedSequence: state.selectedSequence, selectedPlayerId: state.selectedPlayerId }; state = undoActionEdit(state); expect(state.play.sequences[0].tracks[0].actions).toHaveLength(0); expect(state.play.sequences[0].tracks[1].actions).toHaveLength(1); expect(state.play.sequences[1].tracks[0].actions).toHaveLength(1); expect({ selectedSequence: state.selectedSequence, selectedPlayerId: state.selectedPlayerId }).toEqual(beforeSelection);
|
||||
});
|
||||
|
||||
it('track별 history는 50개로 제한되고 새 편집은 현재 track redo만 지운다', () => {
|
||||
let state = createAppState(); for (let i = 0; i < 55; i += 1) state = addMoveAction(state, { x: (i % 5) - 2, z: 3 + (i % 4) }); const id = state.play.sequences[0].id; expect(state.history.bySequenceId[id]['offense-1'].past).toHaveLength(50); state = undoActionEdit(state); expect(canRedoActionEdit(state)).toBe(true); state = { ...state, selectedPlayerId: 'offense-2' }; state = addMoveAction(state, { x: 1, z: 8 }); state = { ...state, selectedPlayerId: 'offense-1' }; expect(canRedoActionEdit(state)).toBe(true); state = addMoveAction(state, { x: 1, z: 8 }); expect(canRedoActionEdit(state)).toBe(false);
|
||||
});
|
||||
|
||||
it('Starting·owner·Sequence 구조 변경은 action history를 기록하거나 지우지 않는다', () => {
|
||||
let state = addMoveAction(createAppState(), { x: 2, z: 4 }); state = undoActionEdit(state); expect(canRedoActionEdit(state)).toBe(true); state = setSelectedPlayerStart(state, { x: 3, z: 4 }); expect(canRedoActionEdit(state)).toBe(true); state = { ...state, play: setSequenceBallOwner(state.play, 0, 'offense-2') }; expect(canRedoActionEdit(state)).toBe(true); state = addPlaySequence(state); const secondId = state.play.sequences[1].id; expect(Object.values(state.history.bySequenceId[secondId]).every((entry) => entry.past.length === 0 && entry.future.length === 0)).toBe(true); state = deletePlaySequence(state); expect(state.history.bySequenceId[secondId]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Pass undo/redo는 선택 track만 바꾸고 downstream owner를 relink한다', () => {
|
||||
let state = addPlaySequence(createAppState()); state = { ...state, selectedSequence: 0, selectedPlayerId: 'offense-1' }; state = commitActionEdit(state, addPassAction(state.play, 0, 'offense-1', 'offense-2')); state = undoActionEdit(state); expect(state.play.sequences[0].tracks[0].actions).toHaveLength(0); expect(state.play.sequences[1].ballOwnerId).toBe('offense-1'); state = redoActionEdit(state); expect(state.play.sequences[0].tracks[0].actions[0].type).toBe('pass'); expect(state.play.sequences[1].ballOwnerId).toBe('offense-2');
|
||||
});
|
||||
|
||||
it('충돌 snapshot은 restore/canRedo를 거부하고 history cursor를 보존한다', () => {
|
||||
let state = addPlaySequence(createAppState()); state = { ...state, selectedSequence: 0, selectedPlayerId: 'offense-1' }; state = commitActionEdit(state, addPassAction(state.play, 0, 'offense-1', 'offense-2')); state = undoActionEdit(state); state = addScreenAction(state.play, 1, 'offense-2', 'defense-1') === state.play ? state : { ...state, play: addScreenAction(state.play, 1, 'offense-2', 'defense-1') }; state = { ...state, selectedSequence: 0 }; const before = state.history.bySequenceId[state.play.sequences[0].id]['offense-1'].future.length; expect(canRedoActionEdit(state)).toBe(false); expect(redoActionEdit(state)).toBe(state); expect(state.history.bySequenceId[state.play.sequences[0].id]['offense-1'].future).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('Pass Undo가 downstream terminal Shoot owner를 깨뜨리면 원본 history를 유지한다', () => {
|
||||
let state = addPlaySequence(createAppState()); state = { ...state, selectedSequence: 0, selectedPlayerId: 'offense-1' }; state = commitActionEdit(state, addPassAction(state.play, 0, 'offense-1', 'offense-2'));
|
||||
state = { ...state, selectedSequence: 1, selectedPlayerId: 'offense-2' }; state = commitActionEdit(state, addShootAction(state.play, 1, 'offense-2'), 1, 'offense-2');
|
||||
state = { ...state, selectedSequence: 0, selectedPlayerId: 'offense-1' }; expect(undoActionEdit(state)).toBe(state); expect(canUndoActionEdit(state)).toBe(false);
|
||||
});
|
||||
|
||||
it('Shoot 중에는 다른 track history를 잠그고 shooter의 Shoot Undo/Redo만 허용한다', () => {
|
||||
let state = addMoveAction(createAppState(), { x: 2, z: 4 }); state = { ...state, selectedPlayerId: 'offense-1' }; state = commitActionEdit(state, addShootAction(state.play, 0, 'offense-1'), 0, 'offense-1');
|
||||
state = { ...state, selectedPlayerId: 'offense-2' }; expect(canUndoActionEdit(state)).toBe(false); expect(undoActionEdit(state)).toBe(state); expect(redoActionEdit(state)).toBe(state);
|
||||
state = { ...state, selectedPlayerId: 'offense-1' }; expect(canUndoActionEdit(state)).toBe(true); state = undoActionEdit(state); expect(hasShoot(state.play)).toBe(false); expect(canRedoActionEdit(state)).toBe(true); state = redoActionEdit(state); expect(hasShoot(state.play)).toBe(true);
|
||||
});
|
||||
|
||||
it('restore Track snapshot은 다른 player action과 downstream start를 보존한다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'offense-1', { x: 0, z: 5 }); play = addAction(play, 0, 'offense-2', { x: -4, z: 4 }); const sequenceId = play.sequences[0].id; const snapshot = trackActionSnapshot(play, sequenceId, 'offense-1'); play = addAction(play, 0, 'offense-1', { x: 0, z: 7 }); const otherActions = play.sequences[0].tracks.find((track) => track.playerId === 'offense-2').actions; const restored = restoreTrackActionSnapshot(play, snapshot); expect(restored.sequences[0].tracks.find((track) => track.playerId === 'offense-2').actions).toEqual(otherActions); expect(restored.sequences[0].tracks.find((track) => track.playerId === 'offense-1').actions).toEqual(snapshot.actions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('재생 샘플링', () => {
|
||||
it('track duration은 거리/속도이고 sequence는 최댓값이며 빈 track은 hold한다', () => {
|
||||
let play = createInitialPlay();
|
||||
play = addAction(play, 0, 'offense-1', { x: 0, z: 4.5 });
|
||||
play = addAction(play, 0, 'offense-2', { x: -5.8, z: 5.7 });
|
||||
const sequence = play.sequences[0];
|
||||
expect(trackDuration(sequence.tracks[0])).toBeCloseTo(0.5333, 3);
|
||||
expect(sequenceDuration(sequence)).toBeGreaterThan(0.5);
|
||||
expect(sampleSequence(play, sequence, 99).locations['offense-3']).toEqual(sequence.tracks[2].startLocation);
|
||||
});
|
||||
|
||||
it('선수별 action 개수가 달라도 sequence 경계에서 최종 위치를 적용한다', () => {
|
||||
let play = createInitialPlay();
|
||||
play = addAction(play, 0, 'offense-1', { x: 0, z: 6 });
|
||||
play = addAction(play, 0, 'offense-1', { x: 0, z: 9 });
|
||||
play = addAction(play, 0, 'offense-2', { x: -5, z: 2 });
|
||||
const duration = sequenceDuration(play.sequences[0]);
|
||||
const sample = samplePlay(play, duration);
|
||||
expect(sample.sequenceIndex).toBe(0);
|
||||
expect(sample.locations['offense-1']).toEqual({ x: 0, z: 9 });
|
||||
expect(sample.locations['offense-2']).toEqual({ x: -5, z: 2 });
|
||||
});
|
||||
|
||||
it('여러 Sequence를 시간순으로 진행한다', () => {
|
||||
let play = addAction(createInitialPlay(), 0, 'offense-1', { x: 0, z: 6 });
|
||||
play = addSequence(play);
|
||||
play = addAction(play, 1, 'offense-1', { x: 0, z: 10 });
|
||||
const firstDuration = sequenceDuration(play.sequences[0]);
|
||||
const sample = samplePlay(play, firstDuration + 0.1);
|
||||
expect(sample.sequenceIndex).toBe(1);
|
||||
expect(sample.locations['offense-1'].z).toBeGreaterThan(6);
|
||||
});
|
||||
|
||||
it('controller는 pause/resume/reset을 유지한다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'offense-1', { x: 0, z: 9 });
|
||||
const controller = createPlaybackController(play); controller.play(); controller.tick(0.2); const elapsed = controller.elapsed;
|
||||
controller.pause(); controller.tick(1); expect(controller.elapsed).toBe(elapsed);
|
||||
controller.play(); controller.tick(0.2); expect(controller.elapsed).toBeGreaterThan(elapsed);
|
||||
controller.reset(); expect(controller.elapsed).toBe(0); expect(controller.isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
it('큰 delta로 총 duration을 넘으면 마지막 pose에서 멈춘다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'offense-1', { x: 0, z: 9 });
|
||||
const controller = createPlaybackController(play); controller.play(); const sample = controller.tick(999);
|
||||
expect(sample.playing).toBe(false); expect(sample.elapsed).toBe(sample.totalDuration); expect(sample.locations['offense-1']).toEqual({ x: 0, z: 9 });
|
||||
});
|
||||
|
||||
it('0초 전술은 play 직후에도 재생되지 않는다', () => {
|
||||
const controller = createPlaybackController(createInitialPlay()); controller.play();
|
||||
expect(controller.isPlaying()).toBe(false); expect(controller.tick(999).playing).toBe(false);
|
||||
});
|
||||
|
||||
it('pass timeline은 PASS_DURATION 동안 실제 ball 위치와 owner를 전환한다', () => {
|
||||
let play = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2'); const sequence = play.sequences[0]; const track = sequence.tracks.find((candidate) => candidate.playerId === 'offense-1');
|
||||
const start = track.startLocation; const target = sequence.tracks.find((candidate) => candidate.playerId === 'offense-2').startLocation; const mid = sampleSequence(play, sequence, PASS_DURATION / 2);
|
||||
expect(mid.ballOwnerId).toBe('offense-1'); expect(mid.ballInFlight).toBe(true); expect(mid.ballLocation.x).toBeCloseTo((start.x + target.x) / 2); expect(mid.ballLocation.z).toBeCloseTo((start.z + target.z) / 2);
|
||||
const end = sampleSequence(play, sequence, PASS_DURATION + 0.001); expect(end.ballOwnerId).toBe('offense-2'); expect(end.ballInFlight).toBe(false);
|
||||
});
|
||||
|
||||
it('pass 완료 후 공은 이동 중인 target을 따라간다', () => {
|
||||
let play = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2'); play = addAction(play, 0, 'offense-2', { x: -2, z: 5 });
|
||||
const sample = sampleSequence(play, play.sequences[0], PASS_DURATION + 0.2); const target = sample.locations['offense-2'];
|
||||
expect(sample.ballOwnerId).toBe('offense-2'); expect(sample.ballLocation).toEqual(target);
|
||||
});
|
||||
|
||||
it('pass arrow는 passer 위치부터 target 마지막 Action까지 연결되고 target 변경을 반영한다', () => {
|
||||
let play = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2'); play = addAction(play, 0, 'offense-2', { x: -2, z: 5 }); const sequence = play.sequences[0]; const action = sequence.tracks[0].actions[0];
|
||||
expect(passArrowPoints(play, sequence, action)).toEqual([action.location, { x: -2, z: 5 }]); expect(pathSignature(passArrowPoints(play, sequence, action))).toBe('0,2.1|-2,5');
|
||||
play = addAction(play, 0, 'offense-2', { x: 2, z: 7 }); expect(pathSignature(passArrowPoints(play, play.sequences[0], play.sequences[0].tracks[0].actions[0]))).toBe('0,2.1|2,7');
|
||||
expect(passArrowPoints(play, sequence, { type: 'pass', location: action.location, targetPlayerId: 'defense-1' })).toEqual([]); expect(passArrowPoints(play, sequence, { type: 'pass', location: action.location, targetPlayerId: 'offense-1' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('짧은 pass arrow도 양수 shaft와 endpoint 근처 화살촉을 유지한다', () => {
|
||||
const visual = passArrowVisualGeometry([{ x: 0, z: 0 }, { x: 0.5, z: 0 }]);
|
||||
expect(visual.shaftLength).toBeGreaterThan(0); expect(visual.end.x).toBeGreaterThan(visual.start.x); expect(visual.headBase.x).toBeGreaterThan(visual.start.x); expect(visual.headBase.x).toBeLessThan(visual.end.x); expect(visual.end.x).toBeLessThan(0.5);
|
||||
});
|
||||
|
||||
it('screen hold 중에는 screen 위치를 유지한다', () => {
|
||||
let play = addScreenAction(createInitialPlay(), 0, 'offense-2', 'defense-1'); const sequence = play.sequences[0]; const track = sequence.tracks.find((candidate) => candidate.playerId === 'offense-2'); const moveTime = distance(track.startLocation, track.actions[0].location) / 4.5;
|
||||
const sample = sampleSequence(play, sequence, moveTime + SCREEN_HOLD / 2); expect(sample.samples['offense-2'].phase).toBe('screenHold'); expect(sample.locations['offense-2']).toEqual(track.actions[0].location);
|
||||
});
|
||||
|
||||
it('screen 접근과 hold 중에는 defender-facing LookAt을 고정한다', () => {
|
||||
let play = addScreenAction(createInitialPlay(), 0, 'offense-2', 'defense-1'); play = addAction(play, 0, 'offense-2', { x: -2, z: 7 }); const sequence = play.sequences[0]; const track = sequence.tracks.find((candidate) => candidate.playerId === 'offense-2'); const screen = track.actions[0]; const moveTime = distance(track.startLocation, screen.location) / 4.5; const sample = sampleSequence(play, sequence, moveTime + SCREEN_HOLD / 2);
|
||||
expect(sample.samples['offense-2'].facing).toBeCloseTo(screen.facing); expect(sample.samples['offense-2'].lookAt).toEqual(screen.lookAt); expect(sample.samples['offense-2'].lookAtLocation).toEqual(sample.locations['defense-1']);
|
||||
});
|
||||
|
||||
it('screen marker state는 Action location과 resolveFacing을 사용한다', () => {
|
||||
const play = addScreenAction(createInitialPlay(), 0, 'offense-2', 'defense-1'); const track = play.sequences[0].tracks.find((candidate) => candidate.playerId === 'offense-2'); const marker = screenMarkerState(track, 0);
|
||||
expect(marker.location).toEqual(track.actions[0].location); expect(marker.facing).toBeCloseTo(track.actions[0].facing); expect(marker.facing).toBeCloseTo(resolveFacing(track, 0));
|
||||
});
|
||||
|
||||
it('pass/screen의 기존 extra duration은 유지된다', () => {
|
||||
let passPlay = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2'); let screenPlay = addScreenAction(createInitialPlay(), 0, 'offense-2', 'defense-1');
|
||||
expect(sequenceDuration(passPlay.sequences[0], 4.5, passPlay.players)).toBeCloseTo(PASS_DURATION); const screenTrack = screenPlay.sequences[0].tracks.find((track) => track.playerId === 'offense-2'); expect(trackNaturalDuration(screenTrack)).toBeCloseTo(distance(screenTrack.startLocation, screenTrack.actions[0].location) / 4.5 + SCREEN_HOLD);
|
||||
});
|
||||
|
||||
it('Shoot은 normal phase 뒤 0.8초 공 포물선을 재생하고 림에서 끝난다', () => {
|
||||
let play = addAction(createInitialPlay(), 0, 'offense-1', { x: 0, z: 6 }); play = addShootAction(play, 0, 'offense-1'); const sequence = play.sequences[0]; const normal = distance(sequence.tracks[0].startLocation, { x: 0, z: 6 }) / 4.5;
|
||||
expect(sequenceDuration(sequence)).toBeCloseTo(normal + SHOOT_DURATION); const mid = sampleSequence(play, sequence, normal + SHOOT_DURATION / 2); expect(mid.ballInFlight).toBe(true); expect(mid.ballOwnerId).toBeNull(); expect(mid.ballLocation.x).toBeCloseTo(0); expect(mid.ballLocation.z).toBeCloseTo((6 + 13.25) / 2); expect(mid.ballHeight).toBeGreaterThan(0.42);
|
||||
const end = sampleSequence(play, sequence, normal + SHOOT_DURATION); expect(end.ballAtRim).toBe(true); expect(end.ballInFlight).toBe(false); expect(end.ballLocation).toEqual({ x: 0, z: 13.25 }); expect(end.ballOwnerId).toBeNull();
|
||||
});
|
||||
|
||||
it('reset intent는 전체 전술의 Sequence 1 시작 pose를 선택한다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'offense-1', { x: 0, z: 9 });
|
||||
const sample = selectRenderSample(play, { resetPreview: true, selectedSequence: 1 }, createPlaybackController(play), 0);
|
||||
expect(sample.elapsed).toBe(0); expect(sample.sequenceIndex).toBe(0); expect(sample.locations['offense-1']).toEqual(play.sequences[0].tracks[0].startLocation);
|
||||
});
|
||||
|
||||
it('paused playback 후 Action 선택 intent는 selected Action editing preview로 전환한다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'offense-1', { x: 3, z: 5 }, { lookAt: { type: 'location', x: 4, z: 6 } });
|
||||
const controller = createPlaybackController(play); controller.play(); controller.tick(0.1); controller.pause();
|
||||
const sample = selectRenderSample(play, { playbackSession: false, selectedSequence: 0, selectedPlayerId: 'offense-1', selectedAction: 0 }, controller, 0);
|
||||
expect(sample.editing).toBe(true); expect(sample.locations['offense-1']).toEqual({ x: 3, z: 5 }); expect(sample.samples['offense-1'].lookAtLocation).toEqual({ x: 4, z: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('facing/lookAt/POV 데이터', () => {
|
||||
it('이동 방향 facing과 invalid/self lookAt을 안전하게 처리한다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'offense-1', { x: 2, z: 2.1 });
|
||||
const track = play.sequences[0].tracks[0]; expect(resolveFacing(track, 0)).toBeCloseTo(Math.atan2(track.actions[0].location.x - track.startLocation.x, track.actions[0].location.z - track.startLocation.z));
|
||||
expect(normalizeLookAt({ type: 'player', targetId: 'offense-1' }, 'offense-1', play.players)).toBeNull();
|
||||
expect(normalizeLookAt({ type: 'player', targetId: 'missing' }, 'offense-1', play.players)).toBeNull();
|
||||
expect(shortestAngleLerp(Math.PI * 0.95, -Math.PI * 0.95, 0.5)).toBeCloseTo(Math.PI, 5);
|
||||
});
|
||||
|
||||
it('자동 lookAt 우선순위와 POV target을 계산한다', () => {
|
||||
const play = createInitialPlay(); const sequence = play.sequences[0];
|
||||
expect(resolveLookAt(play, sequence, 'offense-1', 0)).toEqual({ type: 'rim' });
|
||||
expect(resolveLookAt(play, sequence, 'offense-2', 0)).toEqual({ type: 'ball' });
|
||||
expect(resolveLookAt(play, sequence, 'defense-1', 0)).toEqual({ type: 'player', targetId: 'offense-1' });
|
||||
const sample = sampleSequence(play, sequence, 0).samples['offense-1'];
|
||||
expect(sample.lookAtLocation).toEqual({ x: 0, z: 13.25 });
|
||||
expect(distance(sample.lookAtLocation, sequence.tracks[0].startLocation)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('Sequence 편집 시 선택 Action의 위치와 LookAt을 미리 본다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'offense-1', { x: 3, z: 5 }, { lookAt: { type: 'location', x: 4, z: 6 } });
|
||||
const { locations, samples } = sampleEditingSequence(play, 0, 'offense-1', 0);
|
||||
expect(locations['offense-1']).toEqual({ x: 3, z: 5 }); expect(samples['offense-1'].lookAtLocation).toEqual({ x: 4, z: 6 });
|
||||
});
|
||||
|
||||
it('시선은 행동의 실제 대상을 유지하고 카메라가 회전을 보간한다', () => {
|
||||
let play = createInitialPlay(); play = addAction(play, 0, 'offense-1', { x: 0, z: 4 }, { lookAt: { type: 'location', x: 0, z: 8 } });
|
||||
play = addAction(play, 0, 'offense-1', { x: 0, z: 8 }, { lookAt: { type: 'location', x: 4, z: 8 } });
|
||||
const firstDuration = distance(play.sequences[0].tracks[0].startLocation, play.sequences[0].tracks[0].actions[0].location) / 4.5;
|
||||
const target = sampleSequence(play, play.sequences[0], firstDuration / 2).samples['offense-1'].lookAtLocation;
|
||||
expect(target).toEqual({ x: 0, z: 8 });
|
||||
});
|
||||
|
||||
it('Sequence 삭제 후 selectedSequence을 clamp하고 다시 Action을 추가한다', () => {
|
||||
let state = addPlaySequence(createAppState()); state = deletePlaySequence(state);
|
||||
expect(state.selectedSequence).toBe(0); state = addMoveAction(state, { x: 2, z: 4 });
|
||||
expect(state.play.sequences[state.selectedSequence].tracks[0].actions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('동일 XZ POV target은 facing 방향으로 fallback한다', () => {
|
||||
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 100); updatePovCamera(camera, { x: 2, z: 2 }, { x: 2, z: 2 }, Math.PI / 2);
|
||||
const direction = camera.getWorldDirection(new THREE.Vector3()); expect(direction.x).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it('가로 POV camera는 설정된 eye height과 horizontal FOV를 유지한다', () => {
|
||||
const cameras = createCameras(2); expect(cameras.pov.position.y).toBe(POV_EYE_HEIGHT); expect(cameras.pov.fov).toBeCloseTo(2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(HORIZONTAL_FOV) / 2) / 2) * 180 / Math.PI);
|
||||
});
|
||||
|
||||
it('경로 signature은 point 변경을 감지한다', () => {
|
||||
expect(pathSignature([{ x: 0, z: 0 }, { x: 1, z: 2 }])).toBe('0,0|1,2');
|
||||
});
|
||||
|
||||
it('possession halo는 owner와 facing 오른쪽에 공을 두고 flight 중 숨긴다', () => {
|
||||
const held = possessionVisualState({ ballOwnerId: 'offense-1', ballInFlight: false }, { x: 1, z: 2 }, 0); expect(held.haloVisible).toBe(true); expect(held.ballLocation.x).toBeCloseTo(1.62); expect(held.ballLocation.z).toBe(2);
|
||||
const flight = possessionVisualState({ ballOwnerId: 'offense-1', ballInFlight: true, ballLocation: { x: 4, z: 5 } }, { x: 1, z: 2 }, 0); expect(flight.haloVisible).toBe(false); expect(flight.ballLocation).toEqual({ x: 4, z: 5 });
|
||||
const rim = possessionVisualState({ ballOwnerId: null, ballAtRim: true, ballLocation: { x: 0, z: 13.25 } }, null); expect(rim.haloVisible).toBe(false); expect(rim.ballLocation).toEqual({ x: 0, z: 13.25 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { normalizeLookAt, snapLocation } from './domain.js';
|
||||
import { assertValidPlay } from './playRepository.js';
|
||||
|
||||
export function editAction(play, sequenceIndex, playerId, actionIndex, patch) {
|
||||
const original = play.sequences[sequenceIndex]?.tracks.find(track => track.playerId === playerId)?.actions[actionIndex];
|
||||
if (!original || original.type === 'shoot') return play;
|
||||
const next = structuredClone(play);
|
||||
const track = next.sequences[sequenceIndex].tracks.find(track => track.playerId === playerId);
|
||||
const action = track.actions[actionIndex];
|
||||
if (Object.hasOwn(patch, 'lookAt')) {
|
||||
const target = normalizeLookAt(patch.lookAt, playerId, next.players);
|
||||
if (patch.lookAt && !target) return play;
|
||||
action.lookAt = target;
|
||||
}
|
||||
if (patch.location) {
|
||||
if (action.type !== 'move' || !Number.isFinite(patch.location.x) || !Number.isFinite(patch.location.z)) return play;
|
||||
action.location = snapLocation(patch.location);
|
||||
// Stationary passes follow the edited waypoint; later movement remains explicit.
|
||||
for (let i = actionIndex + 1; i < track.actions.length && ['pass', 'shoot'].includes(track.actions[i].type); i += 1) track.actions[i].location = { ...action.location };
|
||||
for (let i = sequenceIndex + 1; i < next.sequences.length; i += 1) {
|
||||
const previous = next.sequences[i - 1].tracks.find(track => track.playerId === playerId);
|
||||
const following = next.sequences[i].tracks.find(track => track.playerId === playerId);
|
||||
following.startLocation = { ...(previous.actions.at(-1)?.location || previous.startLocation) };
|
||||
for (const action of following.actions) { if (!['pass', 'shoot'].includes(action.type)) break; action.location = { ...following.startLocation }; }
|
||||
}
|
||||
}
|
||||
assertValidPlay(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function snapshot(state) {
|
||||
return structuredClone({ play: state.play, selectedSequence: state.selectedSequence, selectedPlayerId: state.selectedPlayerId, selectedAction: state.selectedAction, mode: state.mode });
|
||||
}
|
||||
export function createEditorHistory(limit = 50) {
|
||||
let past = []; let future = [];
|
||||
return {
|
||||
record(before, after) { if (JSON.stringify(before.play) === JSON.stringify(after.play)) return; past.push(snapshot(before)); past = past.slice(-limit); future = []; },
|
||||
undo(state) { if (!past.length) return state; future.push(snapshot(state)); return { ...state, ...past.pop(), playing: false }; },
|
||||
redo(state) { if (!future.length) return state; past.push(snapshot(state)); return { ...state, ...future.pop(), playing: false }; },
|
||||
clear() { past = []; future = []; },
|
||||
get canUndo() { return past.length > 0; },
|
||||
get canRedo() { return future.length > 0; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as THREE from 'three';
|
||||
import { addAction, addPassAction, addShootAction, addSequence, createInitialPlay, PASS_DURATION, RIM_HEIGHT, setPlayerStartLocation } from './domain.js';
|
||||
import { createEditorHistory, editAction } from './editor.js';
|
||||
import { createAppState } from './state.js';
|
||||
import { isValidPlay } from './playRepository.js';
|
||||
import { createPlaybackController, sampleSequence, totalDuration } from './playback.js';
|
||||
import { createCameras, resizeCameras, updatePovCamera, POV_EYE_HEIGHT } from './cameras.js';
|
||||
import { povLabelScaleY } from './scene.js';
|
||||
|
||||
describe('action editor and recovery', () => {
|
||||
it('moving a waypoint updates its stationary pass and following sequence start', () => {
|
||||
let play = addAction(createInitialPlay(), 0, 'offense-1', { x: 1, z: 4 });
|
||||
play = addSequence(addPassAction(play, 0, 'offense-1', 'offense-2'));
|
||||
const edited = editAction(play, 0, 'offense-1', 0, { location: { x: 2.2, z: 6.1 } });
|
||||
expect(edited.sequences[0].tracks[0].actions.map(action => action.location)).toEqual([{ x: 2, z: 6 }, { x: 2, z: 6 }]);
|
||||
expect(edited.sequences[1].tracks[0].startLocation).toEqual({ x: 2, z: 6 });
|
||||
expect(play.sequences[0].tracks[0].actions[0].location).toEqual({ x: 1, z: 4 });
|
||||
expect(isValidPlay(edited)).toBe(true);
|
||||
});
|
||||
it('supports saved movement gaze but rejects self targets and malformed coordinates', () => {
|
||||
const play = addAction(createInitialPlay(), 0, 'offense-1', { x: 1, z: 4 });
|
||||
const edited = editAction(play, 0, 'offense-1', 0, { lookAt: { type: 'movement' } });
|
||||
expect(isValidPlay(edited)).toBe(true);
|
||||
expect(editAction(play, 0, 'offense-1', 0, { lookAt: { type: 'player', targetId: 'offense-1' } })).toBe(play);
|
||||
expect(editAction(play, 0, 'offense-1', 0, { location: { x: NaN, z: 4 } })).toBe(play);
|
||||
});
|
||||
it('global undo recovers starting positions and sequence structure with immutable redo', () => {
|
||||
const history = createEditorHistory(); const initial = createAppState();
|
||||
const moved = { ...initial, play: setPlayerStartLocation(initial.play, 'offense-1', { x: 3, z: 4 }) };
|
||||
history.record(initial, moved);
|
||||
const added = { ...moved, play: addSequence(moved.play), selectedSequence: 1 };
|
||||
history.record(moved, added);
|
||||
const undo = history.undo(added); expect(undo.play.sequences).toHaveLength(1);
|
||||
expect(history.undo(undo).play).toEqual(initial.play);
|
||||
expect(history.redo(initial).play).toEqual(moved.play);
|
||||
history.record(moved, { ...moved, play: setPlayerStartLocation(moved.play, 'offense-2', { x: 0, z: 1 }) });
|
||||
expect(history.canRedo).toBe(false);
|
||||
});
|
||||
it('does not allow the editor to violate terminal shoot invariants', () => {
|
||||
const play = addShootAction(createInitialPlay(), 0, 'offense-1');
|
||||
expect(editAction(play, 0, 'offense-1', 0, { lookAt: { type: 'ball' } })).toBe(play);
|
||||
});
|
||||
it('editing a completed play updates a following shot without deleting it', () => {
|
||||
let play = addAction(createInitialPlay(), 0, 'offense-1', { x: 1, z: 5 });
|
||||
play = addSequence(play); play = addShootAction(play, 1, 'offense-1');
|
||||
const edited = editAction(play, 0, 'offense-1', 0, { location: { x: 2, z: 6 } });
|
||||
expect(edited.sequences[1].tracks[0].actions[0].location).toEqual({ x: 2, z: 6 });
|
||||
expect(isValidPlay(edited)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action gaze and playback tools', () => {
|
||||
it('passer watches the receiver and receiver watches the airborne ball', () => {
|
||||
const play = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2');
|
||||
const sample = sampleSequence(play, play.sequences[0], PASS_DURATION / 2);
|
||||
expect(sample.samples['offense-1'].lookAt).toEqual({ type: 'player', targetId: 'offense-2' });
|
||||
expect(sample.samples['offense-2'].lookAtLocation).toEqual(sample.ballLocation);
|
||||
});
|
||||
it('explicit gaze wins over the automatic pass target', () => {
|
||||
const play = addPassAction(createInitialPlay(), 0, 'offense-1', 'offense-2');
|
||||
const edited = editAction(play, 0, 'offense-1', 0, { lookAt: { type: 'rim' } });
|
||||
expect(sampleSequence(edited, edited.sequences[0], 0.1).samples['offense-1'].lookAt).toEqual({ type: 'rim' });
|
||||
});
|
||||
it('seek clamps, rate applies, looping wraps and replay restarts', () => {
|
||||
const play = addAction(createInitialPlay(), 0, 'offense-1', { x: 0, z: 7 }); const controller = createPlaybackController(play);
|
||||
const duration = totalDuration(play);
|
||||
expect(controller.seek(-1).elapsed).toBe(0); expect(controller.seek(999).elapsed).toBe(duration);
|
||||
controller.play(); controller.setRate(2); expect(controller.tick(0.1).elapsed).toBeCloseTo(0.2);
|
||||
controller.setLoop(true); controller.seek(duration - 0.1); expect(controller.tick(0.1).elapsed).toBeCloseTo(0.1);
|
||||
controller.pause(); expect(controller.tick(1).elapsed).toBeCloseTo(0.1);
|
||||
});
|
||||
it('shot finishes at the physical rim height', () => {
|
||||
const play = addShootAction(createInitialPlay(), 0, 'offense-1');
|
||||
expect(sampleSequence(play, play.sequences[0], totalDuration(play)).ballHeight).toBeCloseTo(RIM_HEIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('responsive POV and camera stability', () => {
|
||||
it('portrait FOV is bounded and tactical camera contains the court width', () => {
|
||||
const cameras = createCameras(); resizeCameras(cameras, 0.5); cameras.tactical.updateMatrixWorld();
|
||||
expect(cameras.pov.fov).toBeLessThanOrEqual(100);
|
||||
const projected = new THREE.Vector3(7.5, 0, 7).project(cameras.tactical);
|
||||
expect(Math.abs(projected.x)).toBeLessThan(1);
|
||||
});
|
||||
it('POV keeps the eye height and action target inside the viewport on desktop and portrait layouts', () => {
|
||||
for (const aspect of [2, 0.5]) {
|
||||
const cameras = createCameras(aspect);
|
||||
resizeCameras(cameras, aspect);
|
||||
updatePovCamera(cameras.pov, { x: 0, z: 8 }, { x: 0, z: 13.25, y: RIM_HEIGHT }, 0);
|
||||
cameras.pov.updateMatrixWorld();
|
||||
const projected = new THREE.Vector3(0, RIM_HEIGHT, 13.25).project(cameras.pov);
|
||||
expect(cameras.pov.position.y).toBe(POV_EYE_HEIGHT);
|
||||
expect(projected.x).toBeGreaterThan(-1);
|
||||
expect(projected.x).toBeLessThan(1);
|
||||
expect(projected.y).toBeGreaterThan(-1);
|
||||
expect(projected.y).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
it('caps nearby POV labels while preserving the smaller base scale at distance', () => {
|
||||
expect(povLabelScaleY(0.5, 844, 100)).toBeLessThan(0.36);
|
||||
expect(povLabelScaleY(100, 844, 100)).toBeCloseTo(0.36);
|
||||
const camera = createCameras(0.5).pov;
|
||||
camera.lookAt(0, 1.5, 10); camera.updateMatrixWorld();
|
||||
const cameraPoint = camera.worldToLocal(new THREE.Vector3(5, 2.08, 5));
|
||||
const near = povLabelScaleY(-cameraPoint.z, 844, camera.fov);
|
||||
const pixels = near * 844 / (2 * -cameraPoint.z * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2));
|
||||
expect(pixels).toBeCloseTo(22);
|
||||
});
|
||||
it('looks up at the rim and caps rotation speed during playback', () => {
|
||||
const camera = createCameras().pov;
|
||||
updatePovCamera(camera, { x: 0, z: 8 }, { x: 0, z: 13.25, y: RIM_HEIGHT });
|
||||
expect(camera.getWorldDirection(new THREE.Vector3()).y).toBeGreaterThan(0);
|
||||
const previous = camera.quaternion.clone();
|
||||
updatePovCamera(camera, { x: 0, z: 8 }, { x: 0, z: 0, y: 1.5 }, 0, { smooth: true, delta: 0.016 });
|
||||
expect(previous.angleTo(camera.quaternion)).toBeLessThanOrEqual(Math.PI * 0.016 + 1e-6);
|
||||
});
|
||||
});
|
||||
+467
@@ -0,0 +1,467 @@
|
||||
import { accountLayout, editorLayout, decorateSequenceButton } from './ui.js';
|
||||
import './style.css';
|
||||
import { createEditorHistory, editAction } from './editor.js';
|
||||
import { addAction, addScreenAction, addShootAction, clampLocation, finalBallOwner, hasShoot, normalizeLookAt, removeAction, scheduledTrackDuration, sequenceDuration, setSequenceBallOwner, snapLocation } from './domain.js';
|
||||
import { createPlaybackController, samplePlay, selectRenderSample, totalDuration } from './playback.js';
|
||||
import { createBoard } from './scene.js';
|
||||
import { createLocalPlayRepository } from './playRepository.js';
|
||||
import { createPlayOperationCoordinator } from './playOperations.js';
|
||||
import { addPlaySequence, commitActionEdit, createAppState, createAppStateFromPlay, deletePlaySequence, serializePlay, setSelectedPlayerStart } from './state.js';
|
||||
import { createVideoExportJob, downloadVideoFile, VideoExportCancelledError, VideoExportStaleError, shareVideoFile, validateVideoFile } from './videoExport.js';
|
||||
import { ApiError, approveUser, createServerPlayRepository, createTeam, getCurrentUser, listPendingUsers, listTeams, loginAccount, logoutAccount, registerAccount } from './api.js';
|
||||
|
||||
const app = document.querySelector('#app');
|
||||
app.innerHTML = accountLayout() + editorLayout();
|
||||
document.querySelector('.shell').hidden = true;
|
||||
|
||||
const board = createBoard(document.querySelector('#board'));
|
||||
let storage = null; try { storage = window.localStorage; } catch { storage = null; }
|
||||
const localPlayRepository = createLocalPlayRepository(storage);
|
||||
let playRepository = localPlayRepository;
|
||||
let currentUser = null;
|
||||
let currentTeam = null;
|
||||
let accountGeneration = 0;
|
||||
const operationCoordinator = createPlayOperationCoordinator();
|
||||
let state = createAppState();
|
||||
const editorHistory = createEditorHistory();
|
||||
let panel = 'court'; let gazePick = null; let suppressClick = false; let drag = null;
|
||||
document.querySelector('#play-name').value = state.play.name;
|
||||
document.querySelector('#defense-type').value = state.play.defenseType;
|
||||
document.querySelector('#save-status').textContent = '팀을 선택하세요';
|
||||
let controller = createPlaybackController(state.play);
|
||||
let playbackSession = false;
|
||||
let resetPreview = false;
|
||||
let lastFrame = performance.now();
|
||||
let savedListGeneration = 0;
|
||||
let passiveListGeneration = 0;
|
||||
let loadGeneration = 0;
|
||||
const VIDEO_WIDTH = 1280;
|
||||
const VIDEO_HEIGHT = 720;
|
||||
const VIDEO_FPS = 30;
|
||||
let videoExportActive = false;
|
||||
let videoJob = null;
|
||||
let preparedVideo = null;
|
||||
let videoPreviewUrl = null;
|
||||
|
||||
function videoSettings() {
|
||||
const view = document.querySelector('#video-view')?.value || 'tactical';
|
||||
return { view, selectedPlayerId: view === 'pov' ? state.selectedPlayerId : null };
|
||||
}
|
||||
function videoKey() {
|
||||
const name = String(document.querySelector('#play-name')?.value || '').trim() || '새 전술';
|
||||
return JSON.stringify({ play: state.play, name, ...videoSettings() });
|
||||
}
|
||||
function setVideoStatus(message) { const element = document.querySelector('#video-status'); if (element) element.textContent = message; }
|
||||
function renderVideoControls() {
|
||||
const exportButton = document.querySelector('#export-video'); const cancelButton = document.querySelector('#cancel-video'); const progress = document.querySelector('#video-progress'); const shareButton = document.querySelector('#share-video'); const downloadButton = document.querySelector('#download-video');
|
||||
if (!exportButton) return;
|
||||
const povOption = document.querySelector('#video-view option[value="pov"]'); const povPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (povOption) povOption.textContent = `선수 시점 · ${povPlayer ? `${povPlayer.team === 'offense' ? 'O' : 'D'}${povPlayer.number}` : '선택 선수'}`;
|
||||
exportButton.disabled = videoExportActive; cancelButton.hidden = !videoExportActive; progress.hidden = !videoExportActive; shareButton.disabled = !preparedVideo || videoExportActive || preparedVideo.key !== videoKey(); downloadButton.disabled = !preparedVideo || videoExportActive || preparedVideo.key !== videoKey();
|
||||
}
|
||||
function clearPreparedVideo(message = '영상 준비 전') {
|
||||
preparedVideo = null;
|
||||
if (videoPreviewUrl) { URL.revokeObjectURL(videoPreviewUrl); videoPreviewUrl = null; }
|
||||
const preview = document.querySelector('#video-preview'); if (preview) { preview.pause?.(); preview.removeAttribute('src'); preview.load?.(); preview.hidden = true; }
|
||||
const progress = document.querySelector('#video-progress'); if (progress) progress.value = 0;
|
||||
setVideoStatus(message); renderVideoControls();
|
||||
}
|
||||
function ensureVideoFresh() {
|
||||
if (preparedVideo && preparedVideo.key !== videoKey()) clearPreparedVideo('전술이 변경되어 MP4를 다시 만들어야 합니다');
|
||||
if (videoJob && videoJob.key !== videoKey()) videoJob.cancel();
|
||||
}
|
||||
function videoFileName(name) {
|
||||
const safe = String(name || 'court-lab-play').trim().replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '-').slice(0, 70) || 'court-lab-play';
|
||||
return `${safe}.mp4`;
|
||||
}
|
||||
async function startVideoExport() {
|
||||
if (videoJob) return;
|
||||
ensureVideoFresh();
|
||||
const play = structuredClone(state.play); play.name = String(document.querySelector('#play-name').value || '').trim() || '새 전술';
|
||||
const settings = videoSettings(); const key = videoKey(); const duration = totalDuration(play);
|
||||
if (duration <= 0) { setVideoStatus('먼저 재생할 이동 행동을 추가하세요'); return; }
|
||||
clearPreparedVideo('MP4 생성 준비 중…');
|
||||
const exportState = { ...state, play, view: settings.view, selectedPlayerId: settings.selectedPlayerId || state.selectedPlayerId, selectedAction: -1, playing: true, mode: 'move' };
|
||||
let surface = null;
|
||||
// Freeze the editor animation loop while the dedicated export surface is
|
||||
// sampled. This keeps the user's current play/pause state and scrubber
|
||||
// position intact when the export finishes.
|
||||
videoExportActive = true; renderVideoControls(); setVideoStatus('MP4 생성 중… 0%');
|
||||
try {
|
||||
surface = board.createExportSurface(VIDEO_WIDTH, VIDEO_HEIGHT);
|
||||
const job = createVideoExportJob({
|
||||
canvas: surface.canvas,
|
||||
duration,
|
||||
fps: VIDEO_FPS,
|
||||
fileName: videoFileName(play.name),
|
||||
isCurrent: () => videoKey() === key,
|
||||
renderFrame: (elapsed) => {
|
||||
if (videoKey() !== key) throw new VideoExportStaleError();
|
||||
surface.render(play, exportState, { ...samplePlay(play, elapsed), playing: true }, 1 / VIDEO_FPS);
|
||||
},
|
||||
onProgress: (progress) => { const element = document.querySelector('#video-progress'); if (element) element.value = progress; setVideoStatus(`MP4 생성 중… ${Math.round(progress * 100)}%`); },
|
||||
});
|
||||
job.key = key; videoJob = job;
|
||||
const result = await job.promise;
|
||||
if (videoKey() !== key) throw new VideoExportStaleError();
|
||||
setVideoStatus('MP4 재생 정보 확인 중…');
|
||||
if (job.signal.aborted) throw new VideoExportCancelledError();
|
||||
const metadata = await validateVideoFile(result.file, { signal: job.signal });
|
||||
if (job.signal.aborted) throw new VideoExportCancelledError();
|
||||
if (videoKey() !== key) throw new VideoExportStaleError();
|
||||
preparedVideo = { file: result.file, key, metadata };
|
||||
videoPreviewUrl = URL.createObjectURL(result.file);
|
||||
const preview = document.querySelector('#video-preview'); preview.src = videoPreviewUrl; preview.hidden = false;
|
||||
setVideoStatus(`MP4 준비 완료 · ${(result.file.size / 1024 / 1024).toFixed(1)}MB · ${metadata.duration ? metadata.duration.toFixed(1) + '초' : duration.toFixed(1) + '초'}`);
|
||||
} catch (error) {
|
||||
if (error instanceof VideoExportCancelledError || error?.code === 'cancelled') setVideoStatus('영상 생성을 취소했습니다');
|
||||
else if (error instanceof VideoExportStaleError || error?.code === 'stale') clearPreparedVideo('전술이 변경되어 영상 생성을 취소했습니다');
|
||||
else { clearPreparedVideo(`영상 생성 실패: ${error.message || error}`); }
|
||||
} finally {
|
||||
surface?.dispose(); board.resize(); videoExportActive = false; videoJob = null; renderVideoControls();
|
||||
}
|
||||
}
|
||||
async function sharePreparedVideo() {
|
||||
ensureVideoFresh();
|
||||
if (!preparedVideo) { setVideoStatus('먼저 MP4 영상을 만들어 준비하세요'); return; }
|
||||
try { await shareVideoFile(preparedVideo.file, { navigatorObject: window.navigator, title: state.play.name || 'court.lab 전술 영상', text: '농구 전술 MP4 영상' }); setVideoStatus('공유창을 열었습니다 · 카카오톡 대화방을 선택해 전송하세요'); }
|
||||
catch (error) { if (error?.name === 'AbortError') setVideoStatus('영상 파일 공유를 취소했습니다'); else setVideoStatus(error.message || '영상 파일 공유를 지원하지 않습니다'); }
|
||||
}
|
||||
|
||||
function showGate(view = 'auth', message = '') {
|
||||
const gate = document.querySelector('#account-gate'); if (!gate) return;
|
||||
gate.hidden = false; document.querySelector('.shell').hidden = view !== 'editor';
|
||||
for (const id of ['auth-panel', 'register-panel', 'pending-panel', 'team-panel']) document.querySelector(`#${id}`).hidden = id !== `${view}-panel`;
|
||||
if (view === 'auth') document.querySelector('#auth-message').textContent = message;
|
||||
if (view === 'register') document.querySelector('#register-message').textContent = message;
|
||||
if (view === 'pending') document.querySelector('#pending-message').textContent = message || '운영자 승인이 완료되면 다시 로그인해 주세요.';
|
||||
if (view === 'teams') { document.querySelector('#team-panel').hidden = false; document.querySelector('#auth-panel').hidden = true; document.querySelector('#register-panel').hidden = true; document.querySelector('#pending-panel').hidden = true; }
|
||||
if (view === 'editor') gate.hidden = true;
|
||||
}
|
||||
function draftKey() { return currentUser && currentTeam ? `basket-utils:draft:v2:${encodeURIComponent(currentUser.id)}:${encodeURIComponent(currentTeam.id)}` : ''; }
|
||||
function restoreTeamDraft() {
|
||||
const key = draftKey(); if (!key || !storage) return '저장 전';
|
||||
try { const raw = storage.getItem(key); if (!raw) return '저장 전'; state = createAppStateFromPlay(JSON.parse(raw)); return '임시 저장 복원됨'; } catch { return '임시 저장을 복원하지 못했습니다'; }
|
||||
}
|
||||
function renderTeamList(teams) {
|
||||
const list = document.querySelector('#team-list'); list.replaceChildren();
|
||||
const importSelect = document.querySelector('#local-import-team'); importSelect.replaceChildren(); const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = teams.length ? '팀을 선택하세요' : '팀을 먼저 만드세요'; importSelect.append(placeholder); importSelect.value = ''; document.querySelector('#import-local-team').disabled = true;
|
||||
if (!teams.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '아직 팀이 없습니다. 첫 팀을 만들어 시작하세요.'; list.append(empty); return; }
|
||||
for (const team of teams) { const option = document.createElement('option'); option.value = team.id; option.textContent = team.name; importSelect.append(option); }
|
||||
for (const team of teams) { const button = document.createElement('button'); button.type = 'button'; button.className = 'team-card'; button.dataset.teamId = team.id; button.dataset.role = team.role; const name = document.createElement('strong'); name.textContent = team.name; const role = document.createElement('small'); role.textContent = team.role === 'owner' ? '소유자' : team.role === 'editor' ? '편집자' : '열람자'; button.append(name, role); list.append(button); }
|
||||
}
|
||||
async function renderPendingUsers(generation = accountGeneration) {
|
||||
const panel = document.querySelector('#operator-panel'); if (!currentUser?.isOperator) { panel.hidden = true; return; }
|
||||
panel.hidden = false; const target = document.querySelector('#pending-users'); target.replaceChildren();
|
||||
try {
|
||||
const { users } = await listPendingUsers(); if (generation !== accountGeneration || !currentUser) return;
|
||||
if (!users.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '승인 대기 사용자가 없습니다.'; target.append(empty); return; }
|
||||
for (const user of users) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${user.displayName} · ${user.email}`; const button = document.createElement('button'); button.type = 'button'; button.dataset.approveUser = user.id; button.textContent = '승인'; row.append(label, button); target.append(row); }
|
||||
} catch (error) { const message = document.createElement('p'); message.className = 'account-empty'; message.textContent = error.message; target.append(message); }
|
||||
}
|
||||
async function openTeamHub(message = '', generation = accountGeneration) {
|
||||
showGate('teams'); document.querySelector('#team-welcome').textContent = `${currentUser?.displayName || currentUser?.email || ''}님, 사용할 팀을 선택하세요.`; document.querySelector('#team-message').textContent = message;
|
||||
try { const { teams } = await listTeams(); if (generation !== accountGeneration || !currentUser) return; renderTeamList(teams); await renderPendingUsers(generation); }
|
||||
catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = `팀 목록을 불러오지 못했습니다: ${error.message}`; }
|
||||
}
|
||||
async function selectTeam(team) {
|
||||
if (!team?.id || !currentUser) return;
|
||||
const generation = ++accountGeneration; operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentTeam = team; playRepository = createServerPlayRepository(team.id); editorHistory.clear(); state = createAppState(); const draftMessage = restoreTeamDraft();
|
||||
controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; document.querySelector('#play-name').value = state.play.name; document.querySelector('#defense-type').value = state.play.defenseType; document.querySelector('#team-context').textContent = team.name; document.querySelector('#save-status').textContent = draftMessage; showGate('editor'); renderUi();
|
||||
try { await refreshSavedPlays(); if (generation !== accountGeneration || currentTeam?.id !== team.id) return; document.querySelector('#status').textContent = draftMessage === '임시 저장 복원됨' ? '전술을 복원했습니다 · 행동을 선택해 편집하세요' : '팀 전술함을 열었습니다'; } catch (error) { if (generation === accountGeneration) document.querySelector('#status').textContent = `저장 목록을 불러오지 못했습니다: ${error.message}`; }
|
||||
}
|
||||
async function importLocalPlays() {
|
||||
const teamAtRequest = document.querySelector('#local-import-team').value; const teamName = document.querySelector('#local-import-team').selectedOptions[0]?.textContent || ''; if (!teamAtRequest) { document.querySelector('#team-message').textContent = '먼저 기기 전술을 가져올 팀을 선택하세요.'; return; } const generation = accountGeneration; const userAtRequest = currentUser?.id; const repository = createServerPlayRepository(teamAtRequest); const button = document.querySelector('#import-local-team'); button.disabled = true;
|
||||
try { const existing = await repository.list(); const existingIds = new Set(existing.map((record) => record.id)); const records = await localPlayRepository.list(); const imports = []; for (const record of records) { const play = await localPlayRepository.get(record.id); if (play) imports.push(play); } try { const legacy = storage?.getItem('basket-utils:draft:v1'); if (legacy) { const play = JSON.parse(legacy); if (isValidPlayForImport(play) && !imports.some((candidate) => candidate.id === play.id)) imports.push(play); } } catch { /* malformed legacy draft stays untouched */ } let imported = 0; let skipped = 0; for (const play of imports) { if (generation !== accountGeneration || currentUser?.id !== userAtRequest) return; if (existingIds.has(play.id)) { skipped += 1; continue; } await repository.save(play); existingIds.add(play.id); imported += 1; } document.querySelector('#team-message').textContent = imports.length ? `${imported}개 전술을 ${teamName} 팀으로 가져왔습니다. ${skipped ? `${skipped}개는 이미 있어 건너뛰었습니다. ` : ''}이 기기의 원본은 유지됩니다.` : '이 기기에 가져올 전술이 없습니다.'; }
|
||||
catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = `기기 전술 가져오기 실패: ${error.message}`; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
async function initializeAccount() {
|
||||
const generation = accountGeneration;
|
||||
try { const result = await getCurrentUser(); if (generation !== accountGeneration) return; currentUser = result.user; await openTeamHub('', generation); }
|
||||
catch (error) { if (generation !== accountGeneration) return; if (!(error instanceof ApiError) || error.status !== 401) document.querySelector('#auth-message').textContent = '서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.'; showGate('auth', document.querySelector('#auth-message').textContent); }
|
||||
}
|
||||
function isValidPlayForImport(play) { try { return Boolean(play && Array.isArray(play.players) && Array.isArray(play.sequences) && createAppStateFromPlay(play)); } catch { return false; } }
|
||||
async function logoutFromUi() {
|
||||
const generation = ++accountGeneration; videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentUser = null; currentTeam = null; playRepository = localPlayRepository; operationCoordinator.beginIntent(); document.querySelector('.shell').hidden = true; showGate('auth', '로그아웃 중…'); document.querySelectorAll('#login-form button,#show-register').forEach((control) => { control.disabled = true; });
|
||||
try { await logoutAccount(); if (generation === accountGeneration) showGate('auth'); }
|
||||
catch (error) { if (generation === accountGeneration) document.querySelector('#auth-message').textContent = `로그아웃 요청을 완료하지 못했습니다: ${error.message}`; }
|
||||
finally { if (generation === accountGeneration) document.querySelectorAll('#login-form button,#show-register').forEach((control) => { control.disabled = false; }); }
|
||||
}
|
||||
function downloadPreparedVideo() {
|
||||
ensureVideoFresh();
|
||||
if (!preparedVideo) { setVideoStatus('먼저 MP4 영상을 만들어 준비하세요'); return; }
|
||||
try { downloadVideoFile(preparedVideo.file, { fileName: preparedVideo.file.name }); setVideoStatus('MP4 다운로드를 시작했습니다 · 카카오톡에 직접 첨부할 수 있습니다'); }
|
||||
catch (error) { setVideoStatus(error.message || '영상 다운로드를 준비하지 못했습니다'); }
|
||||
}
|
||||
|
||||
async function refreshSavedPlays(selectedId = '', canSelect = () => true, options = {}) {
|
||||
const repository = playRepository; const userAtRequest = currentUser?.id; const teamAtRequest = currentTeam?.id; const nonInvasive = options.nonInvasive === true; const generation = nonInvasive ? ++passiveListGeneration : ++savedListGeneration; const activeGenerationAtStart = savedListGeneration; const isCurrent = () => nonInvasive ? generation === passiveListGeneration && activeGenerationAtStart === savedListGeneration && currentUser?.id === userAtRequest && currentTeam?.id === teamAtRequest && playRepository === repository : generation === savedListGeneration && currentUser?.id === userAtRequest && currentTeam?.id === teamAtRequest && playRepository === repository; const select = document.querySelector('#saved-plays'); const load = document.querySelector('#load-play');
|
||||
try {
|
||||
const records = await repository.list(); if (!isCurrent()) return false; const preservedSelection = nonInvasive ? select.value : ''; const preservedLoadDisabled = nonInvasive ? load.disabled : false; select.replaceChildren(); const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.disabled = true; placeholder.selected = nonInvasive ? !preservedSelection : !selectedId; placeholder.textContent = records.length ? '저장된 전술 선택' : '저장된 전술 없음'; select.append(placeholder);
|
||||
for (const record of records) { const option = document.createElement('option'); option.value = record.id; option.textContent = `${record.name} · ${new Date(record.updatedAt).toLocaleString()}`; select.append(option); }
|
||||
if (nonInvasive) { if (preservedSelection && records.some((record) => record.id === preservedSelection)) select.value = preservedSelection; load.disabled = preservedLoadDisabled; } else { if (selectedId && canSelect() && records.some((record) => record.id === selectedId)) select.value = selectedId; load.disabled = !select.value; } return true;
|
||||
} catch (error) { if (!isCurrent() || nonInvasive) return false; select.replaceChildren(); const option = document.createElement('option'); option.value = ''; option.disabled = true; option.selected = true; option.textContent = '저장 목록을 사용할 수 없음'; select.append(option); load.disabled = true; throw error; }
|
||||
}
|
||||
async function saveCurrentPlay() {
|
||||
const token = operationCoordinator.beginIntent(); const repository = playRepository; const teamAtRequest = currentTeam?.id;
|
||||
const playAtRequest = state.play; const nameAtRequest = String(document.querySelector('#play-name').value || '').trim() || '새 전술'; const nextPlay = structuredClone(playAtRequest); nextPlay.name = nameAtRequest;
|
||||
const isSaveStillRelevant = () => operationCoordinator.isCurrent(token) && currentTeam?.id === teamAtRequest && state.play === playAtRequest && (String(document.querySelector('#play-name').value || '').trim() || '새 전술') === nameAtRequest;
|
||||
try { await operationCoordinator.enqueueSave(nextPlay.id, () => repository.save(nextPlay)); } catch (error) { if (isSaveStillRelevant() && currentTeam?.id === teamAtRequest) document.querySelector('#status').textContent = `전술 저장 실패: ${error.message}`; return; }
|
||||
if (!isSaveStillRelevant()) { try { await refreshSavedPlays('', () => true, { nonInvasive: true }); } catch { /* stale save must not alter current UI */ } return; }
|
||||
state.play.name = nextPlay.name; document.querySelector('#play-name').value = state.play.name; renderUi();
|
||||
try { await refreshSavedPlays(isSaveStillRelevant() ? nextPlay.id : '', isSaveStillRelevant); if (isSaveStillRelevant()) document.querySelector('#status').textContent = '전술을 저장했습니다'; } catch (error) { if (isSaveStillRelevant()) document.querySelector('#status').textContent = `전술은 저장했지만 목록 갱신에 실패했습니다: ${error.message}`; }
|
||||
}
|
||||
async function loadSelectedPlay() {
|
||||
const id = document.querySelector('#saved-plays').value; if (!id) return; const repository = playRepository; const teamAtRequest = currentTeam?.id; const token = operationCoordinator.beginIntent(); const generation = ++loadGeneration; const playAtRequest = state.play; const nameAtRequest = String(document.querySelector('#play-name').value || '').trim() || '새 전술';
|
||||
const isLoadStillRelevant = () => operationCoordinator.isCurrent(token) && generation === loadGeneration && state.play === playAtRequest && (String(document.querySelector('#play-name').value || '').trim() || '새 전술') === nameAtRequest;
|
||||
try { const play = await repository.get(id); if (!isLoadStillRelevant() || currentTeam?.id !== teamAtRequest) return; if (!play) { document.querySelector('#status').textContent = '저장된 전술을 찾을 수 없습니다'; return; } const nextState = createAppStateFromPlay(play); if (!isLoadStillRelevant()) return; editorHistory.clear(); state = nextState; document.querySelector('#play-name').value = state.play.name; document.querySelector('#defense-type').value = state.play.defenseType || 'man-to-man'; saveDraft(); controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; renderUi(); document.querySelector('#status').textContent = '저장된 전술을 불러왔습니다'; } catch (error) { if (isLoadStillRelevant() && currentTeam?.id === teamAtRequest) document.querySelector('#status').textContent = `전술 불러오기 실패: ${error.message}`; }
|
||||
}
|
||||
|
||||
function selectedTrack() { return state.play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId); }
|
||||
function setState(next, status = '', record = true) {
|
||||
gazePick = null;
|
||||
if (record) editorHistory.record(state, next);
|
||||
if (next.play !== state.play) operationCoordinator.beginIntent();
|
||||
state = next;
|
||||
saveDraft();
|
||||
controller = createPlaybackController(state.play);
|
||||
playbackSession = false;
|
||||
resetPreview = false;
|
||||
if (status) document.querySelector('#status').textContent = status;
|
||||
renderUi();
|
||||
}
|
||||
function updatePlay(play, status, selectedAction = -1) { setState({ ...commitActionEdit(state, play), selectedAction }, status); }
|
||||
function addPassForTarget(targetId) {
|
||||
const sequence = state.play.sequences[state.selectedSequence]; const ownerId = finalBallOwner(sequence); const play = addAction(state.play, state.selectedSequence, ownerId, null, { type: 'pass', targetPlayerId: targetId });
|
||||
if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '패스는 현재 공 소유 공격 선수 → 다른 공격 선수만 가능합니다';
|
||||
else { state = { ...state, selectedPlayerId: ownerId, mode: 'move' }; const track = play.sequences[state.selectedSequence].tracks.find(track => track.playerId === ownerId); updatePlay(play, '패스 행동 추가', track.actions.length - 1); }
|
||||
}
|
||||
function addScreenForTarget(targetId) {
|
||||
const screener = state.play.players.find((player) => player.id === state.selectedPlayerId); const target = state.play.players.find((player) => player.id === targetId); const sequence = state.play.sequences[state.selectedSequence];
|
||||
if (!screener || screener.team !== 'offense' || finalBallOwner(sequence) === screener.id) { document.querySelector('#status').textContent = '스크린은 공을 가지지 않은 공격 선수만 가능합니다'; return; }
|
||||
if (!target || target.team !== 'defense') { document.querySelector('#status').textContent = '스크린 대상은 수비 선수여야 합니다'; return; }
|
||||
const play = addScreenAction(state.play, state.selectedSequence, screener.id, target.id);
|
||||
if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '스크린 대상과 너무 가까워 배치할 수 없습니다'; else { state = { ...state, mode: 'move' }; const track = play.sequences[state.selectedSequence].tracks.find(track => track.playerId === screener.id); updatePlay(play, '스크린 행동 추가', track.actions.length - 1); }
|
||||
}
|
||||
function addShootForOwner() {
|
||||
const sequenceIndex = state.selectedSequence; if (state.mode === 'start' || sequenceIndex !== state.play.sequences.length - 1) { document.querySelector('#status').textContent = '슛은 마지막 단계에서만 가능합니다'; return; }
|
||||
const ownerId = finalBallOwner(state.play.sequences[sequenceIndex]); const owner = state.play.players.find((player) => player.id === ownerId);
|
||||
if (!owner || owner.team !== 'offense') { document.querySelector('#status').textContent = '슛을 시도할 공 소유 공격 선수가 없습니다'; return; }
|
||||
const workingState = { ...state, mode: 'move', selectedSequence: sequenceIndex, selectedPlayerId: ownerId, selectedAction: -1 }; const play = addShootAction(state.play, sequenceIndex, ownerId);
|
||||
if (play === state.play) { state = workingState; renderUi(); document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동은 전술당 1개만 가능합니다' : '슛은 마지막 단계의 공 소유 선수만 가능합니다'; return; }
|
||||
state = workingState; const track = play.sequences[sequenceIndex].tracks.find((candidate) => candidate.playerId === ownerId); updatePlay(play, '슛 행동 추가', track.actions.length - 1);
|
||||
}
|
||||
|
||||
function renderUi() {
|
||||
const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId);
|
||||
const sequence = state.play.sequences[state.selectedSequence] || state.play.sequences[0];
|
||||
const playHasShoot = hasShoot(state.play);
|
||||
const startingOwner = state.play.players.find((player) => player.id === sequence?.ballOwnerId);
|
||||
const finalOwnerId = finalBallOwner(sequence); const finalOwner = state.play.players.find((player) => player.id === finalOwnerId); const hasPass = finalOwnerId && finalOwnerId !== sequence?.ballOwnerId;
|
||||
document.querySelector('#canvas-step').textContent = String(state.selectedSequence + 1).padStart(2, '0');
|
||||
if (currentTeam) document.querySelector('#team-context').textContent = currentTeam.name;
|
||||
document.querySelector('#open-team-hub').textContent = currentTeam?.name || '팀 전환';
|
||||
document.querySelector('#selected-label').textContent = selectedPlayer ? `${selectedPlayer.team === 'offense' ? 'O' : 'D'}${selectedPlayer.number}` : '';
|
||||
document.querySelector('#roster').innerHTML = ['offense', 'defense'].map((team) => `<div class="team-label">${team === 'offense' ? 'OFFENSE' : 'DEFENSE'}</div>${state.play.players.filter((player) => player.team === team).map((player) => { const startMark = hasPass && player.id === sequence?.ballOwnerId ? '<span class="ball-mark">START</span>' : ''; const finalMark = player.id === finalOwnerId ? '<span class="ball-mark">BALL</span>' : ''; return `<button class="roster-player ${player.id === state.selectedPlayerId ? 'selected' : ''} ${player.id === finalOwnerId ? 'owns-ball' : ''}" data-player="${player.id}"><span class="dot ${team}"></span>${team === 'offense' ? 'O' : 'D'}${player.number}${startMark}${finalMark}<small>${player.location.x.toFixed(1)}, ${player.location.z.toFixed(1)}</small></button>`; }).join('')}`).join('');
|
||||
document.querySelector('#ball-owner-label').textContent = hasPass ? `시작 공 소유자: ${startingOwner ? `O${startingOwner.number}` : '없음'} · 패스 후: ${finalOwner ? `O${finalOwner.number}` : '없음'}` : `시작 공 소유자: ${startingOwner ? `O${startingOwner.number}` : '없음'}`;
|
||||
const ballOwnerButton = document.querySelector('#set-ball-owner'); ballOwnerButton.disabled = playHasShoot || selectedPlayer?.team !== 'offense' || selectedPlayer?.id === sequence?.ballOwnerId; ballOwnerButton.textContent = selectedPlayer?.id === sequence?.ballOwnerId ? '현재 시작 공 소유자' : '선택 선수를 시작 공 소유자로';
|
||||
const helpCopy = playHasShoot
|
||||
? '<strong>슛</strong><br />슛이 마지막 위치에 추가되었습니다. 기존 이동과 시선은 행동 패널에서 수정할 수 있습니다. 새 행동을 추가하려면 슛을 삭제하세요.'
|
||||
: state.mode === 'start'
|
||||
? '<strong>시작 위치 설정</strong><br />선수를 고른 뒤 코트를 클릭해 시작 위치를 배치하세요.<br />이 단계의 클릭은 행동으로 저장되지 않습니다.<br /><b>배치가 끝나면 이동를 누르세요.</b>'
|
||||
: state.mode === 'move'
|
||||
? '<strong>이동 경로 설정</strong><br />선수를 고른 뒤 코트를 연속 클릭해 행동을 만드세요.<br />시작 위치를 바꾸려면 시작 배치로 돌아가세요.'
|
||||
: state.mode === 'pass' ? '<strong>패스</strong><br />현재 공 소유자는 자동 선택됩니다. 공격 선수를 클릭해 수신자를 고르세요.'
|
||||
: state.mode === 'screen' ? '<strong>스크린</strong><br />볼을 소유하지 않은 공격 선수를 고른 뒤 수비 선수를 클릭하세요.'
|
||||
: '<strong>시선 설정</strong><br />행동을 고른 뒤 선수나 코트를 클릭하면 시선이 저장됩니다.';
|
||||
document.querySelector('#help-copy').innerHTML = helpCopy;
|
||||
const sequenceNav = document.querySelector('#sequences'); sequenceNav.replaceChildren(); state.play.sequences.forEach((sequence, index) => { const button = document.createElement('button'); button.className = `sequence-tab ${index === state.selectedSequence ? 'selected' : ''}`; button.dataset.sequence = String(index); decorateSequenceButton(button, sequence, index, state.play.players); sequenceNav.append(button); }); const addSequenceButton = document.createElement('button'); addSequenceButton.id = 'add-sequence'; addSequenceButton.className = 'add-sequence'; addSequenceButton.textContent = '+'; sequenceNav.append(addSequenceButton); const deleteSequenceButton = document.createElement('button'); deleteSequenceButton.id = 'delete-sequence'; deleteSequenceButton.className = 'danger'; deleteSequenceButton.textContent = '삭제'; sequenceNav.append(deleteSequenceButton);
|
||||
const track = selectedTrack();
|
||||
document.querySelector('#track-duration').textContent = track ? `${track.actions.length}개 · ${scheduledTrackDuration(sequence, track, 4.5, state.play.players).toFixed(1)}s` : '';
|
||||
const emptyCopy = state.mode === 'start' ? '시작 위치를 설정 중입니다.<br />이 단계에서는 행동이 생성되지 않습니다.' : state.mode === 'screen' ? '아직 스크린 행동이 없습니다.<br />수비 선수를 선택해 Screen을 만드세요.' : '아직 행동이 없습니다.<br />코트 위를 클릭해 경로를 만드세요.';
|
||||
document.querySelector('#actions').innerHTML = track?.actions.length ? track.actions.map((action, index) => { const target = state.play.players.find((player) => player.id === action.targetPlayerId); return `<button class="action-row ${index === state.selectedAction ? 'selected' : ''}" data-action="${index}"><span>${index + 1}</span><b>${({ move: '이동', pass: '패스', screen: '스크린', shoot: '슛' }[action.type])}${target ? ` → ${target.team === 'offense' ? 'O' : 'D'}${target.number}` : ''}</b><small>${action.location.x.toFixed(1)}, ${action.location.z.toFixed(1)}${action.lookAt ? ` · ${{ ball: '공', rim: '림', player: '선수', location: '지점', movement: '이동 방향' }[action.lookAt.type]}` : ''}</small></button>`; }).join('') : `<div class="empty">${emptyCopy}</div>`;
|
||||
const canUndo = editorHistory.canUndo; const canRedo = editorHistory.canRedo;
|
||||
document.querySelector('#delete-action').disabled = state.mode === 'start' || state.selectedAction < 0;
|
||||
const shootButton = document.querySelector('#shoot-action'); shootButton.hidden = selectedPlayer?.id !== finalOwnerId;
|
||||
shootButton.disabled = playHasShoot || state.mode === 'start' || state.selectedSequence !== state.play.sequences.length - 1;
|
||||
document.querySelector('#add-sequence').disabled = playHasShoot;
|
||||
document.querySelector('#delete-sequence').disabled = playHasShoot;
|
||||
document.querySelectorAll('[data-mode]').forEach((button) => { button.disabled = playHasShoot; });
|
||||
document.querySelector('#undo').disabled = !canUndo; document.querySelector('#redo').disabled = !canRedo;
|
||||
document.querySelectorAll('[data-mode]').forEach((button) => button.classList.toggle('active', button.dataset.mode === state.mode));
|
||||
document.querySelectorAll('[data-sequence]').forEach((button) => button.classList.toggle('selected', Number(button.dataset.sequence) === state.selectedSequence));
|
||||
document.querySelector('#tactical').classList.toggle('active', state.view === 'tactical'); document.querySelector('#pov').classList.toggle('active', state.view === 'pov');
|
||||
const action = track?.actions[state.selectedAction];
|
||||
document.querySelector('#action-properties').hidden = !action;
|
||||
document.querySelector('#delete-action').hidden = !action;
|
||||
document.querySelector('#action-properties').disabled = !action || action.type === 'shoot';
|
||||
document.querySelector('#gaze').value = action?.lookAt?.type || 'auto';
|
||||
document.querySelector('#cancel-gaze').hidden = !gazePick;
|
||||
document.querySelector('#gaze-description').textContent = playHasShoot ? '기존 이동·시선은 수정할 수 있습니다. 슛의 시선은 림으로 고정됩니다.' : gazePick ? '코트에서 시선 대상을 선택하세요. Esc로 취소합니다.' : action ? (action.lookAt?.type === 'player' ? '선택 대상: ' + action.lookAt.targetId.replace('offense-', 'O').replace('defense-', 'D') : '자동 시선은 행동과 공 소유 상태를 따릅니다.') : '행동을 선택하세요.';
|
||||
for (const axis of ['x', 'z']) { const input = document.querySelector('#action-' + axis); input.value = action?.location[axis] ?? ''; input.disabled = action?.type !== 'move'; }
|
||||
document.querySelector('#apply-position').disabled = action?.type !== 'move';
|
||||
document.querySelector('#scrubber').max = totalDuration(state.play);
|
||||
document.querySelector('#data-preview').textContent = serializePlay(state.play);
|
||||
document.querySelector('.shell').dataset.panel = panel;
|
||||
document.querySelectorAll('button[data-panel]').forEach(button => { const active = button.dataset.panel === panel; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); });
|
||||
ensureVideoFresh(); renderVideoControls();
|
||||
}
|
||||
|
||||
app.addEventListener('submit', async (event) => {
|
||||
event.preventDefault(); const form = event.target;
|
||||
if (form.id === 'login-form') {
|
||||
const generation = ++accountGeneration; const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true;
|
||||
try { const result = await loginAccount(data.get('email'), data.get('password')); if (generation !== accountGeneration) return; currentUser = result.user; form.reset(); await openTeamHub('', generation); }
|
||||
catch (error) { if (generation === accountGeneration) showGate(error.code === 'approval_required' ? 'pending' : 'auth', error.message); }
|
||||
finally { button.disabled = false; }
|
||||
} else if (form.id === 'register-form') {
|
||||
const generation = accountGeneration; const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true;
|
||||
try { const result = await registerAccount(data.get('email'), data.get('password'), data.get('displayName')); if (generation !== accountGeneration) return; form.reset(); showGate('pending', result.message); }
|
||||
catch (error) { if (generation === accountGeneration) showGate('register', error.message); }
|
||||
finally { button.disabled = false; }
|
||||
} else if (form.id === 'team-form') {
|
||||
const generation = accountGeneration; const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true;
|
||||
try { const result = await createTeam(data.get('name')); if (generation !== accountGeneration) return; form.reset(); await selectTeam(result.team); }
|
||||
catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = error.message; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
});
|
||||
|
||||
app.addEventListener('click', (event) => {
|
||||
if (event.target.closest('#show-register')) { showGate('register'); return; }
|
||||
if (event.target.closest('#show-login') || event.target.closest('#pending-login')) { showGate('auth'); return; }
|
||||
if (event.target.closest('#team-logout') || event.target.closest('#logout')) { logoutFromUi(); return; }
|
||||
const teamCard = event.target.closest('[data-team-id]'); if (teamCard) { const team = { id: teamCard.dataset.teamId, name: teamCard.querySelector('strong')?.textContent || '', role: teamCard.dataset.role || 'viewer' }; selectTeam(team); return; }
|
||||
if (event.target.closest('#import-local-team')) { importLocalPlays(); return; }
|
||||
if (event.target.closest('[data-approve-user]')) { const generation = accountGeneration; approveUser(event.target.closest('[data-approve-user]').dataset.approveUser).then(() => { if (generation === accountGeneration) return renderPendingUsers(generation); }).catch((error) => { if (generation === accountGeneration) document.querySelector('#team-message').textContent = error.message; }); return; }
|
||||
if (event.target.closest('#open-team-hub')) { operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); const generation = ++accountGeneration; document.querySelector('.shell').hidden = true; openTeamHub('', generation); return; }
|
||||
if (event.target.closest('#video-share-open')) { const menu = document.querySelector('#project-menu'); menu.dataset.view = 'video'; menu.hidden = false; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'true'); document.querySelector('#export-video').focus(); return; }
|
||||
if (event.target.closest('#close-video-tools')) { const menu = document.querySelector('#project-menu'); menu.hidden = true; delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false'); document.querySelector('#video-share-open').focus(); return; }
|
||||
if (event.target.closest('#toggle-menu')) { const menu = document.querySelector('#project-menu'); const open = menu.hidden; menu.hidden = !open; if (open) delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', String(open)); return; }
|
||||
if (event.target.closest('#cancel-gaze')) { gazePick = null; renderUi(); document.querySelector('#status').textContent = '시선 선택 취소'; return; }
|
||||
const panelButton = event.target.closest('button[data-panel]'); if (panelButton) { panel = panelButton.dataset.panel; renderUi(); return; }
|
||||
if (event.target.closest('#apply-position')) { const x = Number(document.querySelector('#action-x').value); const z = Number(document.querySelector('#action-z').value); updatePlay(editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { location: { x, z } }), '이동 위치 수정', state.selectedAction); return; }
|
||||
if (event.target.closest('#loop')) { const button = document.querySelector('#loop'); const enabled = button.getAttribute('aria-pressed') !== 'true'; button.setAttribute('aria-pressed', String(enabled)); controller.setLoop(enabled); return; }
|
||||
if (event.target.closest('#previous-step') || event.target.closest('#next-step')) { const current = controller.sample().sequenceIndex; const next = Math.max(0, Math.min(state.play.sequences.length - 1, current + (event.target.closest('#next-step') ? 1 : -1))); seekTo(state.play.sequences.slice(0, next).reduce((time, sequence) => time + sequenceDuration(sequence, 4.5, state.play.players), 0)); return; }
|
||||
if (event.target.closest('#export-play')) { const play = structuredClone(state.play); play.name = document.querySelector('#play-name').value.trim() || '새 전술'; const url = URL.createObjectURL(new Blob([JSON.stringify(play, null, 2)], { type: 'application/json' })); const a = document.createElement('a'); a.href = url; a.download = 'court-lab.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); return; }
|
||||
if (event.target.closest('#export-video')) { startVideoExport(); return; }
|
||||
if (event.target.closest('#cancel-video')) { videoJob?.cancel(); return; }
|
||||
if (event.target.closest('#share-video')) { sharePreparedVideo(); return; }
|
||||
if (event.target.closest('#download-video')) { downloadPreparedVideo(); return; }
|
||||
if (event.target.closest('#import-play')) { document.querySelector('#import-file').click(); return; }
|
||||
if (event.target.closest('#save-play')) { saveCurrentPlay(); return; }
|
||||
if (event.target.closest('#load-play')) { loadSelectedPlay(); return; }
|
||||
if (event.target.closest('#shoot-action')) { addShootForOwner(); return; }
|
||||
if (event.target.closest('#undo')) { const next = editorHistory.undo(state); if (next !== state) setState(next, '실행 취소', false); return; }
|
||||
if (event.target.closest('#redo')) { const next = editorHistory.redo(state); if (next !== state) setState(next, '다시 실행', false); return; }
|
||||
if (event.target.closest('#set-ball-owner')) { const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (selectedPlayer?.team === 'offense') { const play = setSequenceBallOwner(state.play, state.selectedSequence, selectedPlayer.id); if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '먼저 패스 행동을 삭제하세요'; else setState({ ...state, play }, `Sequence ${state.selectedSequence + 1} 공 소유자 O${selectedPlayer.number}`); } return; }
|
||||
const playerButton = event.target.closest('[data-player]');
|
||||
if (playerButton) { const playerId = playerButton.dataset.player; if (gazePick) { if (gazePick === 'player') applyGaze({ type: 'player', targetId: playerId }); return; } if (state.mode === 'pass') addPassForTarget(playerId); else if (state.mode === 'screen') addScreenForTarget(playerId); else if (state.mode === 'lookAt' && state.selectedAction >= 0) { const play = structuredClone(state.play); const action = play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId)?.actions[state.selectedAction]; const lookAt = normalizeLookAt({ type: 'player', targetId: playerId }, state.selectedPlayerId, play.players); if (action && lookAt) { action.lookAt = lookAt; updatePlay(play, '선수 대상 시선 저장', state.selectedAction); } } else { playbackSession = false; resetPreview = false; state = { ...state, selectedPlayerId: playerId, selectedAction: -1 }; renderUi(); } return; }
|
||||
const sequenceButton = event.target.closest('[data-sequence]');
|
||||
if (sequenceButton) { gazePick = null; const selectedSequence = Number(sequenceButton.dataset.sequence); state = { ...state, selectedSequence, selectedAction: -1, mode: state.mode === 'start' && selectedSequence > 0 ? 'move' : state.mode }; controller.reset(); playbackSession = false; resetPreview = false; renderUi(); return; }
|
||||
const actionButton = event.target.closest('[data-action]');
|
||||
if (actionButton) { gazePick = null; playbackSession = false; resetPreview = false; state = { ...state, selectedAction: Number(actionButton.dataset.action) }; renderUi(); return; }
|
||||
const modeButton = event.target.closest('[data-mode]'); if (modeButton) { gazePick = null; const mode = modeButton.dataset.mode; if (mode === 'pass') { const ownerId = finalBallOwner(state.play.sequences[state.selectedSequence]); if (!ownerId) { document.querySelector('#status').textContent = '현재 단계의 공 소유 선수가 없습니다'; return; } state = { ...state, selectedPlayerId: ownerId }; } if (mode === 'screen') { const screener = state.play.players.find((player) => player.id === state.selectedPlayerId); if (!screener || screener.team !== 'offense' || finalBallOwner(state.play.sequences[state.selectedSequence]) === screener.id) { document.querySelector('#status').textContent = '먼저 공을 가지지 않은 공격 선수를 선택하세요'; return; } } playbackSession = false; resetPreview = false; state = { ...state, mode, selectedSequence: mode === 'start' ? 0 : state.selectedSequence, selectedAction: -1 }; document.querySelector('#status').textContent = mode === 'start' ? '시작 위치 설정 단계 · 행동은 생성되지 않습니다' : mode === 'move' ? '이동 행동 편집 단계' : mode === 'pass' ? '패스 대상 선택 단계 · 현재 공 소유자 자동 선택됨' : mode === 'screen' ? '스크린 수비 대상 선택 단계' : 'LookAt 편집 단계'; renderUi(); return; }
|
||||
if (event.target.closest('#add-sequence')) { if (state.mode === 'start') { document.querySelector('#status').textContent = '먼저 이동를 눌러 시작 위치를 확정하세요'; return; } const next = addPlaySequence(state); if (next.play === state.play) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; else setState(next, '새 단계가 이전 마지막 위치를 상속했습니다'); return; }
|
||||
if (event.target.closest('#delete-sequence')) { const next = deletePlaySequence(state); if (next.play !== state.play) setState(next, '단계 삭제'); return; }
|
||||
if (event.target.closest('#delete-action')) { const play = removeAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction); if (play === state.play && hasShoot(state.play)) { document.querySelector('#status').textContent = '슛 행동을 선택해 삭제하세요'; return; } updatePlay(play, '행동 삭제'); return; }
|
||||
if (event.target.closest('#play')) { if (state.mode === 'start') { document.querySelector('#status').textContent = '먼저 이동를 눌러 시작 위치를 확정하세요'; return; } gazePick = null; panel = 'court'; renderUi(); resetPreview = false; controller.setRate(document.querySelector('#playback-rate').value); controller.setLoop(document.querySelector('#loop').getAttribute('aria-pressed') === 'true'); controller.play(); playbackSession = controller.isPlaying(); state = { ...state, playing: controller.isPlaying() }; document.querySelector('#status').textContent = controller.isPlaying() ? '재생 중' : '재생할 이동이 없습니다'; return; }
|
||||
if (event.target.closest('#pause')) { controller.pause(); state = { ...state, playing: false }; document.querySelector('#status').textContent = '일시정지'; return; }
|
||||
if (event.target.closest('#reset')) { controller.reset(); playbackSession = false; resetPreview = true; state = { ...state, playing: false, selectedSequence: 0, selectedAction: -1 }; document.querySelector('#status').textContent = '처음 위치로 복귀'; return; }
|
||||
if (event.target.closest('#tactical')) { state = { ...state, view: 'tactical' }; renderUi(); return; }
|
||||
if (event.target.closest('#pov')) { panel = 'court'; gazePick = null; state = { ...state, view: 'pov' }; renderUi(); return; }
|
||||
if (event.target.closest('#new-play')) { operationCoordinator.beginIntent(); editorHistory.clear(); const name = document.querySelector('#play-name').value; const defenseType = document.querySelector('#defense-type').value; state = { ...createAppState(name, defenseType), selectedPlayerId: 'offense-1' }; controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; saveDraft(); document.querySelector('#status').textContent = '시작 배치 단계'; renderUi(); return; }
|
||||
});
|
||||
|
||||
app.addEventListener('change', (event) => { if (event.target.id === 'saved-plays') document.querySelector('#load-play').disabled = !event.target.value; if (event.target.id === 'local-import-team') document.querySelector('#import-local-team').disabled = !event.target.value; });
|
||||
app.addEventListener('input', (event) => { if (event.target.id === 'play-name') { operationCoordinator.beginIntent(); saveDraft(); ensureVideoFresh(); renderVideoControls(); } if (event.target.id === 'scrubber') seekTo(event.target.value); });
|
||||
app.addEventListener('change', async (event) => {
|
||||
if (event.target.id === 'video-view') { ensureVideoFresh(); renderVideoControls(); return; }
|
||||
if (event.target.id === 'gaze') { const type = event.target.value; if (type === 'player' || type === 'location') { gazePick = type; state.mode = 'move'; panel = 'court'; renderUi(); document.querySelector('#status').textContent = type === 'player' ? '시선으로 따라갈 선수를 선택하세요' : '바라볼 코트 지점을 선택하세요'; } else applyGaze(type === 'auto' ? null : { type }); }
|
||||
if (event.target.id === 'playback-rate') controller.setRate(event.target.value);
|
||||
if (event.target.id === 'import-file') {
|
||||
const file = event.target.files[0]; event.target.value = ''; if (!file) return;
|
||||
const token = operationCoordinator.beginIntent(); const previousPlay = state.play;
|
||||
try { if (file.size > 2_000_000) throw new Error('2MB 이하의 전술 파일을 선택하세요'); const imported = createAppStateFromPlay(JSON.parse(await file.text())); if (!operationCoordinator.isCurrent(token) || state.play !== previousPlay) return; editorHistory.clear(); document.querySelector('#play-name').value = imported.play.name; document.querySelector('#defense-type').value = imported.play.defenseType; setState(imported, '전술 파일을 가져왔습니다', false); } catch (error) { if (operationCoordinator.isCurrent(token)) document.querySelector('#status').textContent = '가져오기 실패: ' + error.message; }
|
||||
}
|
||||
});
|
||||
function saveDraft() {
|
||||
try { const key = draftKey(); if (!storage || !key) throw new Error(); const play = structuredClone(state.play); play.name = document.querySelector('#play-name').value.trim() || '새 전술'; storage.setItem(key, JSON.stringify(play)); document.querySelector('#save-status').textContent = '이 팀의 이 기기에 임시 저장됨'; }
|
||||
catch { document.querySelector('#save-status').textContent = '임시 저장 불가 · 파일로 내보내세요'; }
|
||||
}
|
||||
function applyGaze(lookAt) {
|
||||
const play = editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { lookAt });
|
||||
if (play === state.play) { document.querySelector('#status').textContent = '다른 선수를 선택하거나 편집 가능한 행동을 선택하세요'; return; }
|
||||
gazePick = null; updatePlay(play, '시선 저장', state.selectedAction);
|
||||
}
|
||||
function seekTo(time) { controller.pause(); controller.seek(time); playbackSession = true; resetPreview = false; state.mode = 'move'; state.playing = false; document.querySelector('#status').textContent = '재생 위치 미리보기'; renderUi(); }
|
||||
document.addEventListener('keydown', event => {
|
||||
if (document.querySelector('.shell')?.hidden) return;
|
||||
if (event.key === 'Escape' && !document.querySelector('#project-menu').hidden) { const menu = document.querySelector('#project-menu'); menu.hidden = true; delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false'); document.querySelector('#video-share-open').focus(); return; }
|
||||
if (event.target.closest('input,select,textarea,video,button,[contenteditable="true"]')) return;
|
||||
let id = null;
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') id = event.shiftKey ? 'redo' : 'undo';
|
||||
else if (event.code === 'Space') id = state.playing ? 'pause' : 'play';
|
||||
else if (event.key === 'Delete') id = 'delete-action';
|
||||
else if (event.key === 'Escape') { document.querySelector('#project-menu').hidden = true; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false'); gazePick = null; state.mode = 'move'; state.selectedAction = -1; panel = 'court'; renderUi(); document.querySelector('#status').textContent = '선택 취소'; }
|
||||
if (id) { event.preventDefault(); document.getElementById(id).click(); }
|
||||
});
|
||||
|
||||
document.addEventListener('pointerdown', event => {
|
||||
if (!event.target.closest('#project-menu') && !event.target.closest('#toggle-menu')) {
|
||||
document.querySelector('#project-menu').hidden = true;
|
||||
document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#board').addEventListener('click', (event) => {
|
||||
if (suppressClick) { suppressClick = false; return; }
|
||||
if (state.view !== 'tactical' || state.playing) return;
|
||||
const hit = board.pick(event, state); if (!hit) return;
|
||||
if (gazePick) { if (gazePick === 'player' && hit.type === 'player') applyGaze({ type: 'player', targetId: hit.playerId }); else if (gazePick === 'location' && hit.type === 'location') applyGaze({ type: 'location', ...snapLocation(hit.location) }); return; }
|
||||
if (hit.type === 'player') { if (state.mode === 'pass') addPassForTarget(hit.playerId); else if (state.mode === 'screen') addScreenForTarget(hit.playerId); else if (state.mode === 'lookAt' && state.selectedAction >= 0) { const play = structuredClone(state.play); const action = selectedTrack()?.actions[state.selectedAction]; const target = normalizeLookAt({ type: 'player', targetId: hit.playerId }, state.selectedPlayerId, play.players); if (action && target) { play.sequences[state.selectedSequence].tracks.find((track) => track.playerId === state.selectedPlayerId).actions[state.selectedAction].lookAt = target; updatePlay(play, '선수 대상 시선 저장', state.selectedAction); } } else { playbackSession = false; resetPreview = false; state = { ...state, selectedPlayerId: hit.playerId, selectedAction: -1 }; renderUi(); } return; }
|
||||
if (hasShoot(state.play)) { document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; return; }
|
||||
const location = snapLocation(clampLocation(hit.location));
|
||||
if (state.mode === 'start') { const next = setSelectedPlayerStart(state, location); if (next.play === state.play && hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; else setState(next, '시작 위치 변경 · 행동 0개'); }
|
||||
else if (state.mode === 'move') { const play = addAction(state.play, state.selectedSequence, state.selectedPlayerId, location); const track = play.sequences[state.selectedSequence]?.tracks.find((candidate) => candidate.playerId === state.selectedPlayerId); if (track && play !== state.play) updatePlay(play, '이동 행동 추가', track.actions.length - 1); else if (hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; }
|
||||
else if (state.mode === 'screen') { document.querySelector('#status').textContent = '수비 선수를 선택하세요'; }
|
||||
else if (state.selectedAction >= 0) { const play = structuredClone(state.play); const action = play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId)?.actions[state.selectedAction]; if (action) { action.lookAt = { type: 'location', ...location }; updatePlay(play, '코트 위치 시선 저장', state.selectedAction); } }
|
||||
});
|
||||
|
||||
const boardElement = document.querySelector('#board');
|
||||
boardElement.addEventListener('pointerdown', event => {
|
||||
if (event.button !== 0 || state.view !== 'tactical' || state.playing || gazePick || (state.mode === 'start' && hasShoot(state.play))) return;
|
||||
if (state.mode !== 'start' && state.mode !== 'move') return;
|
||||
const hit = board.pick(event, state);
|
||||
if (hit?.type !== 'player' || hit.playerId !== state.selectedPlayerId) return;
|
||||
const action = selectedTrack()?.actions[state.selectedAction];
|
||||
if (state.mode !== 'start' && action?.type !== 'move') return;
|
||||
drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, moved: false, location: null };
|
||||
boardElement.setPointerCapture(event.pointerId);
|
||||
});
|
||||
boardElement.addEventListener('pointermove', event => {
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 7 && !drag.moved) return;
|
||||
const location = board.courtLocation(event); if (!location) return;
|
||||
drag.moved = true; drag.location = snapLocation(location);
|
||||
document.querySelector('.board-wrap').classList.add('dragging');
|
||||
document.querySelector('#status').textContent = `놓으면 위치 변경 · ${drag.location.x.toFixed(1)}, ${drag.location.z.toFixed(1)}`;
|
||||
});
|
||||
function finishDrag(event) {
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const completed = drag; drag = null;
|
||||
document.querySelector('.board-wrap').classList.remove('dragging');
|
||||
if (boardElement.hasPointerCapture(event.pointerId)) boardElement.releasePointerCapture(event.pointerId);
|
||||
if (!completed.moved) return;
|
||||
suppressClick = true; setTimeout(() => { suppressClick = false; }, 0);
|
||||
if (event.type !== 'pointerup') return;
|
||||
if (state.mode === 'start') setState(setSelectedPlayerStart(state, completed.location), '시작 위치 변경');
|
||||
else updatePlay(editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { location: completed.location }), '이동 위치 변경', state.selectedAction);
|
||||
}
|
||||
boardElement.addEventListener('pointerup', finishDrag);
|
||||
boardElement.addEventListener('pointercancel', finishDrag);
|
||||
|
||||
renderUi();
|
||||
initializeAccount();
|
||||
function frame(now) { const delta = Math.min(0.25, (now - lastFrame) / 1000); lastFrame = now; if (videoExportActive) { requestAnimationFrame(frame); return; } const sample = selectRenderSample(state.play, { playbackSession, resetPreview: resetPreview || state.mode === 'start', selectedSequence: state.selectedSequence, selectedPlayerId: state.selectedPlayerId, selectedAction: state.selectedAction }, controller, delta); state.playing = sample.playing; if (playbackSession && !sample.playing && sample.elapsed >= sample.totalDuration && document.querySelector('#status').textContent === '재생 중') document.querySelector('#status').textContent = '재생 완료'; document.querySelector('#clock').textContent = `${sample.elapsed.toFixed(1)}s`; document.querySelector('#scrubber').value = playbackSession ? sample.elapsed : 0; document.querySelector('#time-display').textContent = (playbackSession ? sample.elapsed : 0).toFixed(1) + ' / ' + totalDuration(state.play).toFixed(1) + '초'; board.render(state.play, state, sample, delta); requestAnimationFrame(frame); }
|
||||
requestAnimationFrame(frame);
|
||||
@@ -0,0 +1,26 @@
|
||||
export function createPlayOperationCoordinator() {
|
||||
let intent = 0;
|
||||
const saveQueues = new Map();
|
||||
|
||||
function beginIntent() {
|
||||
intent += 1;
|
||||
return intent;
|
||||
}
|
||||
|
||||
function isCurrent(token) {
|
||||
return token === intent;
|
||||
}
|
||||
|
||||
function enqueueSave(playId, operation) {
|
||||
const previous = saveQueues.get(playId) || Promise.resolve();
|
||||
const current = previous.catch(() => undefined).then(operation);
|
||||
saveQueues.set(playId, current);
|
||||
const clear = () => {
|
||||
if (saveQueues.get(playId) === current) saveQueues.delete(playId);
|
||||
};
|
||||
current.then(clear, clear);
|
||||
return current;
|
||||
}
|
||||
|
||||
return { beginIntent, isCurrent, enqueueSave };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPlayOperationCoordinator } from './playOperations.js';
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe('play operation coordination', () => {
|
||||
it('tracks the newest user intent', () => {
|
||||
const coordinator = createPlayOperationCoordinator();
|
||||
const first = coordinator.beginIntent();
|
||||
const second = coordinator.beginIntent();
|
||||
expect(coordinator.isCurrent(first)).toBe(false);
|
||||
expect(coordinator.isCurrent(second)).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes same-id saves in call order', async () => {
|
||||
const coordinator = createPlayOperationCoordinator();
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
|
||||
const events = [];
|
||||
const first = coordinator.enqueueSave('play-1', async () => {
|
||||
events.push('A:start'); await firstGate; events.push('A:done'); return 'A';
|
||||
});
|
||||
const second = coordinator.enqueueSave('play-1', async () => {
|
||||
events.push('B:start'); events.push('B:done'); return 'B';
|
||||
});
|
||||
await tick();
|
||||
expect(events).toEqual(['A:start']);
|
||||
releaseFirst();
|
||||
await expect(first).resolves.toBe('A');
|
||||
await expect(second).resolves.toBe('B');
|
||||
expect(events).toEqual(['A:start', 'A:done', 'B:start', 'B:done']);
|
||||
});
|
||||
|
||||
it('continues the queue after a rejected save', async () => {
|
||||
const coordinator = createPlayOperationCoordinator();
|
||||
const events = [];
|
||||
const first = coordinator.enqueueSave('play-1', async () => { events.push('A'); throw new Error('failed'); });
|
||||
const second = coordinator.enqueueSave('play-1', async () => { events.push('B'); return 'saved'; });
|
||||
await expect(first).rejects.toThrow('failed');
|
||||
await expect(second).resolves.toBe('saved');
|
||||
expect(events).toEqual(['A', 'B']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
const STORAGE_KEY = 'basket-utils:saved-plays:v1';
|
||||
const SCHEMA_VERSION = 1;
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
||||
const TEAMS = new Set(['offense', 'defense']);
|
||||
const ACTION_TYPES = new Set(['move', 'pass', 'screen', 'shoot']);
|
||||
const LOOK_AT_TYPES = new Set(['rim', 'ball', 'player', 'location', 'movement']);
|
||||
|
||||
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
||||
function normalizedName(name) { return String(name ?? '').trim() || '새 전술'; }
|
||||
function finiteLocation(location) { return Boolean(location && Number.isFinite(location.x) && Number.isFinite(location.z)); }
|
||||
function validPlay(play) {
|
||||
if (!play || typeof play.id !== 'string' || !SAFE_ID.test(play.id) || typeof play.name !== 'string' || !Array.isArray(play.players) || !Array.isArray(play.sequences) || !play.sequences.length || !['man-to-man', '2-3', '3-2'].includes(play.defenseType)) return false;
|
||||
const players = new Map(); for (const player of play.players) { if (!player || typeof player.id !== 'string' || !SAFE_ID.test(player.id) || players.has(player.id) || !TEAMS.has(player.team) || !Number.isFinite(player.number) || !finiteLocation(player.location)) return false; players.set(player.id, player); }
|
||||
const sequenceIds = new Set(); const actionIds = new Set(); const shootRecords = [];
|
||||
for (const sequence of play.sequences) {
|
||||
if (!sequence || typeof sequence.id !== 'string' || !SAFE_ID.test(sequence.id) || sequenceIds.has(sequence.id) || typeof sequence.name !== 'string' || !Array.isArray(sequence.tracks) || sequence.tracks.length !== players.size || typeof sequence.ballOwnerId !== 'string' || !players.has(sequence.ballOwnerId) || players.get(sequence.ballOwnerId).team !== 'offense' || typeof sequence.ballOwnerInherited !== 'boolean') return false;
|
||||
sequenceIds.add(sequence.id); const trackIds = new Set(); let passCount = 0;
|
||||
for (const track of sequence.tracks) {
|
||||
if (!track || typeof track.playerId !== 'string' || !players.has(track.playerId) || trackIds.has(track.playerId) || !finiteLocation(track.startLocation) || !Array.isArray(track.actions)) return false;
|
||||
trackIds.add(track.playerId);
|
||||
for (const action of track.actions) {
|
||||
if (!action || typeof action.id !== 'string' || !SAFE_ID.test(action.id) || actionIds.has(action.id) || !ACTION_TYPES.has(action.type) || !finiteLocation(action.location) || !(action.facing === null || Number.isFinite(action.facing)) || !(action.targetPlayerId === null || (typeof action.targetPlayerId === 'string' && players.has(action.targetPlayerId))) || !(action.lookAt === null || typeof action.lookAt === 'object' && LOOK_AT_TYPES.has(action.lookAt.type))) return false;
|
||||
actionIds.add(action.id); const actor = players.get(track.playerId); const target = action.targetPlayerId ? players.get(action.targetPlayerId) : null;
|
||||
if (action.type === 'pass') { passCount += 1; if (actor.team !== 'offense' || sequence.ballOwnerId !== actor.id || !target || target.team !== 'offense' || target.id === actor.id || sequence.tracks.find((candidate) => candidate.playerId === target.id)?.actions.some((candidate) => candidate.type === 'screen')) return false; }
|
||||
if (action.type === 'screen' && (actor.team !== 'offense' || !target || target.team !== 'defense')) return false;
|
||||
if (action.type === 'shoot') { if (actor.team !== 'offense' || action.targetPlayerId !== null || action.facing !== null || action.lookAt?.type !== 'rim') return false; shootRecords.push({ action, sequence, sequenceIndex: play.sequences.indexOf(sequence), track }); }
|
||||
if (action.lookAt?.type === 'location' && !finiteLocation(action.lookAt)) return false;
|
||||
if (action.lookAt?.type === 'player' && (!players.has(action.lookAt.targetId) || action.lookAt.targetId === track.playerId)) return false;
|
||||
}
|
||||
}
|
||||
if (trackIds.size !== players.size || passCount > 1) return false;
|
||||
const finalOwner = sequence.tracks.find((track) => track.playerId === sequence.ballOwnerId)?.actions.findLast((action) => action.type === 'pass')?.targetPlayerId || sequence.ballOwnerId;
|
||||
if (sequence.tracks.find((track) => track.playerId === finalOwner)?.actions.some((action) => action.type === 'screen')) return false;
|
||||
}
|
||||
if (shootRecords.length > 1) return false;
|
||||
if (shootRecords.length === 1) { const { action, sequence, sequenceIndex, track } = shootRecords[0]; const pass = sequence.tracks.flatMap((candidate) => candidate.actions).findLast((candidate) => candidate.type === 'pass'); const ownerId = pass?.targetPlayerId || sequence.ballOwnerId; const previousLocation = track.actions.length > 1 ? track.actions.at(-2).location : track.startLocation; if (sequenceIndex !== play.sequences.length - 1 || track.playerId !== ownerId || track.actions.at(-1) !== action || action.location.x !== previousLocation.x || action.location.z !== previousLocation.z) return false; }
|
||||
return true;
|
||||
}
|
||||
export function isValidPlay(play) { return validPlay(play); }
|
||||
export function assertValidPlay(play) { if (!validPlay(play)) throw new Error('저장할 전술 데이터가 올바르지 않습니다'); return true; }
|
||||
function readEnvelope(storage) {
|
||||
if (!storage) throw new Error('localStorage를 사용할 수 없습니다');
|
||||
const raw = storage.getItem(STORAGE_KEY); if (raw === null) return { schemaVersion: SCHEMA_VERSION, records: [] };
|
||||
let envelope; try { envelope = JSON.parse(raw); } catch { throw new Error('저장 데이터가 손상되었습니다'); }
|
||||
if (envelope?.schemaVersion !== SCHEMA_VERSION || !Array.isArray(envelope.records)) throw new Error('지원하지 않는 저장 데이터입니다');
|
||||
const recordIds = new Set(); for (const record of envelope.records) if (!record || typeof record.id !== 'string' || recordIds.has(record.id) || !validPlay(record.play) || record.id !== record.play.id || typeof record.name !== 'string' || record.name !== record.play.name || typeof record.updatedAt !== 'string' || Number.isNaN(Date.parse(record.updatedAt))) throw new Error('저장 데이터 형식이 올바르지 않습니다'); else recordIds.add(record.id);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
export function createLocalPlayRepository(storage = typeof window !== 'undefined' ? window.localStorage : null) {
|
||||
return {
|
||||
async save(play) {
|
||||
assertValidPlay(play);
|
||||
const envelope = readEnvelope(storage); const nextPlay = clone(play); nextPlay.name = normalizedName(nextPlay.name); const record = { id: nextPlay.id, name: nextPlay.name, updatedAt: new Date().toISOString(), play: nextPlay };
|
||||
const records = envelope.records.filter((candidate) => candidate.id !== nextPlay.id); records.push(record); records.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); storage.setItem(STORAGE_KEY, JSON.stringify({ schemaVersion: SCHEMA_VERSION, records })); return clone(record);
|
||||
},
|
||||
async list() { return readEnvelope(storage).records.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).map(({ id, name, updatedAt }) => ({ id, name, updatedAt })); },
|
||||
async get(id) { const record = readEnvelope(storage).records.find((candidate) => candidate.id === id); return record ? clone(record.play) : null; },
|
||||
};
|
||||
}
|
||||
|
||||
export { STORAGE_KEY };
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { addAction, addPassAction, addScreenAction, addSequence, addShootAction, createInitialPlay } from './domain.js';
|
||||
import { createLocalPlayRepository, STORAGE_KEY } from './playRepository.js';
|
||||
import { createAppStateFromPlay } from './state.js';
|
||||
|
||||
class MemoryStorage {
|
||||
constructor() { this.data = new Map(); }
|
||||
getItem(key) { return this.data.has(key) ? this.data.get(key) : null; }
|
||||
setItem(key, value) { this.data.set(key, String(value)); }
|
||||
}
|
||||
const validPlay = () => createInitialPlay('저장 전술');
|
||||
|
||||
describe('local play repository', () => {
|
||||
it('empty storage/list/get과 unknown id를 안전하게 처리한다', async () => {
|
||||
const repository = createLocalPlayRepository(new MemoryStorage()); expect(await repository.list()).toEqual([]); expect(await repository.get('missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('action/lookAt/pass/screen/sequence를 roundtrip한다', async () => {
|
||||
let play = validPlay(); play = addAction(play, 0, 'offense-1', { x: 1, z: 4 }, { lookAt: { type: 'location', x: 2, z: 5 } }); play = addPassAction(play, 0, 'offense-1', 'offense-2'); play = addScreenAction(play, 0, 'offense-3', 'defense-1'); play = addSequence(play); play = addShootAction(play, 1, 'offense-2'); const repository = createLocalPlayRepository(new MemoryStorage()); await repository.save(play); const loaded = await repository.get(play.id);
|
||||
expect(loaded).toEqual(play); expect(loaded).not.toBe(play); expect(loaded.sequences[0]).not.toBe(play.sequences[0]);
|
||||
});
|
||||
|
||||
it('same id는 upsert하고 같은 이름의 다른 id는 보존하며 최신순으로 정렬한다', async () => {
|
||||
const storage = new MemoryStorage(); const repository = createLocalPlayRepository(storage); const first = validPlay(); const second = { ...validPlay(), id: 'play-second', name: '같은 이름' }; vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); await repository.save({ ...first, name: '같은 이름' }); vi.setSystemTime(new Date('2026-01-02T00:00:00Z')); await repository.save(second); vi.setSystemTime(new Date('2026-01-03T00:00:00Z')); await repository.save({ ...first, name: '수정된 이름' }); expect((await repository.list()).map((record) => record.id)).toEqual([first.id, second.id]); expect((await repository.get(first.id)).name).toBe('수정된 이름'); vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('빈 이름은 새 전술로 저장하고 play/record 이름을 일치시킨다', async () => {
|
||||
const repository = createLocalPlayRepository(new MemoryStorage()); const play = { ...validPlay(), name: ' ' }; const saved = await repository.save(play); expect(saved.name).toBe('새 전술'); expect(saved.play.name).toBe('새 전술'); expect((await repository.get(play.id)).name).toBe('새 전술');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['malformed JSON', '{bad'], ['schema version', JSON.stringify({ schemaVersion: 2, records: [] })], ['records type', JSON.stringify({ schemaVersion: 1, records: {} })],
|
||||
])('손상된 blob %s은 원문을 보존하고 save를 거부한다', async (_label, raw) => {
|
||||
const storage = new MemoryStorage(); storage.setItem(STORAGE_KEY, raw); const repository = createLocalPlayRepository(storage); await expect(repository.list()).rejects.toThrow(); await expect(repository.save(validPlay())).rejects.toThrow(); expect(storage.getItem(STORAGE_KEY)).toBe(raw);
|
||||
});
|
||||
|
||||
it('record id mismatch와 최소 Play 구조 위반을 거부한다', async () => {
|
||||
const storage = new MemoryStorage(); const repository = createLocalPlayRepository(storage); const badRecord = JSON.stringify({ schemaVersion: 1, records: [{ id: 'wrong', name: 'x', updatedAt: new Date().toISOString(), play: validPlay() }] }); storage.setItem(STORAGE_KEY, badRecord); await expect(repository.get('wrong')).rejects.toThrow(); expect(storage.getItem(STORAGE_KEY)).toBe(badRecord);
|
||||
const badPlay = { id: 'bad', name: 'x', players: [], sequences: [] }; await expect(repository.save(badPlay)).rejects.toThrow();
|
||||
const namedPlay = validPlay(); const mismatch = JSON.stringify({ schemaVersion: 1, records: [{ id: namedPlay.id, name: '다른 이름', updatedAt: new Date().toISOString(), play: namedPlay }] }); storage.setItem(STORAGE_KEY, mismatch); await expect(repository.list()).rejects.toThrow(); expect(storage.getItem(STORAGE_KEY)).toBe(mismatch);
|
||||
});
|
||||
|
||||
it('중복 record id가 있으면 list/get/save 모두 거부하고 원문을 보존한다', async () => {
|
||||
const storage = new MemoryStorage(); const play = validPlay(); const record = { id: play.id, name: play.name, updatedAt: new Date().toISOString(), play }; const raw = JSON.stringify({ schemaVersion: 1, records: [record, record] }); storage.setItem(STORAGE_KEY, raw); const repository = createLocalPlayRepository(storage);
|
||||
await expect(repository.list()).rejects.toThrow(); await expect(repository.get(play.id)).rejects.toThrow(); await expect(repository.save(validPlay())).rejects.toThrow(); expect(storage.getItem(STORAGE_KEY)).toBe(raw);
|
||||
});
|
||||
|
||||
it('내부 action 필드·ID·좌표가 corrupt하면 저장과 state 복원을 거부한다', async () => {
|
||||
const play = validPlay(); play.sequences[0].tracks[0].actions.push({ id: '<img>', type: 'script', location: { x: Infinity, z: 2 }, facing: undefined, targetPlayerId: null, lookAt: null }); const repository = createLocalPlayRepository(new MemoryStorage()); await expect(repository.save(play)).rejects.toThrow(); expect(() => createAppStateFromPlay(play)).toThrow();
|
||||
const badShoot = addShootAction(validPlay(), 0, 'offense-1'); badShoot.sequences[0].tracks[0].actions[0].lookAt = { type: 'ball' }; await expect(repository.save(badShoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('sequence name은 목록에서 원문 text 값으로만 전달된다', async () => {
|
||||
const storage = new MemoryStorage(); const repository = createLocalPlayRepository(storage); const play = { ...validPlay(), name: '<b>전술</b>' }; play.sequences[0].name = '<img src=x onerror=alert(1)>'; await repository.save(play); expect((await repository.list())[0].name).toBe('<b>전술</b>'); expect((await repository.get(play.id)).sequences[0].name).toBe('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('storage unavailable/quota 오류를 그대로 throw한다', async () => {
|
||||
const unavailable = { getItem() { throw new Error('SecurityError'); } }; await expect(createLocalPlayRepository(unavailable).list()).rejects.toThrow('SecurityError'); const quota = new MemoryStorage(); quota.setItem = () => { throw new Error('QuotaExceededError'); }; await expect(createLocalPlayRepository(quota).save(validPlay())).rejects.toThrow('QuotaExceededError');
|
||||
});
|
||||
|
||||
it('createAppStateFromPlay는 deep clone과 fresh editing state를 만든다', () => {
|
||||
const source = validPlay(); const state = createAppStateFromPlay(source); expect(state.play).toEqual(source); expect(state.play).not.toBe(source); expect(state.selectedPlayerId).toBe('offense-1'); expect(state.selectedSequence).toBe(0); expect(state.selectedAction).toBe(-1); expect(state.mode).toBe('move'); expect(state.view).toBe('tactical'); expect(Object.values(state.history.bySequenceId[state.play.sequences[0].id]['offense-1']).every((items) => items.length === 0)).toBe(true); state.play.name = 'changed'; expect(source.name).toBe('저장 전술');
|
||||
});
|
||||
});
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { RIM_HEIGHT, BALL_HOLD_HEIGHT, actionExtraDuration, COURT, DEFENSE_REACTION_DELAY, distance, finalTrackLocation, isDefenseTrack, lookAtLocation, offensePhaseDuration, PASS_DURATION, resolveFacing, resolveLookAt, sequenceDuration, sequenceNormalDuration, SHOOT_DURATION, shortestAngleLerp, trackNaturalDuration, trackPoints, PLAYER_SPEED } from './domain.js';
|
||||
|
||||
function trackReactionDelay(track, players = []) { return isDefenseTrack(track, players) && track.actions.length ? DEFENSE_REACTION_DELAY : 0; }
|
||||
|
||||
function sampleTrack(track, time, speed = PLAYER_SPEED, players = []) {
|
||||
const points = trackPoints(track); let remaining = Math.max(0, time);
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const action = track.actions[index]; const movementDuration = speed > 0 ? distance(points[index], points[index + 1]) / speed : 0; const extraDuration = actionExtraDuration(action);
|
||||
if (movementDuration > 1e-9 && remaining <= movementDuration) { const progress = remaining / movementDuration; return { location: { x: points[index].x + (points[index + 1].x - points[index].x) * progress, z: points[index].z + (points[index + 1].z - points[index].z) * progress }, segmentIndex: index, progress, phase: 'move' }; }
|
||||
remaining -= movementDuration;
|
||||
if (extraDuration > 1e-9 && remaining <= extraDuration) return { location: { ...points[index + 1] }, segmentIndex: index, progress: remaining / extraDuration, phase: action.type === 'pass' ? 'pass' : action.type === 'screen' ? 'screenHold' : 'hold' };
|
||||
remaining -= extraDuration;
|
||||
}
|
||||
const last = points.at(-1); return { location: { x: last.x, z: last.z }, segmentIndex: Math.max(0, points.length - 2), progress: 1, phase: 'final' };
|
||||
}
|
||||
|
||||
function reactionSample(track) { return { location: { ...track.startLocation }, segmentIndex: 0, progress: 0, phase: 'reaction' }; }
|
||||
|
||||
function sampleScheduledTrack(sequence, track, time, speed, players, offensePhase) {
|
||||
const natural = trackNaturalDuration(track, speed);
|
||||
if (!isDefenseTrack(track, players) || !track.actions.length) return sampleTrack(track, time, speed, players);
|
||||
const delay = DEFENSE_REACTION_DELAY; const active = Math.max(natural, offensePhase - delay);
|
||||
if (time <= delay) return reactionSample(track);
|
||||
if (natural <= 0) return sampleTrack(track, 0, speed, players);
|
||||
const naturalTime = active > 0 ? Math.min(natural, Math.max(0, (time - delay) * natural / active)) : 0;
|
||||
return sampleTrack(track, naturalTime, speed, players);
|
||||
}
|
||||
|
||||
function passInfo(sequence, speed, players) {
|
||||
const passerTrack = sequence?.tracks.find((track) => track.actions.some((action) => action.type === 'pass')); if (!passerTrack) return null;
|
||||
const actionIndex = passerTrack.actions.findIndex((action) => action.type === 'pass'); const action = passerTrack.actions[actionIndex]; let startTime = trackReactionDelay(passerTrack, players); const points = trackPoints(passerTrack);
|
||||
for (let index = 0; index < actionIndex; index += 1) startTime += (speed > 0 ? distance(points[index], points[index + 1]) / speed : 0) + actionExtraDuration(passerTrack.actions[index]);
|
||||
const targetTrack = sequence.tracks.find((track) => track.playerId === action.targetPlayerId); const targetAtEnd = targetTrack ? sampleTrack(targetTrack, startTime + PASS_DURATION, speed, players).location : finalTrackLocation(targetTrack);
|
||||
return { passerId: passerTrack.playerId, targetId: action.targetPlayerId, actionIndex, startTime, endTime: startTime + PASS_DURATION, start: { ...points[actionIndex] }, end: { ...targetAtEnd } };
|
||||
}
|
||||
|
||||
function shootInfo(sequence, speed, players) {
|
||||
const track = sequence?.tracks.find((candidate) => candidate.actions.some((action) => action.type === 'shoot')); const actionIndex = track?.actions.findIndex((action) => action.type === 'shoot');
|
||||
if (!track || actionIndex < 0) return null;
|
||||
const start = finalTrackLocation(track); const startTime = sequenceNormalDuration(sequence, speed, players);
|
||||
return { shooterId: track.playerId, actionIndex, startTime, endTime: startTime + SHOOT_DURATION, start };
|
||||
}
|
||||
|
||||
function ballState(play, sequence, time, locations, speed) {
|
||||
const ownerId = sequence?.ballOwnerId || play.players.find((player) => player.team === 'offense')?.id; const pass = passInfo(sequence, speed, play.players); const shoot = shootInfo(sequence, speed, play.players);
|
||||
if (shoot && time >= shoot.startTime) { const progress = Math.min(1, Math.max(0, (time - shoot.startTime) / SHOOT_DURATION)); return { ownerId: null, location: { x: shoot.start.x + (COURT.rim.x - shoot.start.x) * progress, z: shoot.start.z + (COURT.rim.z - shoot.start.z) * progress }, height: 1.9 + (RIM_HEIGHT - 1.9) * progress + 1.8 * 4 * progress * (1 - progress), inFlight: progress < 1, atRim: progress >= 1 }; }
|
||||
if (!pass || time < pass.startTime) return { ownerId, location: { ...(locations[ownerId] || play.players.find((player) => player.id === ownerId)?.location) }, height: BALL_HOLD_HEIGHT };
|
||||
if (time < pass.endTime) { const progress = (time - pass.startTime) / PASS_DURATION; return { ownerId: pass.passerId, location: { x: pass.start.x + (pass.end.x - pass.start.x) * progress, z: pass.start.z + (pass.end.z - pass.start.z) * progress }, inFlight: true }; }
|
||||
return { ownerId: pass.targetId, location: { ...(locations[pass.targetId] || pass.end) }, height: BALL_HOLD_HEIGHT };
|
||||
}
|
||||
|
||||
export function sampleSequence(play, sequence, time, speed = PLAYER_SPEED) {
|
||||
const locations = {}; const samples = {};
|
||||
const offensePhase = offensePhaseDuration(sequence, speed, play.players);
|
||||
for (const track of sequence?.tracks || []) { const sample = sampleScheduledTrack(sequence, track, time, speed, play.players, offensePhase); locations[track.playerId] = sample.location; const actionIndex = Math.min(track.actions.length - 1, Math.max(0, sample.segmentIndex)); const facingFrom = resolveFacing(track, actionIndex, speed); const facingTo = actionIndex + 1 < track.actions.length ? resolveFacing(track, actionIndex + 1, speed) : facingFrom; const screenAction = track.actions[actionIndex]; const screenPhase = screenAction?.type === 'screen' && (sample.phase === 'move' || sample.phase === 'screenHold'); samples[track.playerId] = { ...sample, facing: screenPhase ? (Number.isFinite(screenAction.facing) ? screenAction.facing : facingFrom) : shortestAngleLerp(facingFrom, facingTo, sample.progress), lookAt: resolveLookAt(play, sequence, track.playerId, actionIndex, sample.location) }; }
|
||||
for (const player of play.players) if (!locations[player.id]) locations[player.id] = { ...player.location };
|
||||
const ball = ballState(play, sequence, Math.max(0, time), locations, speed); const sampleSequenceData = { ...sequence, ballLocation: ball.location, ballOwnerId: ball.ownerId };
|
||||
for (const track of sequence?.tracks || []) { const sample = samples[track.playerId]; if (!sample) continue; const actionIndex = Math.min(track.actions.length - 1, Math.max(0, sample.segmentIndex)); sample.lookAt = resolveLookAt(play, sampleSequenceData, track.playerId, actionIndex, sample.location); const currentTarget = lookAtLocation(sample.lookAt, play, locations, sampleSequenceData); const screenAction = track.actions[actionIndex]; const screenPhase = screenAction?.type === 'screen' && (sample.phase === 'move' || sample.phase === 'screenHold'); if (screenPhase) { sample.lookAtLocation = currentTarget; continue; } sample.lookAtLocation = currentTarget; }
|
||||
const pass = passInfo(sequence, speed, play.players);
|
||||
if (pass && time >= pass.startTime && time < pass.endTime) {
|
||||
const receiver = samples[pass.targetId];
|
||||
const receiverAction = sequence.tracks.find(track => track.playerId === pass.targetId)?.actions[receiver?.segmentIndex];
|
||||
if (receiver && !receiverAction?.lookAt) { receiver.lookAt = { type: 'ball' }; receiver.lookAtLocation = { ...ball.location }; }
|
||||
}
|
||||
return { locations, samples, duration: sequenceDuration(sequence, speed, play.players), ballLocation: ball.location, ballOwnerId: ball.ownerId, ballHeight: ball.height ?? BALL_HOLD_HEIGHT, ballInFlight: Boolean(ball.inFlight), ballAtRim: Boolean(ball.atRim) };
|
||||
}
|
||||
|
||||
export function sampleEditingSequence(play, sequenceIndex, selectedPlayerId = null, selectedAction = -1, speed = PLAYER_SPEED) {
|
||||
const sequence = play.sequences[sequenceIndex] || play.sequences[0]; const index = play.sequences.indexOf(sequence); const sample = sampleSequence(play, sequence, sequenceDuration(sequence, speed, play.players), speed);
|
||||
if (selectedPlayerId && selectedAction >= 0) { const track = sequence.tracks.find((candidate) => candidate.playerId === selectedPlayerId); const action = track?.actions[selectedAction]; if (track && action) { const actionIndex = track.actions.indexOf(action); sample.locations[selectedPlayerId] = { ...action.location }; sample.samples[selectedPlayerId] = { ...sample.samples[selectedPlayerId], location: { ...action.location }, segmentIndex: actionIndex, progress: 1, facing: resolveFacing(track, actionIndex, speed), lookAt: resolveLookAt(play, sequence, selectedPlayerId, actionIndex, action.location) }; sample.samples[selectedPlayerId].lookAtLocation = lookAtLocation(sample.samples[selectedPlayerId].lookAt, play, sample.locations, sequence); } }
|
||||
return { ...sample, sequenceIndex: index < 0 ? 0 : index, elapsed: sample.duration, totalDuration: sample.duration, playing: false, editing: true };
|
||||
}
|
||||
|
||||
export function selectRenderSample(play, options = {}, controller = null, deltaSeconds = 0, speed = PLAYER_SPEED) { if (options.resetPreview) return { ...samplePlay(play, 0, speed), elapsed: 0, totalDuration: totalDuration(play, speed), playing: false, resetPreview: true }; if (options.playbackSession && controller) return controller.tick(deltaSeconds); return sampleEditingSequence(play, options.selectedSequence ?? 0, options.selectedPlayerId, options.selectedAction ?? -1, speed); }
|
||||
export function sequenceAt(play, sequenceIndex, elapsed, speed = PLAYER_SPEED) { return sampleSequence(play, play.sequences[sequenceIndex], Math.max(0, elapsed), speed); }
|
||||
export function totalDuration(play, speed = PLAYER_SPEED) { return (play.sequences || []).reduce((total, sequence) => total + sequenceDuration(sequence, speed, play.players), 0); }
|
||||
export function samplePlay(play, elapsed, speed = PLAYER_SPEED) { let remaining = Math.max(0, elapsed); let sequenceIndex = 0; for (let index = 0; index < play.sequences.length; index += 1) { const duration = sequenceDuration(play.sequences[index], speed, play.players); if (remaining < duration || index === play.sequences.length - 1) { sequenceIndex = index; break; } remaining -= duration; } return { ...sequenceAt(play, sequenceIndex, remaining, speed), sequenceIndex, elapsed: remaining, totalDuration: totalDuration(play, speed) }; }
|
||||
export function createPlaybackController(play, speed = PLAYER_SPEED) {
|
||||
let elapsed = 0; let playing = false; let rate = 1; let looping = false;
|
||||
return {
|
||||
play() { const duration = totalDuration(play, speed); if (elapsed >= duration) elapsed = 0; playing = duration > 0; },
|
||||
pause() { playing = false; },
|
||||
reset() { elapsed = 0; playing = false; },
|
||||
seek(time) { elapsed = Math.min(totalDuration(play, speed), Math.max(0, Number(time) || 0)); return this.sample(); },
|
||||
setRate(value) { rate = Math.max(0.25, Math.min(2, Number(value) || 1)); },
|
||||
setLoop(value) { looping = Boolean(value); },
|
||||
isPlaying() { return playing; },
|
||||
tick(deltaSeconds) {
|
||||
const duration = totalDuration(play, speed);
|
||||
if (playing && duration > 0) {
|
||||
elapsed += Math.max(0, Number(deltaSeconds) || 0) * rate;
|
||||
if (elapsed >= duration) { if (looping) elapsed %= duration; else { elapsed = duration; playing = false; } }
|
||||
}
|
||||
return this.sample();
|
||||
},
|
||||
sample() { if (totalDuration(play, speed) === 0) playing = false; return { ...samplePlay(play, elapsed, speed), playing, elapsed }; },
|
||||
get elapsed() { return elapsed; },
|
||||
};
|
||||
}
|
||||
export { sampleTrack };
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Lightweight articulated fallback until an authored, rigged player asset is supplied.
|
||||
export function createPlayerAvatar(color) {
|
||||
const avatar = new THREE.Group(); avatar.name = 'avatar'; avatar.position.y = -0.35;
|
||||
const uniform = new THREE.MeshStandardMaterial({ color, roughness: 0.9 });
|
||||
const skin = new THREE.MeshStandardMaterial({ color: 0xb77d58, roughness: 0.9 });
|
||||
const dark = new THREE.MeshStandardMaterial({ color: 0x182536, roughness: 1 });
|
||||
function mesh(geometry, material, parent, x, y, z) {
|
||||
const object = new THREE.Mesh(geometry, material); object.position.set(x, y, z); parent.add(object); return object;
|
||||
}
|
||||
mesh(new THREE.CylinderGeometry(0.23, 0.19, 0.55, 10), uniform, avatar, 0, 1.23, 0);
|
||||
mesh(new THREE.BoxGeometry(0.39, 0.25, 0.25), dark, avatar, 0, 0.87, 0);
|
||||
const head = new THREE.Group(); head.name = 'head'; head.position.y = 1.65; avatar.add(head);
|
||||
mesh(new THREE.SphereGeometry(0.17, 12, 10), skin, head, 0, 0, 0);
|
||||
mesh(new THREE.BoxGeometry(0.065, 0.055, 0.09), skin, head, 0, -0.015, 0.16);
|
||||
for (const side of [-1, 1]) {
|
||||
const arm = new THREE.Group(); arm.name = side < 0 ? 'leftArm' : 'rightArm'; arm.position.set(side * 0.29, 1.43, 0); avatar.add(arm);
|
||||
mesh(new THREE.CapsuleGeometry(0.075, 0.42, 3, 8), skin, arm, 0, -0.25, 0);
|
||||
const leg = new THREE.Group(); leg.name = side < 0 ? 'leftLeg' : 'rightLeg'; leg.position.set(side * 0.12, 0.83, 0); avatar.add(leg);
|
||||
mesh(new THREE.CapsuleGeometry(0.085, 0.5, 3, 8), skin, leg, 0, -0.36, 0);
|
||||
mesh(new THREE.BoxGeometry(0.18, 0.12, 0.31), dark, leg, 0, -0.74, 0.055);
|
||||
}
|
||||
return avatar;
|
||||
}
|
||||
|
||||
export function posePlayerAvatar(avatar, sample, location, time, shooting) {
|
||||
const stride = sample?.phase === 'move' ? Math.sin(time * 11) * 0.4 : 0;
|
||||
avatar.getObjectByName('leftLeg').rotation.x = stride;
|
||||
avatar.getObjectByName('rightLeg').rotation.x = -stride;
|
||||
const passing = sample?.phase === 'pass';
|
||||
avatar.getObjectByName('leftArm').rotation.x = shooting ? -2.6 : passing ? -1.35 : -stride * 0.65;
|
||||
avatar.getObjectByName('rightArm').rotation.x = shooting ? -2.6 : passing ? -1.35 : stride * 0.65;
|
||||
const target = sample?.lookAtLocation; const head = avatar.getObjectByName('head');
|
||||
if (target) {
|
||||
const desired = Math.atan2(target.x - location.x, target.z - location.z) - (sample.facing || 0);
|
||||
const angle = Math.atan2(Math.sin(desired), Math.cos(desired));
|
||||
head.rotation.y = THREE.MathUtils.clamp(angle, -Math.PI * 0.42, Math.PI * 0.42);
|
||||
} else head.rotation.y = 0;
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import * as THREE from 'three';
|
||||
import { COURT, RIM_HEIGHT, BALL_HOLD_HEIGHT, distance, finalTrackLocation, resolveFacing } from './domain.js';
|
||||
import { createCameras, resizeCameras, updatePovCamera } from './cameras.js';
|
||||
import { createPlayerAvatar, posePlayerAvatar } from './playerAvatar.js';
|
||||
|
||||
const palette = { offense: 0xd96c43, defense: 0x719db6, move: 0x174a7e, pass: 0x5b2a83, selected: 0xe9ddbd, court: 0xc3af8d, line: 0xfaf4e5 };
|
||||
|
||||
export function povLabelScaleY(depth, viewportHeight, verticalFov, baseScale = 0.36, maxPixels = 22) {
|
||||
const safeDepth = Math.max(0.1, depth);
|
||||
const safeHeight = Math.max(1, viewportHeight);
|
||||
const projectedPixels = baseScale * safeHeight / (2 * safeDepth * Math.tan(THREE.MathUtils.degToRad(verticalFov) / 2));
|
||||
return projectedPixels > maxPixels ? baseScale * maxPixels / projectedPixels : baseScale;
|
||||
}
|
||||
|
||||
function courtTexture() {
|
||||
const canvas = document.createElement('canvas'); canvas.width = 1024; canvas.height = 1024;
|
||||
const context = canvas.getContext('2d');
|
||||
const colors = ['#c6b18c', '#c8b38f', '#c3ad88', '#cab691', '#c4af8c'];
|
||||
for (let column = 0; column < 32; column += 1) {
|
||||
context.fillStyle = colors[column % colors.length]; context.fillRect(column * 32, 0, 32, 1024);
|
||||
context.strokeStyle = '#947f5d18'; context.beginPath(); context.moveTo(column * 32, 0); context.lineTo(column * 32, 1024); context.stroke();
|
||||
for (let row = 0; row < 5; row += 1) { const y = row * 230 + column % 3 * 73; context.beginPath(); context.moveTo(column * 32, y); context.lineTo((column + 1) * 32, y); context.stroke(); }
|
||||
}
|
||||
const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture;
|
||||
}
|
||||
|
||||
function labelSprite(text, color) {
|
||||
const canvas = document.createElement('canvas'); canvas.width = 128; canvas.height = 64;
|
||||
const context = canvas.getContext('2d'); context.fillStyle = color; context.font = 'bold 38px sans-serif'; context.textAlign = 'center'; context.textBaseline = 'middle'; context.fillText(text, 64, 32);
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(canvas), transparent: true }));
|
||||
sprite.scale.set(1.1, 0.55, 1); sprite.position.y = 0.62; return sprite;
|
||||
}
|
||||
|
||||
function addCourt(scene) {
|
||||
const court = new THREE.Mesh(new THREE.BoxGeometry(COURT.width, 0.16, COURT.length), new THREE.MeshBasicMaterial({ map: courtTexture() }));
|
||||
court.position.set(0, -0.08, COURT.length / 2); court.name = 'court'; scene.add(court);
|
||||
const points = [];
|
||||
const line = (a, b) => points.push(new THREE.Vector3(a[0], 0.02, a[1]), new THREE.Vector3(b[0], 0.02, b[1]));
|
||||
line([-7.5, 0], [7.5, 0]); line([-7.5, 14], [7.5, 14]); line([-7.5, 0], [-7.5, 14]); line([7.5, 0], [7.5, 14]);
|
||||
line([-2.45, 8.2], [-2.45, 14]); line([2.45, 8.2], [2.45, 14]); line([-2.45, 8.2], [2.45, 8.2]); const threePointTheta = Math.acos(6.6 / 6.75); const threePointCornerZ = COURT.rim.z - Math.sqrt(6.75 ** 2 - 6.6 ** 2); line([-6.6, 14], [-6.6, threePointCornerZ]); line([6.6, 14], [6.6, threePointCornerZ]); line([-1.6, 13.8], [1.6, 13.8]);
|
||||
const arc = (cx, cz, radius, start, end) => { for (let i = 0; i < 48; i += 1) { const a = start + (end - start) * i / 48; const b = start + (end - start) * (i + 1) / 48; points.push(new THREE.Vector3(cx + radius * Math.cos(a), 0.03, cz + radius * Math.sin(a)), new THREE.Vector3(cx + radius * Math.cos(b), 0.03, cz + radius * Math.sin(b))); } };
|
||||
arc(0, 0, 1.8, 0, Math.PI); arc(0, 8.2, 1.8, 0, Math.PI * 2); arc(0, 13.25, 1.25, Math.PI, Math.PI * 2); arc(COURT.rim.x, COURT.rim.z, 6.75, Math.PI + threePointTheta, Math.PI * 2 - threePointTheta);
|
||||
const paint = new THREE.Mesh(new THREE.PlaneGeometry(4.9, 5.8), new THREE.MeshBasicMaterial({ color: 0x526951, transparent: true, opacity: 0.25, side: THREE.DoubleSide })); paint.rotation.x = -Math.PI / 2; paint.position.set(0, 0.015, 11.1); scene.add(paint);
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
scene.add(new THREE.LineSegments(geometry, new THREE.LineBasicMaterial({ color: palette.line, transparent: true, opacity: 0.92 })));
|
||||
const hoop = new THREE.Mesh(new THREE.TorusGeometry(0.225, 0.018, 10, 32), new THREE.MeshStandardMaterial({ color: 0xff703d }));
|
||||
hoop.name = 'physicalHoop'; hoop.rotation.x = Math.PI / 2; hoop.position.set(COURT.rim.x, RIM_HEIGHT, COURT.rim.z); scene.add(hoop);
|
||||
const diagramHoop = new THREE.Mesh(new THREE.TorusGeometry(0.225, 0.03, 8, 24), new THREE.MeshBasicMaterial({ color: palette.line }));
|
||||
diagramHoop.name = 'diagramHoop'; diagramHoop.rotation.x = Math.PI / 2; diagramHoop.position.set(COURT.rim.x, 0.1, COURT.rim.z); scene.add(diagramHoop);
|
||||
const backboard = new THREE.Mesh(new THREE.BoxGeometry(1.8, 1.05, 0.06), new THREE.MeshStandardMaterial({ color: 0xdde8ee, transparent: true, opacity: 0.75 }));
|
||||
backboard.name = 'physicalBackboard'; backboard.position.set(0, 3.45, 13.55); scene.add(backboard);
|
||||
}
|
||||
|
||||
function makePath(points, color) {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points.map((point) => new THREE.Vector3(point.x, 0.14, point.z)));
|
||||
return new THREE.Line(geometry, new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.7 }));
|
||||
}
|
||||
|
||||
export function passArrowVisualGeometry(points) {
|
||||
if (!points?.[0] || !points?.[1]) return null;
|
||||
const dx = points[1].x - points[0].x; const dz = points[1].z - points[0].z; const length = Math.hypot(dx, dz);
|
||||
if (!Number.isFinite(length) || length <= 1e-6) return null;
|
||||
const shaftLength = length * 0.45; const trimTotal = Math.min(1.15, length - shaftLength); const trimRatio = 0.45 / 1.15;
|
||||
const startTrim = trimTotal * trimRatio; const endTrim = trimTotal - startTrim; const ux = dx / length; const uz = dz / length;
|
||||
const start = { x: points[0].x + ux * startTrim, z: points[0].z + uz * startTrim }; const end = { x: points[1].x - ux * endTrim, z: points[1].z - uz * endTrim };
|
||||
const headLength = Math.min(0.4, (length - trimTotal) * 0.65); const headBase = { x: end.x - ux * headLength, z: end.z - uz * headLength }; const halfWidth = Math.min(0.2, Math.max(0.04, headLength * 0.5));
|
||||
return { start, end, headBase, perpendicular: { x: -uz, z: ux }, halfWidth, shaftLength: length - trimTotal };
|
||||
}
|
||||
|
||||
function makePassArrow(points) {
|
||||
const visual = passArrowVisualGeometry(points); if (!visual) return null;
|
||||
const start = new THREE.Vector3(visual.start.x, 0.22, visual.start.z); const end = new THREE.Vector3(visual.end.x, 0.22, visual.end.z); const base = new THREE.Vector3(visual.headBase.x, 0.22, visual.headBase.z); const perpendicular = new THREE.Vector3(visual.perpendicular.x, 0, visual.perpendicular.z); const geometry = new THREE.BufferGeometry().setFromPoints([start, end]); const shaft = new THREE.Line(geometry, new THREE.LineDashedMaterial({ color: palette.pass, dashSize: 0.28, gapSize: 0.18, depthWrite: false })); shaft.computeLineDistances(); const headGeometry = new THREE.BufferGeometry().setFromPoints([end, base.clone().addScaledVector(perpendicular, visual.halfWidth), end, base.clone().addScaledVector(perpendicular, -visual.halfWidth)]); const head = new THREE.LineSegments(headGeometry, new THREE.LineBasicMaterial({ color: palette.pass, depthWrite: false })); const group = new THREE.Group(); group.add(shaft, head); return group;
|
||||
}
|
||||
|
||||
function makeScreenMarker() { const material = new THREE.MeshBasicMaterial({ color: 0x8dd8ff, depthWrite: false }); const crossbar = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.06, 0.14), material); const stem = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.06, 0.9), material); stem.position.z = -0.45; const group = new THREE.Group(); group.add(crossbar, stem); return group; }
|
||||
|
||||
function disposeOverlay(overlay) { overlay.traverse((child) => { child.geometry?.dispose(); child.material?.dispose(); }); }
|
||||
|
||||
export function pathSignature(points) { return points.map((point) => `${point.x},${point.z}`).join('|'); }
|
||||
export function possessionVisualState(playbackSample, ownerLocation, ownerFacing = 0) {
|
||||
if (playbackSample?.ballInFlight || playbackSample?.ballAtRim) return { haloVisible: false, ballLocation: playbackSample.ballLocation || null };
|
||||
if (!playbackSample?.ballOwnerId || !ownerLocation) return { haloVisible: false, ballLocation: null };
|
||||
return { haloVisible: true, ballLocation: { x: ownerLocation.x + 0.62 * Math.cos(ownerFacing), z: ownerLocation.z - 0.62 * Math.sin(ownerFacing) } };
|
||||
}
|
||||
export function passArrowPoints(play, sequence, action) {
|
||||
if (action?.type !== 'pass') return [];
|
||||
const target = play.players.find((player) => player.id === action.targetPlayerId);
|
||||
const targetTrack = sequence?.tracks.find((track) => track.playerId === action.targetPlayerId);
|
||||
if (!target || target.team !== 'offense' || !targetTrack) return [];
|
||||
const points = [action.location, finalTrackLocation(targetTrack)];
|
||||
return points.every(Boolean) && distance(points[0], points[1]) > 1e-6 ? points : [];
|
||||
}
|
||||
export function screenMarkerState(track, actionIndex) {
|
||||
const action = track?.actions?.[actionIndex];
|
||||
return action?.type === 'screen' ? { location: { ...action.location }, facing: resolveFacing(track, actionIndex) } : null;
|
||||
}
|
||||
|
||||
export function createBoard(container) {
|
||||
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x192922);
|
||||
scene.add(new THREE.HemisphereLight(0xe9f6ff, 0x15202c, 2));
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; container.appendChild(renderer.domElement);
|
||||
const cameras = createCameras(1); const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2();
|
||||
const courtPlane = new THREE.Mesh(new THREE.PlaneGeometry(COURT.width, COURT.length), new THREE.MeshBasicMaterial({ visible: false })); courtPlane.rotation.x = -Math.PI / 2; courtPlane.position.set(0, 0.1, COURT.length / 2); scene.add(courtPlane);
|
||||
addCourt(scene);
|
||||
const playerObjects = new Map(); const pathObjects = new Map(); const screenMarkers = new Map(); const passArrowObjects = new Map(); const arrow = new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(), 1.6, palette.selected, 0.28, 0.2); arrow.visible = false; scene.add(arrow);
|
||||
const povLabelWorldPosition = new THREE.Vector3();
|
||||
const ball = new THREE.Mesh(new THREE.SphereGeometry(0.2, 16, 12), new THREE.MeshStandardMaterial({ color: 0xf47b20, roughness: 0.7 })); scene.add(ball);
|
||||
const possessionHalo = new THREE.Mesh(new THREE.RingGeometry(0.62, 0.76, 32), new THREE.MeshBasicMaterial({ color: 0xb6c8a3, transparent: true, opacity: 0.8, side: THREE.DoubleSide })); possessionHalo.rotation.x = -Math.PI / 2; possessionHalo.position.y = 0.18; possessionHalo.visible = false; scene.add(possessionHalo);
|
||||
|
||||
function resize() { const width = container.clientWidth || 800; const height = container.clientHeight || 600; renderer.setSize(width, height, false); resizeCameras(cameras, width / height); }
|
||||
let previousPovPlayer = null; let previousElapsed = null;
|
||||
function render(play, appState, playbackSample, delta = 0, targetRenderer = renderer, targetViewportHeight = container.clientHeight || 600) {
|
||||
scene.getObjectByName('physicalHoop').visible = appState.view === 'pov';
|
||||
scene.getObjectByName('physicalBackboard').visible = appState.view === 'pov';
|
||||
scene.getObjectByName('diagramHoop').visible = appState.view !== 'pov';
|
||||
const sequence = play.sequences[playbackSample?.sequenceIndex ?? appState.selectedSequence] || play.sequences[0];
|
||||
const positions = playbackSample?.locations || Object.fromEntries(play.players.map((player) => [player.id, player.location]));
|
||||
const activePathKeys = new Set(sequence.tracks.map((track) => `${sequence.id}:${track.playerId}`)); const activeMarkerKeys = new Set();
|
||||
const activePassArrowKeys = new Set();
|
||||
for (const [key, path] of pathObjects) if (!activePathKeys.has(key)) { scene.remove(path); path.geometry.dispose(); path.material.dispose(); pathObjects.delete(key); }
|
||||
for (const [key, marker] of screenMarkers) if (!key.startsWith(`${sequence.id}:`)) { scene.remove(marker); disposeOverlay(marker); screenMarkers.delete(key); }
|
||||
for (const [key, passArrow] of passArrowObjects) if (!key.startsWith(`${sequence.id}:`)) { scene.remove(passArrow); disposeOverlay(passArrow); passArrowObjects.delete(key); }
|
||||
for (const player of play.players) {
|
||||
let object = playerObjects.get(player.id);
|
||||
if (!object) { object = new THREE.Group(); object.userData.playerId = player.id; object.add(new THREE.Mesh(new THREE.CylinderGeometry(0.38, 0.38, 0.16, 32), new THREE.MeshBasicMaterial({ color: player.team === 'offense' ? palette.offense : palette.defense }))); const ring = new THREE.Mesh(new THREE.TorusGeometry(0.52, 0.07, 8, 24), new THREE.MeshBasicMaterial({ color: palette.selected })); ring.rotation.x = Math.PI / 2; ring.position.y = -0.3; ring.name = 'selectionRing'; ring.visible = false; object.add(ring); object.add(labelSprite(`${player.team === 'offense' ? 'O' : 'D'}${player.number}`, '#ffffff')); scene.add(object); playerObjects.set(player.id, object); }
|
||||
const location = positions[player.id] || player.location; object.position.set(location.x, 0.35, location.z); object.children[0].material.color.set(player.team === 'offense' ? palette.offense : palette.defense); object.children.find((child) => child.name === 'selectionRing').visible = player.id === appState.selectedPlayerId;
|
||||
object.rotation.y = playbackSample?.samples?.[player.id]?.facing || 0;
|
||||
object.visible = appState.view !== 'pov' || player.id !== appState.selectedPlayerId;
|
||||
let avatar = object.getObjectByName('avatar');
|
||||
if (!avatar) { avatar = createPlayerAvatar(player.team === 'offense' ? palette.offense : palette.defense); object.add(avatar); }
|
||||
avatar.visible = appState.view === 'pov'; object.children[0].visible = appState.view !== 'pov';
|
||||
const label = object.children.find(child => child.isSprite); if (label) { const pov = appState.view === 'pov'; label.position.y = pov ? 1.73 : 0.62; label.scale.set(pov ? 0.72 : 1.1, pov ? 0.36 : 0.55, 1); }
|
||||
posePlayerAvatar(avatar, playbackSample?.samples?.[player.id], location, playbackSample?.elapsed || 0, Boolean(playbackSample?.ballInFlight && playbackSample?.samples?.[player.id]?.lookAt?.type === 'rim' && sequence.tracks.find(track => track.playerId === player.id)?.actions.at(-1)?.type === 'shoot'));
|
||||
object.userData.playerId = player.id;
|
||||
const track = sequence.tracks.find((candidate) => candidate.playerId === player.id); const pathKey = `${sequence.id}:${player.id}`; const points = track ? trackPointsForPath(track) : []; const signature = pathSignature(points); const previousPath = pathObjects.get(pathKey);
|
||||
if (points.length > 1 && (!previousPath || previousPath.userData.signature !== signature)) { if (previousPath) { scene.remove(previousPath); previousPath.geometry.dispose(); previousPath.material.dispose(); } const path = makePath(points, palette.move); path.userData.signature = signature; scene.add(path); pathObjects.set(pathKey, path); }
|
||||
const currentPath = pathObjects.get(pathKey); if (currentPath) { currentPath.visible = appState.view !== 'pov'; currentPath.material.opacity = player.id === appState.selectedPlayerId ? 1 : 0.8; }
|
||||
if (points.length <= 1 && previousPath) { scene.remove(previousPath); previousPath.geometry.dispose(); previousPath.material.dispose(); pathObjects.delete(pathKey); }
|
||||
for (const [actionIndex, action] of (track?.actions || []).entries()) {
|
||||
const markerState = screenMarkerState(track, actionIndex);
|
||||
if (markerState) { const markerKey = `${sequence.id}:${player.id}:${action.id}`; activeMarkerKeys.add(markerKey); let marker = screenMarkers.get(markerKey); if (!marker) { marker = makeScreenMarker(); scene.add(marker); screenMarkers.set(markerKey, marker); } marker.position.set(markerState.location.x, 0.22, markerState.location.z); marker.rotation.y = markerState.facing; }
|
||||
if (action.type === 'pass') { const passPoints = passArrowPoints(play, sequence, action); const passKey = `${sequence.id}:${player.id}:${action.id}`; if (passPoints.length) { activePassArrowKeys.add(passKey); const signature = pathSignature(passPoints); const previousPassArrow = passArrowObjects.get(passKey); if (!previousPassArrow || previousPassArrow.userData.signature !== signature) { if (previousPassArrow) { scene.remove(previousPassArrow); disposeOverlay(previousPassArrow); } const passArrow = makePassArrow(passPoints); passArrow.userData.signature = signature; scene.add(passArrow); passArrowObjects.set(passKey, passArrow); } } }
|
||||
}
|
||||
}
|
||||
for (const [key, marker] of screenMarkers) if (!activeMarkerKeys.has(key)) { scene.remove(marker); disposeOverlay(marker); screenMarkers.delete(key); }
|
||||
for (const [key, passArrow] of passArrowObjects) if (!activePassArrowKeys.has(key)) { scene.remove(passArrow); disposeOverlay(passArrow); passArrowObjects.delete(key); }
|
||||
const hasSampleOwner = playbackSample && Object.hasOwn(playbackSample, 'ballOwnerId'); const ballOwnerId = hasSampleOwner ? playbackSample.ballOwnerId : sequence.ballOwnerId || play.players.find((player) => player.team === 'offense')?.id; const ownerLocation = positions[ballOwnerId]; const possession = possessionVisualState({ ...(playbackSample || {}), ballOwnerId }, ownerLocation, playbackSample?.samples?.[ballOwnerId]?.facing || 0); possessionHalo.visible = possession.haloVisible; if (ownerLocation) possessionHalo.position.set(ownerLocation.x, 0.18, ownerLocation.z); ball.visible = Boolean(possession.ballLocation); if (possession.ballLocation) ball.position.set(possession.ballLocation.x, playbackSample?.ballHeight ?? BALL_HOLD_HEIGHT, possession.ballLocation.z);
|
||||
const selectedSample = playbackSample?.samples?.[appState.selectedPlayerId];
|
||||
if (appState.view === 'tactical' && appState.selectedAction >= 0 && selectedSample?.lookAtLocation) { const position = positions[appState.selectedPlayerId]; const target = selectedSample.lookAtLocation; const direction = new THREE.Vector3(target.x - position.x, 0, target.z - position.z); if (direction.lengthSq() > 1e-5) { arrow.position.set(position.x, 0.8, position.z); const length = direction.length(); arrow.setDirection(direction.normalize()); arrow.setLength(Math.min(3.8, Math.max(0.8, length))); arrow.visible = true; } } else arrow.visible = false;
|
||||
if (appState.view === 'pov') {
|
||||
const sample = selectedSample || { facing: 0 }; const target = selectedSample?.lookAtLocation;
|
||||
const height = sample.lookAt?.type === 'rim' ? RIM_HEIGHT : sample.lookAt?.type === 'ball' ? (playbackSample.ballHeight ?? BALL_HOLD_HEIGHT) : 1.5;
|
||||
const elapsed = playbackSample?.elapsed ?? 0;
|
||||
const smooth = Boolean(playbackSample?.playing && previousPovPlayer === appState.selectedPlayerId && previousElapsed !== null && elapsed >= previousElapsed && elapsed - previousElapsed < 0.3);
|
||||
updatePovCamera(cameras.pov, positions[appState.selectedPlayerId] || play.players[0].location, target ? { ...target, y: height } : null, sample.facing, { smooth, delta });
|
||||
cameras.pov.updateMatrixWorld();
|
||||
const viewportHeight = targetViewportHeight;
|
||||
for (const object of playerObjects.values()) {
|
||||
const label = object.children.find(child => child.isSprite);
|
||||
if (!label || !object.visible) continue;
|
||||
label.getWorldPosition(povLabelWorldPosition);
|
||||
cameras.pov.worldToLocal(povLabelWorldPosition);
|
||||
const labelHeight = povLabelScaleY(-povLabelWorldPosition.z, viewportHeight, cameras.pov.fov);
|
||||
label.scale.set(labelHeight * 2, labelHeight, 1);
|
||||
}
|
||||
previousPovPlayer = appState.selectedPlayerId; previousElapsed = elapsed;
|
||||
} else { previousPovPlayer = null; previousElapsed = null; }
|
||||
targetRenderer.render(scene, appState.view === 'pov' ? cameras.pov : cameras.tactical);
|
||||
}
|
||||
function pick(event, appState) { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; const camera = appState.view === 'pov' ? cameras.pov : cameras.tactical; raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects([...playerObjects.values()], true); const playerHit = hits.find((hit) => hit.object.parent?.userData.playerId || hit.object.userData.playerId); if (playerHit) return { type: 'player', playerId: playerHit.object.parent?.userData.playerId || playerHit.object.userData.playerId }; const courtHit = raycaster.intersectObject(courtPlane)[0]; if (courtHit) return { type: 'location', location: { x: courtHit.point.x, z: courtHit.point.z } }; return null; }
|
||||
function createExportSurface(width = 1280, height = 720) {
|
||||
const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height;
|
||||
const exportRenderer = new THREE.WebGLRenderer({ canvas, antialias: true }); exportRenderer.setPixelRatio(1); exportRenderer.setSize(width, height, false); exportRenderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
const cameraState = Object.fromEntries(Object.entries(cameras).map(([name, camera]) => [name, { position: camera.position.clone(), quaternion: camera.quaternion.clone(), fov: camera.fov, aspect: camera.aspect, near: camera.near, far: camera.far, zoom: camera.zoom, view: camera.view ? { ...camera.view } : null }]));
|
||||
const previousCameraHistory = { player: previousPovPlayer, elapsed: previousElapsed };
|
||||
let firstFrame = true;
|
||||
return {
|
||||
canvas,
|
||||
render(play, appState, playbackSample, delta = 0) { if (firstFrame) { previousPovPlayer = null; previousElapsed = null; firstFrame = false; } resizeCameras(cameras, width / height); render(play, appState, playbackSample, delta, exportRenderer, height); },
|
||||
dispose() { for (const [name, original] of Object.entries(cameraState)) { const camera = cameras[name]; camera.position.copy(original.position); camera.quaternion.copy(original.quaternion); camera.fov = original.fov; camera.aspect = original.aspect; camera.near = original.near; camera.far = original.far; camera.zoom = original.zoom; if (original.view?.enabled) camera.setViewOffset(original.view.fullWidth, original.view.fullHeight, original.view.offsetX, original.view.offsetY, original.view.width, original.view.height); else camera.clearViewOffset(); camera.updateProjectionMatrix(); camera.updateMatrixWorld(); } previousPovPlayer = previousCameraHistory.player; previousElapsed = previousCameraHistory.elapsed; exportRenderer.dispose(); exportRenderer.forceContextLoss?.(); canvas.width = 1; canvas.height = 1; },
|
||||
};
|
||||
}
|
||||
resize(); new ResizeObserver(resize).observe(container); return { scene, renderer, cameras, render, createExportSurface, pick, resize, courtLocation(event) { const rect = renderer.domElement.getBoundingClientRect(); pointer.set((event.clientX - rect.left) / rect.width * 2 - 1, -(event.clientY - rect.top) / rect.height * 2 + 1); raycaster.setFromCamera(pointer, cameras.tactical); const hit = raycaster.intersectObject(courtPlane)[0]; return hit ? { x: hit.point.x, z: hit.point.z } : null; } };
|
||||
}
|
||||
|
||||
function trackPointsForPath(track) { return [track.startLocation, ...track.actions.map((action) => action.location)]; }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { addAction, addSequence, createInitialPlay, removeAction, removeSequence, restoreTrackActionSnapshot, setPlayerStartLocation, trackActionSnapshot } from './domain.js';
|
||||
import { assertValidPlay } from './playRepository.js';
|
||||
|
||||
export function createAppState(name = '새 전술', defenseType = 'man-to-man') {
|
||||
const play = createInitialPlay(name, defenseType);
|
||||
return { play, selectedPlayerId: 'offense-1', selectedSequence: 0, selectedAction: -1, mode: 'start', view: 'tactical', playing: false, history: createSequenceHistory(play) };
|
||||
}
|
||||
export function createAppStateFromPlay(sourcePlay) {
|
||||
assertValidPlay(sourcePlay); const play = cloneValue(sourcePlay); const selectedPlayerId = play.players.some((player) => player.id === 'offense-1') ? 'offense-1' : play.players[0]?.id || null;
|
||||
return { play, selectedPlayerId, selectedSequence: 0, selectedAction: -1, mode: 'move', view: 'tactical', playing: false, history: createSequenceHistory(play) };
|
||||
}
|
||||
export function serializePlay(play) { return JSON.stringify(play); }
|
||||
function cloneValue(value) { return JSON.parse(JSON.stringify(value)); }
|
||||
function samePlay(a, b) { return serializePlay(a) === serializePlay(b); }
|
||||
|
||||
export function createSequenceHistory(play, limit = 50) {
|
||||
return { limit, bySequenceId: Object.fromEntries((play?.sequences || []).map((sequence) => [sequence.id, Object.fromEntries((play.players || []).map((player) => [player.id, { past: [], future: [] }]))])) };
|
||||
}
|
||||
function normalizeActionHistory(history, play) {
|
||||
const limit = history?.limit || 50; const bySequenceId = {};
|
||||
for (const sequence of play?.sequences || []) {
|
||||
const oldTracks = history?.bySequenceId?.[sequence.id] || {}; bySequenceId[sequence.id] = Object.fromEntries((play.players || []).map((player) => { const entry = oldTracks[player.id]; return [player.id, { past: cloneValue(entry?.past || []).slice(-limit), future: cloneValue(entry?.future || []) }]; }));
|
||||
}
|
||||
return { limit, bySequenceId };
|
||||
}
|
||||
export function recordActionHistory(history, play, sequenceId, playerId) {
|
||||
const snapshot = trackActionSnapshot(play, sequenceId, playerId); if (!snapshot) return normalizeActionHistory(history, play);
|
||||
const next = normalizeActionHistory(history, play); const entry = next.bySequenceId[sequenceId][playerId]; entry.past = [...entry.past, snapshot].slice(-next.limit); entry.future = []; return next;
|
||||
}
|
||||
export function commitActionEdit(state, nextPlay, sequenceIndex = state.selectedSequence, playerId = state.selectedPlayerId) {
|
||||
const sequence = state.play.sequences[sequenceIndex]; const before = sequence?.tracks.find((track) => track.playerId === playerId); const after = nextPlay?.sequences?.find((candidate) => candidate.id === sequence?.id)?.tracks.find((track) => track.playerId === playerId);
|
||||
if (!before || !after) return state;
|
||||
if (serializePlay(before.actions) === serializePlay(after.actions)) return samePlay(state.play, nextPlay) ? state : { ...state, play: nextPlay };
|
||||
return { ...state, play: nextPlay, history: recordActionHistory(state.history, state.play, sequence.id, playerId) };
|
||||
}
|
||||
function restoreActionHistoryState(state, result) { return { ...state, play: result.play, history: result.history, selectedAction: -1 }; }
|
||||
function actionHistoryResult(history, currentPlay, sequenceId, playerId, direction) {
|
||||
const normalized = normalizeActionHistory(history, currentPlay); const entry = normalized.bySequenceId?.[sequenceId]?.[playerId]; if (!entry) return { play: currentPlay, history };
|
||||
if (direction === 'undo') {
|
||||
if (!entry.past.length) return { play: currentPlay, history }; const snapshot = entry.past.at(-1); const nextPlay = restoreTrackActionSnapshot(currentPlay, snapshot); if (nextPlay === currentPlay) return { play: currentPlay, history }; entry.past = entry.past.slice(0, -1); entry.future = [trackActionSnapshot(currentPlay, sequenceId, playerId), ...entry.future]; return { play: nextPlay, history: normalized };
|
||||
}
|
||||
if (!entry.future.length) return { play: currentPlay, history }; const snapshot = entry.future[0]; const nextPlay = restoreTrackActionSnapshot(currentPlay, snapshot); if (nextPlay === currentPlay) return { play: currentPlay, history }; entry.future = entry.future.slice(1); entry.past = [...entry.past, trackActionSnapshot(currentPlay, sequenceId, playerId)].slice(-normalized.limit); return { play: nextPlay, history: normalized };
|
||||
}
|
||||
export function canUndoActionEdit(state) { const sequenceId = state.play.sequences[state.selectedSequence]?.id; const entry = state.history?.bySequenceId?.[sequenceId]?.[state.selectedPlayerId]; const snapshot = entry?.past?.at(-1); return Boolean(snapshot && restoreTrackActionSnapshot(state.play, snapshot) !== state.play); }
|
||||
export function canRedoActionEdit(state) { const sequenceId = state.play.sequences[state.selectedSequence]?.id; const entry = state.history?.bySequenceId?.[sequenceId]?.[state.selectedPlayerId]; const snapshot = entry?.future?.[0]; return Boolean(snapshot && restoreTrackActionSnapshot(state.play, snapshot) !== state.play); }
|
||||
export function undoActionEdit(state) { const sequenceId = state.play.sequences[state.selectedSequence]?.id; const result = actionHistoryResult(state.history, state.play, sequenceId, state.selectedPlayerId, 'undo'); return result.play === state.play ? state : restoreActionHistoryState(state, result); }
|
||||
export function redoActionEdit(state) { const sequenceId = state.play.sequences[state.selectedSequence]?.id; const result = actionHistoryResult(state.history, state.play, sequenceId, state.selectedPlayerId, 'redo'); return result.play === state.play ? state : restoreActionHistoryState(state, result); }
|
||||
|
||||
export function addMoveAction(state, location) { const play = addAction(state.play, state.selectedSequence, state.selectedPlayerId, location); const track = play.sequences[state.selectedSequence]?.tracks.find((candidate) => candidate.playerId === state.selectedPlayerId); return track && play !== state.play ? { ...commitActionEdit(state, play), selectedAction: track.actions.length - 1 } : state; }
|
||||
export function setSelectedPlayerStart(state, location) { const play = setPlayerStartLocation(state.play, state.selectedPlayerId, location); return { ...state, play, selectedAction: -1 }; }
|
||||
export function addPlaySequence(state) { const play = addSequence(state.play); return { ...state, play, history: normalizeActionHistory(state.history, play), selectedSequence: play.sequences.length - 1, selectedAction: -1 }; }
|
||||
export function deletePlaySequence(state, index = state.selectedSequence) {
|
||||
const play = removeSequence(state.play, index); if (samePlay(state.play, play)) return state; const selectedSequence = index < state.selectedSequence ? state.selectedSequence - 1 : Math.min(state.selectedSequence, play.sequences.length - 1); return { ...state, play, history: normalizeActionHistory(state.history, play), selectedSequence: Math.max(0, selectedSequence), selectedAction: -1 };
|
||||
}
|
||||
export function deleteSelectedAction(state, index = state.selectedAction) { const play = removeAction(state.play, state.selectedSequence, state.selectedPlayerId, index); return { ...commitActionEdit(state, play), selectedAction: -1 }; }
|
||||
@@ -0,0 +1,78 @@
|
||||
:root { color-scheme:light; font-family:Inter,Pretendard,"Noto Sans KR","Segoe UI","Malgun Gothic",sans-serif; font-synthesis:none; --paper:#f8f9f6; --surface:#fff; --ink:#23342e; --muted:#78847d; --line:#e7ebe5; --accent:#d96c43; --accent-soft:#fbede5; --court:#192922; --green:#306a51; --radius:12px; --shadow:0 12px 36px #12221b12; background:var(--paper); color:var(--ink); }
|
||||
* { box-sizing:border-box; } body { margin:0; min-width:320px; } button,input,select { font:inherit; }
|
||||
button { display:inline-flex; align-items:center; justify-content:center; gap:7px; min-height:38px; padding:8px 12px; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--ink); font-size:12px; font-weight:550; cursor:pointer; transition:background 140ms,border-color 140ms,box-shadow 140ms,transform 140ms; }
|
||||
button:hover:not(:disabled) { background:#f0f3ec; border-color:#c5d0c4; } button:active:not(:disabled) { transform:translateY(1px); } button:disabled { opacity:.38; cursor:not-allowed; }
|
||||
button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible { outline:3px solid #d96c4377; outline-offset:3px; } [hidden] { display:none!important; }
|
||||
input,select { width:100%; min-width:0; min-height:39px; padding:9px 11px; border:1px solid var(--line); border-radius:8px; background:var(--paper); color:var(--ink); font-size:12px; } input[type=number] { font-variant-numeric:tabular-nums; }
|
||||
.icon { display:block; flex-shrink:0; width:19px; height:19px; } .icon-button { padding:8px; width:38px; flex-shrink:0; } .section-kicker { display:block; font-size:9px; font-weight:700; letter-spacing:.16em; color:var(--muted); }
|
||||
.shell { width:100%; height:100dvh; min-height:620px; display:grid; grid-template-rows:76px minmax(0,1fr) 150px; }
|
||||
.topbar { position:relative; z-index:10; display:flex; align-items:center; gap:30px; padding:0 26px; background:white; border-bottom:1px solid var(--line); }
|
||||
.brand { display:flex; align-items:center; gap:10px; flex-shrink:0; width:185px; } .brand-mark { width:37px; height:37px; display:grid; place-items:center; color:#fff4e9; background:var(--accent); border-radius:10px; transform:rotate(-6deg); } .brand-mark .icon { width:26px; height:26px; }
|
||||
.brand strong { font-size:26px; letter-spacing:-.06em; font-weight:850; line-height:1; } .brand strong span { font-weight:400; } .brand i { font-size:10px; font-style:normal; vertical-align:super; margin-left:3px; } .brand small { display:block; margin-top:5px; font-size:7px; letter-spacing:.14em; color:var(--muted); }
|
||||
.document-title { display:flex; align-items:center; gap:18px; min-width:0; flex:1; } .breadcrumb { white-space:nowrap; font-size:11px; color:var(--muted); } .breadcrumb span { margin-left:20px; color:#c0c9c1; }
|
||||
#play-name { width:min(250px,100%); background:transparent; border-color:transparent; font-size:14px; font-weight:650; padding-left:0; } #play-name:hover,#play-name:focus { border-color:var(--line); padding-left:10px; }
|
||||
.top-actions { display:flex; align-items:center; gap:10px; } #save-status { max-width:175px; font-size:10px; color:#748579; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-right:7px; } #save-status::before { content:''; display:inline-block; width:5px; height:5px; border-radius:50%; background:#78a085; margin-right:7px; vertical-align:1px; }
|
||||
button.primary { padding-inline:18px; background:var(--accent); border-color:var(--accent); color:white; box-shadow:0 3px 8px #d96c4320; } button.primary:hover:not(:disabled) { background:#c65c36; border-color:#c65c36; } #toggle-menu { border-color:transparent; }
|
||||
.project-menu { position:absolute; right:22px; top:64px; width:330px; padding:22px; background:white; border:1px solid var(--line); border-radius:14px; box-shadow:var(--shadow); max-height:calc(100dvh - 90px); overflow:auto; } .menu-heading { font-size:15px; font-weight:700; margin-bottom:20px; }
|
||||
.project-menu[data-view=video] { width:min(390px,calc(100vw - 24px)); } .project-menu[data-view=video]>.menu-heading,.project-menu[data-view=video]>.setup,.project-menu[data-view=video]>.saved-controls,.project-menu[data-view=video]>.file-tools,.project-menu[data-view=video]>.debug { display:none; } .project-menu[data-view=video] .video-tools { margin-top:0; padding-top:0; border-top:0; } .video-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } .video-heading .menu-heading { margin-bottom:0; } .video-heading .icon-button { width:30px; min-height:30px; padding:3px; border-color:transparent; color:var(--muted); font-size:21px; line-height:1; }
|
||||
.setup,.saved-controls { display:grid; gap:8px; padding-bottom:18px; margin-bottom:18px; border-bottom:1px solid var(--line); } .project-menu label { font-size:11px; color:var(--muted); } .file-tools { display:flex; gap:6px; } .file-tools button { flex:1; padding:8px 4px; font-size:11px; }
|
||||
.video-tools { margin-top:18px; padding-top:18px; border-top:1px solid var(--line); } .video-tools .menu-heading { margin-bottom:8px; font-size:13px; } .video-help { margin:0 0 10px; color:var(--muted); font-size:10px; line-height:1.65; } .video-tool-row,.video-share-row { display:flex; gap:6px; } .video-tool-row select { flex:1.1; min-height:34px; padding:6px 7px; font-size:10px; } .video-tool-row button,.video-share-row button { flex:1; min-height:34px; padding:6px 7px; font-size:10px; } .video-tool-row #cancel-video { flex:0 0 auto; color:#a97969; } #video-progress { display:block; width:100%; height:7px; margin:10px 0 6px; accent-color:var(--accent); } .video-status { display:block; min-height:28px; color:var(--muted); font-size:9px; line-height:1.55; } #video-preview { display:block; width:100%; margin:10px 0; border-radius:8px; background:#192922; } .video-share-row { margin-top:8px; } .video-share-row .icon { width:14px; height:14px; }
|
||||
.debug { margin-top:16px; font-size:10px; color:var(--muted); } .debug summary { cursor:pointer; } .data-preview { overflow:auto; max-height:110px; font-size:9px; } .mobile-tabs { display:none; }
|
||||
.workspace { display:grid; grid-template-columns:222px minmax(0,1fr) 278px; min-height:0; padding:18px 20px; gap:18px; }
|
||||
.panel { min-height:0; overflow:auto; scrollbar-width:thin; scrollbar-color:#d4dbd2 transparent; padding:7px 0; } .panel-heading { padding:0 8px 19px; }
|
||||
h2 { font-size:17px; font-weight:700; letter-spacing:-.04em; margin:8px 0 0; display:flex; align-items:center; justify-content:space-between; } .count-pill { font-size:10px; color:var(--muted); background:#ebefe8; border-radius:5px; padding:4px 6px; font-weight:500; letter-spacing:0; }
|
||||
.panel-title { display:flex; justify-content:space-between; align-items:center; padding:0 8px; font-size:10px; color:var(--muted); margin-bottom:10px; } #selected-label { background:var(--ink); color:white; border-radius:4px; font-size:9px; padding:3px 6px; }
|
||||
.team-label { display:flex; align-items:center; gap:8px; margin:16px 8px 8px; color:var(--muted); font-size:9px; letter-spacing:.12em; font-weight:700; } .team-label::after { content:''; flex:1; height:1px; background:var(--line); }
|
||||
.roster-player { width:100%; display:flex; justify-content:flex-start; padding:6px 10px; min-height:39px; border-color:transparent; background:transparent; margin:2px 0; border-radius:8px; font-size:12px; } .roster-player.selected { border-color:#eed7c7; background:var(--accent-soft); } .roster-player small { color:#a0aaa3; margin-left:auto; font-size:9px; font-variant-numeric:tabular-nums; }
|
||||
.dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:7px; } .dot.offense { background:#d96c43; box-shadow:0 0 0 4px #f6e4d8; } .dot.defense { background:#719db6; box-shadow:0 0 0 4px #e3ecf0; } .ball-mark { font-size:7px; font-weight:700; color:#b46d3e; border-radius:4px; padding:3px 4px; background:#f1e5cb; }
|
||||
.ball-owner-control { margin-top:20px; border:1px solid var(--line); background:white; border-radius:var(--radius); padding:13px; } .ball-owner-control>span { display:block; font-size:10px; line-height:1.7; color:var(--muted); } .ball-owner-control>.section-kicker { font-size:8px; margin-bottom:7px; } .ball-owner-control button { width:100%; margin-top:10px; font-size:10px; background:var(--paper); min-height:32px; }
|
||||
.coach-note { margin-top:16px; padding:12px 9px; border-top:1px solid var(--line); font-size:11px; } .coach-note summary { cursor:pointer; color:#687970; } .coach-note summary span { float:right; } .help { font-size:10px; line-height:1.8; color:var(--muted); padding-top:10px; } .help strong { color:var(--ink); } .panel-footnote { font-size:9px; color:#8e9b92; padding:12px 8px; line-height:1.8; } .live-dot { display:inline-block; width:5px; height:5px; background:#97b499; border-radius:50%; margin-right:6px; }
|
||||
.board-wrap { position:relative; min-width:0; min-height:0; background:var(--court); border-radius:18px; overflow:hidden; box-shadow:0 6px 20px #19292212; }
|
||||
.canvas-header { position:absolute; left:24px; right:24px; top:22px; z-index:2; display:flex; justify-content:space-between; align-items:center; gap:8px; pointer-events:none; } .canvas-header .section-kicker { color:#83948a; font-size:8px; }
|
||||
h1 { display:flex; align-items:center; gap:10px; color:#f4f2e8; font-size:17px; font-weight:500; letter-spacing:-.04em; margin:7px 0 0; } #canvas-step { font-family:ui-monospace,monospace; font-size:10px; color:#aab7a8; border:1px solid #405047; padding:3px 5px; border-radius:4px; }
|
||||
.view-group { display:flex; background:#111f19; padding:4px; border-radius:8px; pointer-events:auto; } .view-group button { background:transparent; color:#92a095; border-color:transparent; font-size:10px; min-height:30px; padding:5px 8px; border-radius:5px; } .view-group .icon { width:14px; height:14px; } .view-group button.active { background:#34443a; color:#f3f3e9; box-shadow:0 2px 5px #0002; } .view-group button:hover:not(:disabled) { background:#3b4e42; border-color:transparent; }
|
||||
.canvas-meta { position:absolute; top:85px; left:24px; right:24px; display:flex; justify-content:space-between; color:#829187; font-size:8px; letter-spacing:.09em; pointer-events:none; z-index:1; } .canvas-meta b { margin:0 7px; font-weight:normal; }
|
||||
.board { position:absolute; inset:108px 10px 110px; } .board canvas { display:block; width:100%; height:100%; touch-action:none; }
|
||||
.mode-group { position:absolute; bottom:43px; left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:3px; background:#fdfdf7; padding:5px; border-radius:12px; box-shadow:0 7px 20px #0003; max-width:calc(100% - 28px); z-index:2; } .mode-group button { display:flex; flex-direction:column; gap:4px; background:transparent; border-color:transparent; border-radius:8px; color:#6b7970; min-width:49px; padding:7px 10px; font-size:9px; } .mode-group button.active { color:#bf5c35; background:#f8e7da; border-color:#f0d6c4; } .mode-group button:hover:not(:disabled) { background:#f0f0e7; border-color:transparent; } .tool-divider { width:1px; height:28px; background:#e4e7df; margin:0 3px; }
|
||||
.board-status { position:absolute; bottom:14px; left:24px; right:24px; display:flex; justify-content:space-between; gap:12px; pointer-events:none; color:#a6b3a7; font-size:9px; line-height:1.5; } #status { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } #clock { color:#d8dfd3; font-family:ui-monospace,monospace; flex-shrink:0; } #cancel-gaze { position:absolute; z-index:3; top:105px; left:50%; transform:translateX(-50%); background:#f8e7da; border-color:#e6c8b4; font-size:10px; }
|
||||
.action-row { width:100%; margin:0 0 7px; padding:12px; display:grid; grid-template-columns:26px minmax(0,1fr); text-align:left; justify-content:stretch; gap:5px 9px; background:white; border-color:var(--line); border-radius:9px; } .action-row>span { grid-row:span 2; display:grid; place-items:center; width:25px; height:28px; background:#f0f3eb; color:#83917f; font-family:ui-monospace,monospace; font-size:10px; border-radius:5px; } .action-row b { font-size:11px; font-weight:600; } .action-row small { font-size:9px; color:#8a988d; } .action-row.selected { border-color:#e5ba9f; background:#fffaf5; box-shadow:0 2px 6px #d96c4308; } .action-row.selected>span { background:#f4e0d0; color:#bb7144; }
|
||||
.empty { position:relative; padding:50px 18px 24px; margin-bottom:18px; border:1px dashed #dbe2d8; border-radius:12px; color:#8b978e; font-size:11px; text-align:center; line-height:1.9; } .empty::before { content:'↗'; position:absolute; top:12px; left:calc(50% - 14px); width:28px; height:28px; border-radius:8px; display:grid; place-items:center; background:#eaf0e7; color:#85917c; font-size:19px; }
|
||||
#action-properties { margin:24px 0 0; padding:17px 14px 14px; border:1px solid var(--line); border-radius:12px; min-width:0; background:white; } #action-properties legend { display:flex; align-items:center; gap:7px; padding:0 5px; font-size:11px; font-weight:600; } #action-properties legend .icon { width:14px; height:14px; color:var(--muted); } #action-properties label { display:grid; gap:8px; font-size:10px; font-weight:550; color:#647368; } #action-properties p { margin:10px 0 18px; color:#8a968b; font-size:9px; line-height:1.8; } #action-properties:disabled { opacity:.55; }
|
||||
.coordinates { display:grid; grid-template-columns:1fr 1fr; gap:10px; } .coordinates label { display:block!important; color:#415347!important; } .coordinates label span { font-weight:normal; font-size:8px; color:#9aa396; margin-left:4px; } .coordinates input { margin-top:8px; } #apply-position { width:100%; margin-top:13px; background:#edf2e9; border-color:transparent; color:#496448; font-size:10px; justify-content:space-between; }
|
||||
.danger { color:#a97969; border-color:transparent; background:transparent; font-size:10px; } .full { width:100%; margin-top:9px; } .history-actions { display:flex; gap:7px; padding-top:12px; border-top:1px solid var(--line); margin-top:9px; } .history-actions button { flex:1; background:transparent; color:#7a897d; font-size:10px; min-height:33px; } .shortcut-note { display:flex; align-items:center; justify-content:center; gap:5px; font-size:8px; color:#99a297; margin-top:18px; } kbd { font-family:inherit; padding:3px 5px; border:1px solid #e0e5da; border-radius:4px; font-size:8px; background:#f1f4ed; }
|
||||
.playback-deck { display:grid; grid-template-columns:175px minmax(0,1fr); grid-template-rows:minmax(0,1fr) 60px; padding:0 26px; border-top:1px solid var(--line); background:white; } .sequence-heading { align-self:center; } .sequence-heading strong { display:block; font-size:13px; margin-top:7px; font-weight:600; } .sequences { display:flex; align-items:center; gap:8px; overflow:auto; min-width:0; scrollbar-width:thin; }
|
||||
.sequence-tab { flex:0 0 auto; min-width:118px; height:64px; justify-content:flex-start; border-radius:8px; background:#f6f8f2; color:#7c8b7c; font-size:10px; } .sequence-tab.selected { background:#eef2e7; color:#456744; border-color:#9db58c; box-shadow:0 0 0 2px #a6be9718; } .sequence-tab svg { width:48px; height:48px; } .sequence-tab .sequence-copy { display:grid; gap:4px; text-align:left; } .sequence-copy small { font-size:8px; color:#9aa58f; } .add-sequence { min-width:45px; height:64px; border-style:dashed; border-color:#d6decf; background:transparent; font-size:20px; font-weight:300; color:#8ea181; } #delete-sequence { margin-left:auto; min-width:40px; }
|
||||
.playback-row { grid-column:1 / -1; display:flex; gap:25px; align-items:center; border-top:1px solid var(--line); } .transport { display:flex; gap:9px; align-items:center; width:150px; justify-content:center; flex-shrink:0; } .transport .icon-button { border:0; background:transparent; color:#85927f; } .play-button { border-radius:50%; width:38px; min-height:38px; border-color:var(--ink); background:var(--ink); color:white; padding:8px; } .play-button:hover:not(:disabled) { background:var(--green); border-color:var(--green); } .play-button .icon { width:16px; height:16px; margin-left:2px; fill:currentColor; stroke-width:1; }
|
||||
.timeline { display:flex; align-items:center; gap:12px; min-width:0; flex:1; } .timeline .icon-button { border-color:transparent; width:24px; padding:0; font-size:22px; color:#8e9e88; } .timeline input[type=range] { flex:1; min-width:30px; height:20px; min-height:20px; padding:0; accent-color:var(--accent); background:transparent; border:none; } .timeline output { font-family:ui-monospace,monospace; font-size:10px; color:#7f8c7b; white-space:nowrap; } .timeline select { width:64px; min-height:30px; padding:4px 6px; border-color:transparent; background:transparent; font-size:11px; } #loop { background:transparent; border-color:transparent; color:#889680; font-size:11px; } #loop[aria-pressed=true] { color:var(--green); background:#e7efdf; }
|
||||
@media(max-width:1180px) { .workspace { grid-template-columns:180px minmax(0,1fr) 230px; gap:12px; padding:14px; } .topbar { padding:0 18px; gap:18px; } .brand { width:170px; } .breadcrumb { display:none; } #save-status { max-width:120px; } .canvas-header { left:17px; right:17px; } .canvas-header h1 { font-size:15px; } .view-group button { gap:4px; padding:5px 6px; } .view-group .icon { display:none; } .mode-group button { min-width:43px; padding:6px 7px; } }
|
||||
@media(max-width:900px) {
|
||||
.shell { grid-template-rows:68px 47px minmax(0,1fr) 136px; min-height:660px; } .topbar { gap:14px; padding:0 16px; } .brand { width:auto; } .brand strong { font-size:24px; } .brand small,#save-status { display:none; }
|
||||
.mobile-tabs { display:flex; align-items:center; justify-content:center; gap:5px; padding:5px 15px; } .mobile-tabs button { min-height:34px; min-width:95px; border-color:transparent; background:transparent; color:#8b998b; font-size:11px; } .mobile-tabs .icon { width:15px; height:15px; } .mobile-tabs button.active { color:var(--ink); background:white; border-color:#e1e7db; box-shadow:0 2px 4px #11221105; }
|
||||
.workspace { position:relative; display:block; padding:0 14px 14px; } .board-wrap { height:100%; } .workspace .panel { display:none; position:absolute; top:0; bottom:14px; left:14px; right:14px; background:var(--paper); border:1px solid var(--line); border-radius:16px; padding:20px; z-index:4; box-shadow:var(--shadow); } .shell[data-panel=players] .left-panel,.shell[data-panel=actions] .right-panel { display:block; } #roster { display:grid; grid-template-columns:1fr 1fr; column-gap:12px; } .team-label { grid-column:1 / -1; } .roster-player { border-color:var(--line); min-height:44px; } .panel-footnote { display:none; }
|
||||
.playback-deck { padding:0 16px; grid-template-columns:110px minmax(0,1fr); grid-template-rows:minmax(0,1fr) 56px; } .sequence-heading .section-kicker { font-size:7px; } .sequence-heading strong { font-size:11px; } .sequence-tab { height:56px; min-width:105px; padding:5px; } .sequence-tab svg { width:42px; height:42px; } .add-sequence { height:56px; min-width:35px; } .transport { width:110px; gap:3px; } .playback-row { gap:8px; } .timeline { gap:5px; } .timeline output { font-size:9px; }
|
||||
}
|
||||
@media(max-width:520px) {
|
||||
.shell { grid-template-rows:92px 44px minmax(0,1fr) 134px; min-height:650px; } .topbar { align-items:flex-start; padding:14px 16px 0; gap:6px; } .brand-mark { width:30px; height:30px; border-radius:8px; } .brand-mark .icon { width:22px; height:22px; } .brand strong { font-size:24px; } .document-title { position:absolute; bottom:7px; left:16px; right:16px; } #play-name { width:100%; min-height:28px; font-size:12px; padding-block:4px; } .top-actions { margin-left:auto; gap:6px; } .top-actions button { min-height:32px; padding:6px 9px; font-size:10px; } .top-actions .icon { width:15px; height:15px; } #toggle-menu { width:32px; } .project-menu { top:54px; right:12px; width:min(330px,calc(100vw - 24px)); }
|
||||
.mobile-tabs { padding:3px 14px; } .mobile-tabs button { flex:1; min-width:0; } .workspace { padding:0 10px 10px; } .workspace .panel { left:10px; right:10px; bottom:10px; padding:18px; } .board-wrap { border-radius:13px; } .canvas-header { top:17px; left:16px; right:16px; } .canvas-header h1 { font-size:14px; } .canvas-header .section-kicker { font-size:7px; } .view-group { padding:3px; } .view-group button { font-size:9px; padding:4px 6px; } .canvas-meta { top:74px; left:16px; right:16px; font-size:7px; } .board { inset:91px 0 98px; } .mode-group { bottom:37px; padding:4px; border-radius:10px; } .mode-group button { min-width:52px; padding:6px 8px; min-height:47px; } .board-status { left:16px; right:16px; bottom:12px; font-size:8px; }
|
||||
.playback-deck { grid-template-columns:74px minmax(0,1fr); padding:0 12px; grid-template-rows:minmax(0,1fr) 58px; } .sequence-heading .section-kicker { font-size:6px; letter-spacing:.07em; } .sequence-heading strong { font-size:10px; } .sequence-tab { min-width:98px; } .transport { width:98px; gap:1px; } .transport .icon-button { width:28px; padding:5px; } .play-button { width:34px; min-height:34px; } .timeline { gap:4px; flex-wrap:wrap; position:relative; padding-top:12px; } .timeline input[type=range] { position:absolute; left:0; right:0; top:1px; width:100%; height:10px; min-height:10px; } .timeline .icon-button { min-height:27px; width:19px; } .timeline output { margin-left:auto; font-size:8px; } .timeline select { width:45px; font-size:9px; min-height:27px; padding:1px; } #loop { min-height:27px; padding:2px 4px; font-size:15px; } #loop span,.shortcut-note { display:none; }
|
||||
}
|
||||
@media(max-height:550px) and (min-width:521px) and (max-width:1000px) {
|
||||
.shell { min-height:350px; grid-template-rows:52px minmax(0,1fr) 64px; } .topbar { padding-inline:14px; } .brand-mark { height:30px; width:30px; } .brand small { display:none; } .brand strong { font-size:23px; }
|
||||
.mobile-tabs { position:absolute; left:12px; top:67px; width:110px; z-index:5; flex-direction:column; padding:0; } .mobile-tabs button { width:100%; background:var(--paper); justify-content:flex-start; min-height:38px; } .workspace { position:relative; display:block; padding:8px 12px 8px 132px; } .board-wrap { height:100%; } .workspace .panel { display:none; position:absolute; inset:8px 12px 8px 132px; z-index:5; background:var(--paper); padding:15px; overflow:auto; border-radius:12px; } .shell[data-panel=players] .left-panel,.shell[data-panel=actions] .right-panel { display:block; }
|
||||
.canvas-header { top:13px; left:15px; right:15px; } .canvas-header .section-kicker,.canvas-meta { display:none; } .canvas-header h1 { font-size:12px; margin:0; } .board { inset:45px 110px 18px 0; } .mode-group { top:57px; bottom:auto; left:auto; right:14px; transform:none; display:grid; grid-template-columns:1fr 1fr; gap:2px; padding:4px; } .mode-group button { min-width:35px; padding:4px 6px; font-size:8px; } .mode-group .icon { width:16px; height:16px; } .tool-divider { display:none; } .board-status { left:14px; right:14px; bottom:5px; font-size:8px; }
|
||||
.playback-deck { display:flex; align-items:center; gap:16px; padding:0 14px; } .sequence-heading { display:none; } .sequences { max-width:220px; flex:0 1 auto; } .sequence-tab,.add-sequence { height:43px; min-width:34px; } .sequence-tab svg { width:27px; height:31px; } .sequence-copy small,#delete-sequence { display:none; } .playback-row { flex:1; min-width:0; border:0; gap:6px; } .transport { width:100px; gap:2px; } .timeline { gap:4px; } .timeline output { font-size:8px; } .timeline select { width:47px; }
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce) { *,*::before,*::after { transition:none!important; } }
|
||||
|
||||
.mode-group button span { white-space: nowrap; }
|
||||
.account-gate { position:fixed; inset:0; z-index:50; display:grid; place-items:center; padding:24px; background:linear-gradient(145deg,#edf2eb,#f8f9f6 55%,#f5e8df); overflow:auto; }
|
||||
.account-card { width:min(470px,100%); padding:32px; border:1px solid #dfe7dc; border-radius:20px; background:#fff; box-shadow:0 24px 70px #23342e18; }
|
||||
.account-brand { display:flex; align-items:center; gap:10px; margin-bottom:34px; } .account-brand .brand-mark { width:40px; height:40px; } .account-brand strong { font-size:28px; letter-spacing:-.06em; line-height:1; } .account-brand strong span { font-weight:400; } .account-brand small { display:block; margin-top:5px; color:var(--muted); font-size:7px; letter-spacing:.14em; }
|
||||
.account-heading .section-kicker { margin-bottom:10px; } .account-heading h1 { margin:0; color:var(--ink); font-size:27px; font-weight:700; } .account-heading p { margin:11px 0 24px; color:var(--muted); font-size:12px; line-height:1.7; }
|
||||
.account-form { display:grid; gap:14px; } .account-form label,.team-form label { display:grid; gap:7px; color:#627268; font-size:11px; font-weight:600; } .account-form button { width:100%; margin-top:4px; }
|
||||
.account-link { width:100%; margin-top:17px; border-color:transparent; background:transparent; color:#6d8173; font-size:11px; } .account-link:hover:not(:disabled) { background:#f1f4ed; } .account-message { min-height:18px; margin:16px 0 0; color:#a26a55; font-size:11px; line-height:1.6; }
|
||||
.team-list { display:grid; gap:9px; margin-bottom:22px; } .team-card { width:100%; min-height:60px; padding:12px 15px; justify-content:space-between; text-align:left; background:#f7f9f4; border-color:#e0e7dc; } .team-card strong { font-size:13px; } .team-card small { color:var(--muted); font-size:10px; } .team-card:hover:not(:disabled) { border-color:#b6c8ae; background:#edf4e9; }
|
||||
.team-form { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:9px; padding-top:18px; border-top:1px solid var(--line); } .team-form button { min-height:39px; } .local-import { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:9px; margin-top:13px; } .local-import label { display:grid; gap:7px; color:#627268; font-size:11px; font-weight:600; } .account-secondary { width:100%; margin-top:0; background:#edf2e9; border-color:transparent; color:#496448; font-size:11px; } .account-empty { margin:4px 0; color:var(--muted); font-size:11px; line-height:1.6; }
|
||||
.operator-panel { margin-top:21px; padding-top:17px; border-top:1px solid var(--line); } .operator-heading { display:flex; justify-content:space-between; align-items:center; margin-bottom:11px; } .operator-heading strong { font-size:11px; } .pending-user { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:9px 0; border-bottom:1px solid #eef1eb; color:var(--muted); font-size:10px; } .pending-user button { min-height:29px; padding:5px 9px; font-size:10px; }
|
||||
#open-team-hub { min-height:32px; padding:6px 10px; color:#587160; border-color:#dbe4d8; background:#f5f8f1; font-size:10px; } #team-context { color:#65766b; }
|
||||
@media(max-width:520px) { .account-gate { align-items:start; padding:14px; } .account-card { margin-top:5vh; padding:24px 20px; border-radius:16px; } .account-brand { margin-bottom:26px; } .team-form,.local-import { grid-template-columns:1fr; } .team-form button,.local-import button { width:100%; } #open-team-hub { width:78px; max-width:78px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .top-actions button { white-space:nowrap; } #video-share-open span,#save-play span { display:none; } #video-share-open,#save-play { width:34px; min-width:34px; padding:6px; } }
|
||||
@@ -0,0 +1,116 @@
|
||||
const paths = {
|
||||
court: '<rect x="3" y="3" width="18" height="18" rx="3"/><path d="M3 12h18M9 3v4h6V3M9 21v-4h6v4"/><circle cx="12" cy="12" r="3"/>',
|
||||
move: '<path d="M4 19c0-8 13-1 13-11V4m-5 4 5-5 4 5"/><circle cx="4" cy="19" r="1.5"/>',
|
||||
pass: '<path d="m15 4 5 5-5 5M4 19l3-3m3-3 3-3m3-3 4 2"/>',
|
||||
screen: '<path d="M5 6h14M12 6v14"/>',
|
||||
shoot: '<circle cx="8" cy="7" r="3"/><path d="M5 17c3-8 11-8 14-2M15 19h7m-6-4 1 4m4-4-1 4"/>',
|
||||
play: '<path d="m8 5 11 7-11 7Z"/>',
|
||||
pause: '<path d="M8 5v14M16 5v14"/>',
|
||||
reset: '<path d="M4 10a8 8 0 1 1 1 8M4 4v6h6"/>',
|
||||
eye: '<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
save: '<path d="M5 3h12l4 4v14H3V3h2ZM7 3v6h10V3M7 21v-8h10v8"/>',
|
||||
menu: '<path d="M5 6h14M5 12h14M5 18h14"/>',
|
||||
people: '<circle cx="9" cy="8" r="3"/><path d="M3 21v-3a6 6 0 0 1 12 0v3M17 5a3 3 0 0 1 0 6m1 3a5 5 0 0 1 3 5v2"/>',
|
||||
sliders: '<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3"/><circle cx="15" cy="17" r="3"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>',
|
||||
};
|
||||
export const icon = name => `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.court}</svg>`;
|
||||
|
||||
export function accountLayout() {
|
||||
return `<section id="account-gate" class="account-gate" aria-live="polite">
|
||||
<div class="account-card">
|
||||
<div class="account-brand"><span class="brand-mark">${icon('court')}</span><div><strong>court<span>lab</span></strong><small>BASKETBALL PLAY STUDIO</small></div></div>
|
||||
<div id="auth-panel">
|
||||
<div class="account-heading"><span class="section-kicker">TEAM PLAYBOOK</span><h1>로그인</h1><p>승인된 계정으로 팀 전술을 안전하게 관리하세요.</p></div>
|
||||
<form id="login-form" class="account-form"><label>이메일<input name="email" type="email" autocomplete="email" required maxlength="320" /></label><label>비밀번호<input name="password" type="password" autocomplete="current-password" required minlength="8" /></label><button class="primary" type="submit">로그인</button></form>
|
||||
<button id="show-register" class="account-link" type="button">처음 오셨나요? 가입 신청</button>
|
||||
<p id="auth-message" class="account-message" role="status"></p>
|
||||
</div>
|
||||
<div id="register-panel" hidden>
|
||||
<div class="account-heading"><span class="section-kicker">NEW ACCOUNT</span><h1>가입 신청</h1><p>신청 후 운영자 승인이 완료되면 전술함을 사용할 수 있습니다.</p></div>
|
||||
<form id="register-form" class="account-form"><label>표시 이름<input name="displayName" type="text" autocomplete="name" required maxlength="80" /></label><label>이메일<input name="email" type="email" autocomplete="email" required maxlength="320" /></label><label>비밀번호<input name="password" type="password" autocomplete="new-password" required minlength="8" /></label><button class="primary" type="submit">가입 신청</button></form>
|
||||
<button id="show-login" class="account-link" type="button">이미 계정이 있어요</button>
|
||||
<p id="register-message" class="account-message" role="status"></p>
|
||||
</div>
|
||||
<div id="pending-panel" hidden>
|
||||
<div class="account-heading"><span class="section-kicker">APPROVAL PENDING</span><h1>승인 대기 중</h1><p id="pending-message">운영자 승인이 완료되면 다시 로그인해 주세요.</p></div>
|
||||
<button id="pending-login" class="primary" type="button">로그인 화면으로</button>
|
||||
</div>
|
||||
<div id="team-panel" hidden>
|
||||
<div class="account-heading"><span class="section-kicker">YOUR TEAMS</span><h1>팀 전술함</h1><p id="team-welcome"></p></div>
|
||||
<div id="team-list" class="team-list"></div>
|
||||
<form id="team-form" class="team-form"><label>새 팀 만들기<input name="name" type="text" maxlength="100" placeholder="예: U18 남자부" required /></label><button class="primary" type="submit">팀 만들기</button></form>
|
||||
<div class="local-import"><label>가져올 팀<select id="local-import-team" aria-label="기기 전술을 가져올 팀"><option value="">팀을 먼저 선택하세요</option></select></label><button id="import-local-team" class="account-secondary" type="button" disabled>이 기기의 전술 가져오기</button></div>
|
||||
<section id="operator-panel" class="operator-panel" hidden><div class="operator-heading"><span class="section-kicker">OPERATOR</span><strong>승인 대기 사용자</strong></div><div id="pending-users"></div></section>
|
||||
<button id="team-logout" class="account-link" type="button">로그아웃</button>
|
||||
<p id="team-message" class="account-message" role="status"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function decorateSequenceButton(button, sequence, index, players) {
|
||||
const dots = sequence.tracks.map(track => {
|
||||
const player = players.find(player => player.id === track.playerId);
|
||||
const point = track.startLocation;
|
||||
const x = Math.max(5, Math.min(55, 30 + point.x * 3.3));
|
||||
const y = Math.max(5, Math.min(55, 5 + point.z * 3.5));
|
||||
return `<circle cx="${x}" cy="${y}" r="1.7" fill="${player?.team === 'offense' ? '#d96c43' : '#719db6'}"/>`;
|
||||
}).join('');
|
||||
button.innerHTML = `<svg viewBox="0 0 60 60" fill="none" aria-hidden="true"><rect x="3" y="3" width="54" height="54" rx="2" stroke="#b4c1a8"/><path d="M20 57V37h20v20M6 56a24 24 0 0 1 48 0M23 3a7 7 0 0 0 14 0" stroke="#b4c1a8" stroke-width=".7"/>${dots}</svg>`;
|
||||
const copy = document.createElement('span'); copy.className = 'sequence-copy';
|
||||
const name = document.createElement('span'); name.textContent = sequence.name.replace(/^Sequence /, '단계 ');
|
||||
const caption = document.createElement('small'); caption.textContent = `${String(index + 1).padStart(2, '0')} / ${sequence.tracks.reduce((sum, track) => sum + track.actions.length, 0)}개 행동`;
|
||||
copy.append(name, caption); button.append(copy); button.setAttribute('aria-label', name.textContent);
|
||||
}
|
||||
|
||||
export function editorLayout() {
|
||||
return `<section class="shell">
|
||||
<header class="topbar">
|
||||
<div class="brand"><span class="brand-mark">${icon('court')}</span><div><strong>court<span>lab</span></strong><small>BASKETBALL PLAY STUDIO</small></div></div>
|
||||
<div class="document-title"><span class="breadcrumb"><span id="team-context">내 팀</span> <span>/</span></span><input id="play-name" value="새 전술" aria-label="전술 이름" /></div>
|
||||
<div class="top-actions"><span id="save-status" role="status">저장 전</span><button id="open-team-hub" type="button" aria-label="팀 전환">팀 전환</button><button id="video-share-open" aria-label="영상 공유">${icon('download')}<span>영상 공유</span></button><button id="save-play" class="primary" aria-label="저장">${icon('save')}<span>저장</span></button><button id="toggle-menu" class="icon-button" aria-expanded="false" aria-controls="project-menu" aria-label="전술 메뉴">${icon('menu')}</button></div>
|
||||
<div id="project-menu" class="project-menu" hidden>
|
||||
<div class="menu-heading">플레이북 관리</div>
|
||||
<div class="setup"><label for="defense-type">새 전술의 수비 형태</label><select id="defense-type" aria-label="수비 형태"><option value="man-to-man">맨투맨</option><option value="2-3">2–3 지역 수비</option><option value="3-2">3–2 지역 수비</option></select><button id="new-play">${icon('plus')}새 전술 만들기</button></div>
|
||||
<div class="saved-controls"><label for="saved-plays">저장된 전술</label><select id="saved-plays" aria-label="저장된 전술"><option value="" disabled selected>저장된 전술 없음</option></select><button id="load-play" disabled>불러오기</button></div>
|
||||
<div class="file-tools"><button id="export-play">${icon('download')}파일 내보내기</button><button id="import-play">파일 가져오기</button><input id="import-file" type="file" accept=".json,application/json" hidden /></div>
|
||||
<section class="video-tools" aria-labelledby="video-tools-title"><div class="video-heading"><div id="video-tools-title" class="menu-heading">MP4 영상 공유</div><button id="close-video-tools" class="icon-button" aria-label="영상 공유 닫기">×</button></div><p class="video-help">현재 전술을 실제 MP4로 만들어 미리본 뒤 카카오톡 대화방을 선택해 첨부합니다.</p><div class="video-tool-row"><select id="video-view" aria-label="영상 시점"><option value="tactical" selected>작전판 전체</option><option value="pov">선수 시점 · 선택 선수</option></select><button id="export-video">MP4 영상 만들기</button><button id="cancel-video" hidden>취소</button></div><progress id="video-progress" max="1" value="0" aria-label="영상 생성 진행률" hidden></progress><span id="video-status" class="video-status" role="status">영상 준비 전</span><video id="video-preview" controls playsinline preload="metadata" hidden></video><div class="video-share-row"><button id="share-video" class="primary" disabled>영상 파일 공유</button><button id="download-video" disabled>${icon('download')}MP4 다운로드</button></div></section>
|
||||
<details class="debug"><summary>전술 데이터 보기</summary><pre id="data-preview" class="data-preview" aria-label="JSON 상태"></pre></details>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="mobile-tabs" aria-label="편집 패널"><button data-panel="court" class="active">${icon('court')}코트</button><button data-panel="players">${icon('people')}선수</button><button data-panel="actions">${icon('sliders')}행동 · 시선</button></nav>
|
||||
<div class="workspace">
|
||||
<aside class="panel left-panel">
|
||||
<div class="panel-heading"><span class="section-kicker">YOUR LINEUP</span><h2>선수 구성 <span class="count-pill">5 : 5</span></h2></div>
|
||||
<div class="panel-title">선택한 선수 <span id="selected-label"></span></div><div id="roster"></div>
|
||||
<div class="ball-owner-control"><span class="section-kicker">POSSESSION</span><span id="ball-owner-label"></span><button id="set-ball-owner">선택 선수를 공 소유자로</button></div>
|
||||
<details class="coach-note"><summary>편집 가이드 <span>↗</span></summary><div id="help-copy" class="help"></div></details>
|
||||
<div class="panel-footnote"><span class="live-dot"></span>모든 움직임은 여기서 시작됩니다.</div>
|
||||
</aside>
|
||||
<section class="board-wrap">
|
||||
<div class="canvas-header"><div><span class="section-kicker">THE PLAYGROUND</span><h1>전술 캔버스 <span id="canvas-step">01</span></h1></div><div class="view-group"><button id="tactical" class="view active">${icon('court')}작전판</button><button id="pov" class="view">${icon('eye')}선수 시점</button></div></div>
|
||||
<div class="canvas-meta"><span><i class="live-dot"></i> HALF COURT</span><span>15 × 14 M <b>·</b> 5 ON 5</span></div>
|
||||
<div id="board" class="board" aria-label="농구 작전 코트"></div><button id="cancel-gaze" hidden>시선 선택 취소</button>
|
||||
<div class="mode-group" aria-label="행동 도구"><button data-mode="start" class="mode start active">${icon('people')}<span>배치</span></button><span class="tool-divider"></span><button data-mode="move" class="mode">${icon('move')}<span>이동</span></button><button data-mode="pass" class="mode">${icon('pass')}<span>패스</span></button><button data-mode="screen" class="mode">${icon('screen')}<span>스크린</span></button><button id="shoot-action" class="mode">${icon('shoot')}<span>슛</span></button></div>
|
||||
<div class="board-status"><span id="status" role="status">선수를 선택하고 첫 움직임을 만들어보세요.</span><span id="clock">00.0s</span></div>
|
||||
</section>
|
||||
<aside class="panel right-panel">
|
||||
<div class="panel-heading"><span class="section-kicker">MAKE YOUR MOVE</span><h2>행동 편집</h2></div>
|
||||
<div class="panel-title">행동 목록 <span id="track-duration"></span></div><div id="actions"></div>
|
||||
<fieldset id="action-properties" disabled><legend>${icon('sliders')} 행동 속성</legend>
|
||||
<label>시선 방향<select id="gaze" aria-label="시선"><option value="auto">자동 · 행동에 맞게</option><option value="ball">공</option><option value="rim">림</option><option value="movement">이동 방향</option><option value="player">선수 선택…</option><option value="location">코트 지점 선택…</option></select></label>
|
||||
<p id="gaze-description">행동을 선택하면 시선을 설정할 수 있습니다.</p>
|
||||
<div class="coordinates"><label>X <span>가로 위치</span><input id="action-x" aria-label="X" type="number" step="0.5" min="-7.5" max="7.5" /></label><label>Z <span>세로 위치</span><input id="action-z" aria-label="Z" type="number" step="0.5" min="0" max="14" /></label></div><button id="apply-position">위치 적용 <span>↗</span></button>
|
||||
</fieldset><button id="delete-action" class="danger full">선택 행동 삭제</button>
|
||||
<div class="history-actions"><button id="undo" title="Ctrl+Z">↶ 실행 취소</button><button id="redo" title="Ctrl+Shift+Z">↷ 다시 실행</button></div>
|
||||
<div class="shortcut-note"><kbd>Space</kbd> 재생 <span>·</span> <kbd>Esc</kbd> 선택 취소</div>
|
||||
</aside>
|
||||
</div>
|
||||
<footer class="playback-deck">
|
||||
<div class="sequence-heading"><span class="section-kicker">PLAY SEQUENCE</span><strong>플레이 흐름</strong></div><nav class="sequences" id="sequences" aria-label="플레이 단계"></nav>
|
||||
<div class="playback-row"><div class="transport"><button id="reset" class="icon-button" aria-label="처음으로" title="처음으로">${icon('reset')}</button><button id="play" class="play-button" aria-label="재생" title="재생 · Space">${icon('play')}</button><button id="pause" class="icon-button" aria-label="일시정지" title="일시정지">${icon('pause')}</button></div><div class="timeline"><button id="previous-step" class="icon-button" aria-label="이전 단계">‹</button><input id="scrubber" type="range" min="0" max="0" value="0" step="0.01" aria-label="재생 위치" /><button id="next-step" class="icon-button" aria-label="다음 단계">›</button><output id="time-display">0.0 / 0.0초</output><select id="playback-rate" aria-label="재생 속도"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="1.5">1.5×</option><option value="2">2×</option></select><button id="loop" aria-pressed="false">↻ <span>반복</span></button></div></div>
|
||||
</footer>
|
||||
</section>`;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
const MP4_MIME_TYPES = [
|
||||
'video/mp4;codecs=avc1.42E01E',
|
||||
'video/mp4;codecs=avc1.4D401F',
|
||||
'video/mp4',
|
||||
];
|
||||
|
||||
export class VideoExportError extends Error {
|
||||
constructor(message, code = 'video-export-error') {
|
||||
super(message);
|
||||
this.name = 'VideoExportError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export class VideoExportCancelledError extends VideoExportError {
|
||||
constructor(message = '영상 생성을 취소했습니다') {
|
||||
super(message, 'cancelled');
|
||||
this.name = 'VideoExportCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
export class VideoExportStaleError extends VideoExportError {
|
||||
constructor(message = '전술이 변경되어 영상 생성을 취소했습니다') {
|
||||
super(message, 'stale');
|
||||
this.name = 'VideoExportStaleError';
|
||||
}
|
||||
}
|
||||
|
||||
export class VideoShareError extends Error {
|
||||
constructor(message, code = 'video-share-error') {
|
||||
super(message);
|
||||
this.name = 'VideoShareError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectMp4MimeType(mediaRecorderConstructor = globalThis.MediaRecorder) {
|
||||
if (!mediaRecorderConstructor || typeof mediaRecorderConstructor.isTypeSupported !== 'function') return null;
|
||||
for (const mimeType of MP4_MIME_TYPES) {
|
||||
try {
|
||||
if (mediaRecorderConstructor.isTypeSupported(mimeType)) return mimeType;
|
||||
} catch {
|
||||
// A browser may throw for an unknown codec string. Try the next one.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isMp4MimeType(type) {
|
||||
return typeof type === 'string' && type.toLowerCase().split(';', 1)[0] === 'video/mp4';
|
||||
}
|
||||
|
||||
function abortError(signal) {
|
||||
return signal?.reason instanceof Error ? signal.reason : new VideoExportCancelledError();
|
||||
}
|
||||
|
||||
function stopStream(stream) {
|
||||
for (const track of stream?.getTracks?.() || []) track.stop?.();
|
||||
}
|
||||
|
||||
function makeMp4File(blob, fileName, FileConstructor = globalThis.File) {
|
||||
if (!(blob instanceof Blob) || blob.size <= 0 || !isMp4MimeType(blob.type)) {
|
||||
throw new VideoExportError('브라우저가 유효한 MP4 영상을 만들지 못했습니다', 'invalid-mp4');
|
||||
}
|
||||
if (typeof FileConstructor !== 'function') throw new VideoExportError('이 브라우저는 MP4 파일을 준비할 수 없습니다', 'file-unsupported');
|
||||
return new FileConstructor([blob], fileName, { type: 'video/mp4', lastModified: Date.now() });
|
||||
}
|
||||
|
||||
async function runVideoExport(options, signal) {
|
||||
const {
|
||||
canvas,
|
||||
duration,
|
||||
fps = 30,
|
||||
renderFrame,
|
||||
onProgress = () => {},
|
||||
isCurrent = () => true,
|
||||
MediaRecorderConstructor = globalThis.MediaRecorder,
|
||||
clockImpl = () => globalThis.performance?.now?.() ?? Date.now(),
|
||||
sleepImpl = (delay) => new Promise((resolve) => setTimeout(resolve, Math.max(0, delay))),
|
||||
isVisible = () => globalThis.document?.hidden !== true,
|
||||
FileConstructor = globalThis.File,
|
||||
fileName = 'court-lab-play.mp4',
|
||||
} = options;
|
||||
if (!canvas?.captureStream) throw new VideoExportError('이 브라우저는 캔버스 영상 생성을 지원하지 않습니다', 'capture-stream-unsupported');
|
||||
if (typeof MediaRecorderConstructor !== 'function') throw new VideoExportError('이 브라우저는 MP4 영상 생성을 지원하지 않습니다', 'media-recorder-unsupported');
|
||||
if (!Number.isFinite(duration) || duration <= 0) throw new VideoExportError('먼저 재생할 이동 행동을 추가하세요', 'empty-play');
|
||||
if (!Number.isFinite(fps) || fps <= 0) throw new VideoExportError('영상 프레임 설정이 올바르지 않습니다', 'invalid-settings');
|
||||
if (typeof renderFrame !== 'function') throw new VideoExportError('영상 렌더러를 준비하지 못했습니다', 'renderer-unsupported');
|
||||
if (typeof clockImpl !== 'function' || typeof sleepImpl !== 'function') throw new VideoExportError('이 브라우저는 영상 시간 기준을 준비하지 못했습니다', 'clock-unsupported');
|
||||
if (typeof isVisible !== 'function') throw new VideoExportError('이 브라우저의 영상 표시 상태를 확인하지 못했습니다', 'visibility-unsupported');
|
||||
if (!isVisible()) throw new VideoExportError('영상 생성 중에는 이 탭을 보고 있어야 합니다', 'document-hidden');
|
||||
if (!isCurrent()) throw new VideoExportStaleError();
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
|
||||
const mimeType = selectMp4MimeType(MediaRecorderConstructor);
|
||||
if (!mimeType) throw new VideoExportError('이 브라우저는 MP4 녹화를 지원하지 않습니다. Safari 또는 MP4 녹화를 지원하는 브라우저에서 다시 시도하세요.', 'mp4-unsupported');
|
||||
let stream;
|
||||
try {
|
||||
stream = canvas.captureStream(fps);
|
||||
} catch (error) {
|
||||
throw new VideoExportError(`영상 캔버스를 준비하지 못했습니다: ${error.message || error}`, 'capture-stream-error');
|
||||
}
|
||||
let recorder;
|
||||
try {
|
||||
recorder = new MediaRecorderConstructor(stream, { mimeType });
|
||||
} catch (error) {
|
||||
stopStream(stream);
|
||||
throw new VideoExportError(`MP4 녹화기를 준비하지 못했습니다: ${error.message || error}`, 'recorder-error');
|
||||
}
|
||||
if (recorder.mimeType && !isMp4MimeType(recorder.mimeType)) {
|
||||
stopStream(stream);
|
||||
throw new VideoExportError('브라우저가 MP4 대신 다른 영상 형식을 선택했습니다', 'mp4-unsupported');
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
// Capture at the requested wall-clock rate. A fixed sample loop tied to RAF
|
||||
// would finish too early on 60/120 Hz displays and produce fast-forwarded MP4.
|
||||
const frameCount = Math.max(1, Math.ceil(duration * fps));
|
||||
let settled = false;
|
||||
let recording = false;
|
||||
let removeAbortListener = () => {};
|
||||
const finish = (resolve, reject, error, result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
removeAbortListener();
|
||||
stopStream(stream);
|
||||
if (error) reject(error); else resolve(result);
|
||||
};
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const fail = (error) => finish(resolve, reject, error);
|
||||
const abort = () => {
|
||||
const error = abortError(signal);
|
||||
if (recording) {
|
||||
try { recorder.stop(); } catch { /* recorder may already be stopping */ }
|
||||
}
|
||||
fail(error);
|
||||
};
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
removeAbortListener = () => signal.removeEventListener('abort', abort);
|
||||
}
|
||||
recorder.ondataavailable = (event) => { if (event.data?.size) chunks.push(event.data); };
|
||||
recorder.onerror = (event) => fail(new VideoExportError(event.error?.message || 'MP4 녹화 중 오류가 발생했습니다', 'recorder-error'));
|
||||
recorder.onstop = () => {
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
const blob = new Blob(chunks, { type: mimeType });
|
||||
const file = makeMp4File(blob, fileName, FileConstructor);
|
||||
finish(resolve, reject, null, { file, blob, mimeType, duration, fps, frameCount });
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
};
|
||||
try {
|
||||
recorder.start();
|
||||
recording = true;
|
||||
} catch (error) {
|
||||
fail(new VideoExportError(`MP4 녹화를 시작하지 못했습니다: ${error.message || error}`, 'recorder-error'));
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const recordingStartedAt = clockImpl();
|
||||
for (let index = 0; index < frameCount; index += 1) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
if (!isCurrent()) throw new VideoExportStaleError();
|
||||
if (!isVisible()) throw new VideoExportError('영상 생성 중에는 이 탭을 보고 있어야 합니다', 'document-hidden');
|
||||
const elapsed = Math.min(duration, index / fps);
|
||||
const frameTarget = elapsed * 1000;
|
||||
const wait = frameTarget - (clockImpl() - recordingStartedAt);
|
||||
if (wait > 0) await sleepImpl(wait);
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
if (!isCurrent()) throw new VideoExportStaleError();
|
||||
if (!isVisible()) throw new VideoExportError('영상 생성 중에는 이 탭을 보고 있어야 합니다', 'document-hidden');
|
||||
await renderFrame(elapsed, index / Math.max(1, frameCount - 1));
|
||||
onProgress(Math.min(1, (index + 1) / frameCount));
|
||||
}
|
||||
const finalWait = duration * 1000 - (clockImpl() - recordingStartedAt);
|
||||
if (finalWait > 0) await sleepImpl(finalWait);
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
if (!isCurrent()) throw new VideoExportStaleError();
|
||||
if (!isVisible()) throw new VideoExportError('영상 생성 중에는 이 탭을 보고 있어야 합니다', 'document-hidden');
|
||||
recorder.stop();
|
||||
recording = false;
|
||||
} catch (error) {
|
||||
if (settled) return;
|
||||
try { if (recording) recorder.stop(); } catch { /* cleanup below is sufficient */ }
|
||||
finish(resolve, reject, error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function createVideoExportJob(options) {
|
||||
const controller = new AbortController();
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) controller.abort(options.signal.reason);
|
||||
else options.signal.addEventListener('abort', () => controller.abort(options.signal.reason), { once: true });
|
||||
}
|
||||
const promise = runVideoExport(options || {}, controller.signal);
|
||||
return { promise, signal: controller.signal, cancel: () => controller.abort(new VideoExportCancelledError()) };
|
||||
}
|
||||
|
||||
export async function validateVideoFile(file, options = {}) {
|
||||
if (!file || file.size <= 0 || !isMp4MimeType(file.type)) throw new VideoExportError('준비된 파일이 유효한 MP4가 아닙니다', 'invalid-mp4');
|
||||
if (options.signal?.aborted) throw abortError(options.signal);
|
||||
const documentObject = options.documentObject ?? globalThis.document;
|
||||
const urlObject = options.urlObject ?? globalThis.URL;
|
||||
if (!documentObject?.createElement || !urlObject?.createObjectURL) return { duration: null, type: file.type, size: file.size };
|
||||
const video = documentObject.createElement('video');
|
||||
if (typeof video.canPlayType === 'function' && !video.canPlayType('video/mp4')) throw new VideoExportError('이 브라우저에서 생성된 MP4를 재생할 수 없습니다', 'video-playback-unsupported');
|
||||
const url = urlObject.createObjectURL(file);
|
||||
const timeoutMs = Math.max(500, Number(options.timeoutMs) || 5000);
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); video.onloadedmetadata = null; video.onerror = null; urlObject.revokeObjectURL?.(url); video.removeAttribute?.('src'); video.load?.(); };
|
||||
const finish = (error, value) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(value); };
|
||||
const abort = () => finish(abortError(options.signal));
|
||||
options.signal?.addEventListener('abort', abort, { once: true });
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = Number(video.duration);
|
||||
if (!Number.isFinite(duration) || duration <= 0) { finish(new VideoExportError('생성된 MP4에 재생 가능한 시간 정보가 없습니다', 'video-metadata-invalid')); return; }
|
||||
finish(null, { duration, type: file.type, size: file.size });
|
||||
};
|
||||
video.onerror = () => finish(new VideoExportError('생성된 MP4를 미리보기로 열 수 없습니다', 'video-playback-invalid'));
|
||||
timer = setTimeout(() => finish(new VideoExportError('MP4 재생 정보를 확인하는 데 시간이 걸리고 있습니다', 'video-metadata-timeout')), timeoutMs);
|
||||
video.src = url;
|
||||
video.load?.();
|
||||
});
|
||||
}
|
||||
|
||||
export async function shareVideoFile(file, options = {}) {
|
||||
const navigatorObject = options.navigatorObject ?? globalThis.navigator;
|
||||
if (!navigatorObject || typeof navigatorObject.share !== 'function' || typeof navigatorObject.canShare !== 'function') throw new VideoShareError('이 브라우저는 영상 파일 공유를 지원하지 않습니다', 'share-unsupported');
|
||||
let supported = false;
|
||||
try { supported = Boolean(navigatorObject.canShare({ files: [file] })); } catch { supported = false; }
|
||||
if (!supported) throw new VideoShareError('이 브라우저에서 MP4 파일 공유를 지원하지 않습니다. MP4를 다운로드해 카카오톡에 직접 첨부하세요.', 'file-share-unsupported');
|
||||
return navigatorObject.share({ files: [file], title: options.title || 'court.lab 전술 영상', text: options.text || '농구 전술 MP4 영상' });
|
||||
}
|
||||
|
||||
export function downloadVideoFile(file, options = {}) {
|
||||
const documentObject = options.documentObject ?? globalThis.document;
|
||||
const urlObject = options.urlObject ?? globalThis.URL;
|
||||
if (!documentObject?.createElement || !urlObject?.createObjectURL) throw new VideoShareError('영상 다운로드를 준비하지 못했습니다', 'download-unsupported');
|
||||
const url = urlObject.createObjectURL(file);
|
||||
const anchor = documentObject.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = options.fileName || file.name || 'court-lab-play.mp4';
|
||||
anchor.click();
|
||||
setTimeout(() => urlObject.revokeObjectURL?.(url), 1000);
|
||||
}
|
||||
|
||||
export { MP4_MIME_TYPES };
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createVideoExportJob, downloadVideoFile, selectMp4MimeType, shareVideoFile, validateVideoFile, VideoExportCancelledError } from './videoExport.js';
|
||||
|
||||
class FakeFile extends Blob {
|
||||
constructor(parts, name, options) { super(parts, options); this.name = name; }
|
||||
}
|
||||
|
||||
class FakeRecorder {
|
||||
static supported = true;
|
||||
static isTypeSupported(type) { return FakeRecorder.supported && type.startsWith('video/mp4'); }
|
||||
constructor(stream, options) { this.stream = stream; this.mimeType = options.mimeType; this.ondataavailable = null; this.onstop = null; this.onerror = null; this.started = false; }
|
||||
start() { this.started = true; }
|
||||
stop() { if (!this.started) return; this.started = false; this.ondataavailable?.({ data: new Blob(['mp4-data'], { type: 'video/mp4' }) }); queueMicrotask(() => this.onstop?.()); }
|
||||
}
|
||||
|
||||
function fakeCanvas() {
|
||||
const stopped = [];
|
||||
return { stopped, captureStream() { return { getTracks: () => [{ stop: () => stopped.push(true) }] }; } };
|
||||
}
|
||||
|
||||
function fakeTiming() {
|
||||
let time = 0;
|
||||
return { clockImpl: () => time, sleepImpl: (delay) => { time += delay; }, get time() { return time; } };
|
||||
}
|
||||
|
||||
describe('MP4 video export', () => {
|
||||
it('selects only a recorder MIME that is explicitly MP4', () => {
|
||||
expect(selectMp4MimeType({ isTypeSupported: (type) => type === 'video/mp4' })).toBe('video/mp4');
|
||||
expect(selectMp4MimeType({ isTypeSupported: () => false })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders fixed samples, emits an MP4 File, and cleans the stream', async () => {
|
||||
const canvas = fakeCanvas(); const elapsed = []; const timing = fakeTiming();
|
||||
const job = createVideoExportJob({ canvas, duration: 1, fps: 2, MediaRecorderConstructor: FakeRecorder, FileConstructor: FakeFile, ...timing, renderFrame: (time) => elapsed.push(time) });
|
||||
const result = await job.promise;
|
||||
expect(elapsed).toEqual([0, 0.5]);
|
||||
expect(result.file.type).toBe('video/mp4');
|
||||
expect(result.file.name).toBe('court-lab-play.mp4');
|
||||
expect(result.file.size).toBeGreaterThan(0);
|
||||
expect(canvas.stopped).toHaveLength(1);
|
||||
expect(timing.time).toBe(1000);
|
||||
});
|
||||
|
||||
it('cancels while waiting for the next frame and stops the stream', async () => {
|
||||
const canvas = fakeCanvas(); const timing = fakeTiming();
|
||||
const job = createVideoExportJob({ canvas, duration: 2, fps: 2, MediaRecorderConstructor: FakeRecorder, FileConstructor: FakeFile, clockImpl: timing.clockImpl, sleepImpl: () => new Promise(() => {}), renderFrame: () => {} });
|
||||
await Promise.resolve();
|
||||
job.cancel();
|
||||
await expect(job.promise).rejects.toBeInstanceOf(VideoExportCancelledError);
|
||||
expect(canvas.stopped).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a stale snapshot before producing a shareable file', async () => {
|
||||
const canvas = fakeCanvas(); let current = true; const timing = fakeTiming();
|
||||
const job = createVideoExportJob({ canvas, duration: 1, fps: 2, MediaRecorderConstructor: FakeRecorder, FileConstructor: FakeFile, ...timing, isCurrent: () => current, renderFrame: () => { current = false; } });
|
||||
await expect(job.promise).rejects.toMatchObject({ code: 'stale' });
|
||||
});
|
||||
|
||||
it('checks staleness after pacing before rendering the next sample', async () => {
|
||||
const canvas = fakeCanvas(); let current = true; let rendered = 0; const timing = fakeTiming();
|
||||
const job = createVideoExportJob({ canvas, duration: 1, fps: 2, MediaRecorderConstructor: FakeRecorder, FileConstructor: FakeFile, clockImpl: timing.clockImpl, sleepImpl: (delay) => { current = false; timing.sleepImpl(delay); }, isCurrent: () => current, renderFrame: () => { rendered += 1; } });
|
||||
await expect(job.promise).rejects.toMatchObject({ code: 'stale' });
|
||||
expect(rendered).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MP4 file validation and sharing', () => {
|
||||
it('requires metadata that proves the file can be previewed', async () => {
|
||||
const file = new FakeFile(['mp4-data'], 'play.mp4', { type: 'video/mp4' }); const revoked = [];
|
||||
const video = { duration: 1.25, canPlayType: () => 'probably', load() { queueMicrotask(() => this.onloadedmetadata?.()); }, removeAttribute() {} };
|
||||
const documentObject = { createElement: () => video }; const urlObject = { createObjectURL: () => 'blob:test', revokeObjectURL: (url) => revoked.push(url) };
|
||||
await expect(validateVideoFile(file, { documentObject, urlObject })).resolves.toMatchObject({ duration: 1.25, type: 'video/mp4' });
|
||||
expect(revoked).toEqual(['blob:test']);
|
||||
});
|
||||
|
||||
it('cancels metadata validation and revokes its preview URL', async () => {
|
||||
const file = new FakeFile(['mp4-data'], 'play.mp4', { type: 'video/mp4' }); const controller = new AbortController(); const revoked = [];
|
||||
const video = { duration: 1, canPlayType: () => 'probably', load() {}, removeAttribute() {} };
|
||||
const documentObject = { createElement: () => video }; const urlObject = { createObjectURL: () => 'blob:test', revokeObjectURL: (url) => revoked.push(url) };
|
||||
const validation = validateVideoFile(file, { documentObject, urlObject, signal: controller.signal });
|
||||
controller.abort(new VideoExportCancelledError());
|
||||
await expect(validation).rejects.toBeInstanceOf(VideoExportCancelledError);
|
||||
expect(revoked).toEqual(['blob:test']);
|
||||
});
|
||||
|
||||
it('checks file support again at the user share click', async () => {
|
||||
const file = new FakeFile(['mp4-data'], 'play.mp4', { type: 'video/mp4' }); const calls = [];
|
||||
const navigatorObject = { canShare: (payload) => { calls.push(payload); return true; }, share: (payload) => { calls.push(payload); return Promise.resolve(); } };
|
||||
await shareVideoFile(file, { navigatorObject });
|
||||
expect(calls[0].files[0]).toBe(file);
|
||||
expect(calls[1].files[0]).toBe(file);
|
||||
});
|
||||
|
||||
it('offers a download fallback without changing the file type', () => {
|
||||
const file = new FakeFile(['mp4-data'], 'play.mp4', { type: 'video/mp4' }); const clicked = []; const revoked = [];
|
||||
const documentObject = { createElement: () => ({ click: () => clicked.push(true) }) }; const urlObject = { createObjectURL: () => 'blob:test', revokeObjectURL: (url) => revoked.push(url) };
|
||||
downloadVideoFile(file, { documentObject, urlObject });
|
||||
expect(clicked).toHaveLength(1);
|
||||
expect(file.type).toBe('video/mp4');
|
||||
expect(revoked).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { loadConfig } from './server/config.js';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: { '/api': `http://127.0.0.1:${config.server.port}` },
|
||||
// Keep Vite's standard secret-file protections and add the JSON config variants.
|
||||
fs: { deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '.config.json', '.config.json.*', '**/.config.json', '**/.config.json.*'] },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,975 @@
|
||||
# 농구 전술보드 웹앱 MVP 작업지시서
|
||||
|
||||
## 코드 작업 담당 모델
|
||||
|
||||
실제 코드 구현·수정·리팩터링·테스트 코드 작성은 **GPT-5.6 Luna (`gpt-5.6-luna`)**에게 지시한다. 주 에이전트는 조사·설계·작업 지시·검토·검증을 담당하며, 문서는 직접 수정할 수 있다. 구체적인 작업 규칙은 [AGENTS.md](./AGENTS.md)를 따른다.
|
||||
|
||||
## 1. 프로젝트 목표
|
||||
|
||||
Three.js 기반의 웹 농구 전술보드 MVP를 구현한다.
|
||||
|
||||
편집 화면은 전체 코트를 보기 편한 **쿼터뷰 2.5D 스타일**로 제공하되, 내부 월드 좌표는 실제 3D 좌표계를 사용한다.
|
||||
|
||||
사용자가 선수별 이동 경로와 시선을 간단하게 지정하면 이를 Sequence 단위로 저장하고, 재생 시 전체 전술 또는 특정 선수의 POV(Player View)로 확인할 수 있어야 한다.
|
||||
|
||||
핵심 방향은 다음과 같다.
|
||||
|
||||
- 사용자가 입력해야 하는 정보는 최소화
|
||||
- 선수 이동 위치만 지정해도 기본 전술 재생 가능
|
||||
- Facing, LookAt, Duration 등은 가능한 한 자동 계산
|
||||
- 동일한 전술 데이터를 쿼터뷰와 선수 POV에서 함께 사용
|
||||
- 추후 패스, 스크린, Read/Decision 등으로 확장 가능한 구조
|
||||
|
||||
---
|
||||
|
||||
# 2. 기술 스택
|
||||
|
||||
기본 기술 스택은 다음을 사용한다.
|
||||
|
||||
- Vanilla JavaScript
|
||||
- Three.js
|
||||
- HTML
|
||||
- CSS
|
||||
- Vite
|
||||
- ES Module
|
||||
|
||||
초기 MVP에서는 별도의 React/Vue 등의 프레임워크를 사용하지 않는다.
|
||||
|
||||
Three.js의 Scene/Object3D 구조와 앱 상태 관리를 명확하게 분리한다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 핵심 데이터 모델
|
||||
|
||||
전체 구조는 다음 개념으로 구성한다.
|
||||
|
||||
```text
|
||||
Play
|
||||
├─ Players
|
||||
├─ DefenseType
|
||||
└─ Sequences[]
|
||||
└─ PlayerTracks
|
||||
└─ Actions[]
|
||||
```
|
||||
|
||||
## Play
|
||||
|
||||
하나의 완성된 전술.
|
||||
|
||||
예시:
|
||||
|
||||
```js
|
||||
{
|
||||
id: "play-001",
|
||||
name: "Horns Entry",
|
||||
defenseType: "man-to-man",
|
||||
sequences: []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sequence
|
||||
|
||||
Sequence는 하나의 프레임이 아니다.
|
||||
|
||||
**여러 선수가 동시에 행동하는 하나의 전술 구간**으로 정의한다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
Sequence 1
|
||||
|
||||
1번
|
||||
- 4번에게 접근
|
||||
- 우측 엘보 방향으로 이동
|
||||
|
||||
4번
|
||||
- 볼 캐치
|
||||
- 핸드오프 준비
|
||||
|
||||
2번
|
||||
- 코너 유지
|
||||
|
||||
5번
|
||||
- 엘보 유지
|
||||
```
|
||||
|
||||
각 Sequence의 모든 선수 행동이 종료되면 다음 Sequence로 넘어간다.
|
||||
|
||||
---
|
||||
|
||||
## PlayerTrack
|
||||
|
||||
하나의 Sequence 내부에서 특정 선수가 수행하는 행동 묶음.
|
||||
|
||||
```js
|
||||
{
|
||||
playerId: "offense-1",
|
||||
actions: []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Action
|
||||
|
||||
기존의 move보다 포괄적인 단위로 사용한다.
|
||||
|
||||
기본 구조는 다음과 같이 설계한다.
|
||||
|
||||
```js
|
||||
{
|
||||
id: "action-001",
|
||||
|
||||
location: {
|
||||
x: 0,
|
||||
z: 0
|
||||
},
|
||||
|
||||
facing: null,
|
||||
|
||||
lookAt: null,
|
||||
|
||||
type: "move",
|
||||
|
||||
targetPlayerId: null
|
||||
}
|
||||
```
|
||||
|
||||
초기 MVP에서는 다음 필드 위주로 구현한다.
|
||||
|
||||
- location
|
||||
- facing
|
||||
- lookAt
|
||||
- type
|
||||
|
||||
향후 다음 Action Type을 확장할 수 있도록 설계한다.
|
||||
|
||||
```text
|
||||
move
|
||||
pass
|
||||
screen
|
||||
dribble
|
||||
shoot
|
||||
hold
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4. 코트 좌표계
|
||||
|
||||
화면 pixel 좌표를 데이터에 직접 저장하지 않는다.
|
||||
|
||||
실제 농구 코트 기준 좌표계를 사용한다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
X축 = 코트 좌우
|
||||
Z축 = 코트 길이 방향
|
||||
Y축 = 높이
|
||||
```
|
||||
|
||||
Three.js에서는:
|
||||
|
||||
```js
|
||||
player.position.set(x, y, z);
|
||||
```
|
||||
|
||||
형태로 그대로 사용할 수 있도록 한다.
|
||||
|
||||
MVP에서는 하프코트를 우선 구현해도 된다.
|
||||
|
||||
코트 위치를 선택할 때 내부적으로 Grid Snap을 적용할 수 있도록 한다.
|
||||
|
||||
Grid는 화면에 항상 표시할 필요는 없다.
|
||||
|
||||
---
|
||||
|
||||
# 5. Three.js Scene
|
||||
|
||||
Scene은 하나만 사용한다.
|
||||
|
||||
```text
|
||||
Three.js Scene
|
||||
│
|
||||
├─ Tactical Camera
|
||||
│
|
||||
└─ Player POV Camera
|
||||
```
|
||||
|
||||
## Tactical Camera
|
||||
|
||||
전술 편집 및 전체 플레이 재생용 카메라.
|
||||
|
||||
쿼터뷰 형태로 제공한다.
|
||||
|
||||
카메라는 고정된 각도를 기본으로 한다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
높은 위치
|
||||
+
|
||||
골대 방향을 내려다보는 Perspective View
|
||||
```
|
||||
|
||||
MVP에서는 자유로운 3D 카메라 회전 기능은 필요하지 않다.
|
||||
|
||||
---
|
||||
|
||||
## Player POV Camera
|
||||
|
||||
특정 선수의 시점에서 전술을 재생하기 위한 카메라.
|
||||
|
||||
선택한 선수 위치를 기준으로 한다.
|
||||
|
||||
개념:
|
||||
|
||||
```js
|
||||
camera.position =
|
||||
player.position + eyeHeight
|
||||
```
|
||||
|
||||
시선은 해당 Action의 lookAt 데이터를 기준으로 계산한다.
|
||||
|
||||
---
|
||||
|
||||
# 6. 선수 표현
|
||||
|
||||
초기 MVP에서 현실적인 3D 인간 모델은 사용하지 않는다.
|
||||
|
||||
다음과 같이 단순화한다.
|
||||
|
||||
```text
|
||||
Court
|
||||
→ 3D Plane
|
||||
|
||||
Players
|
||||
→ Billboard Sprite 또는 단순 Capsule/Cylinder
|
||||
|
||||
Ball
|
||||
→ Sphere
|
||||
|
||||
Basket
|
||||
→ 간단한 Low-poly Object
|
||||
|
||||
Move Path
|
||||
→ Three.js Line
|
||||
|
||||
LookAt
|
||||
→ Arrow 또는 Cone
|
||||
```
|
||||
|
||||
공격과 수비는 명확하게 구분될 수 있어야 한다.
|
||||
|
||||
선수에는 번호를 표시한다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
O1
|
||||
O2
|
||||
O3
|
||||
O4
|
||||
O5
|
||||
|
||||
D1
|
||||
D2
|
||||
D3
|
||||
D4
|
||||
D5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 7. 전술 생성 UX
|
||||
|
||||
새 전술을 생성할 때 다음 정보를 받는다.
|
||||
|
||||
```text
|
||||
전술 이름
|
||||
|
||||
수비 형태
|
||||
- Man to Man
|
||||
- 2-3 Zone
|
||||
- 3-2 Zone
|
||||
```
|
||||
|
||||
전술 생성 시 자동으로:
|
||||
|
||||
```text
|
||||
Sequence 1
|
||||
```
|
||||
|
||||
을 생성한다.
|
||||
|
||||
공격 5명과 수비 5명도 기본 배치한다.
|
||||
|
||||
수비 전술 선택에 따라 초기 수비 위치를 자동 배치한다.
|
||||
|
||||
---
|
||||
|
||||
# 8. 기본 LookAt 자동 설정
|
||||
|
||||
사용자가 모든 Action마다 시선을 직접 입력하지 않도록 한다.
|
||||
|
||||
기본 규칙을 구현한다.
|
||||
|
||||
## 공격
|
||||
|
||||
볼 핸들러:
|
||||
|
||||
```text
|
||||
lookAt → Rim
|
||||
```
|
||||
|
||||
오프볼 공격자:
|
||||
|
||||
```text
|
||||
lookAt → Ball
|
||||
```
|
||||
|
||||
## 수비
|
||||
|
||||
Man to Man:
|
||||
|
||||
```text
|
||||
기본적으로 매치업 상대 또는 Ball 방향
|
||||
```
|
||||
|
||||
Zone Defense:
|
||||
|
||||
```text
|
||||
Ball / 자신의 담당 Zone 방향
|
||||
```
|
||||
|
||||
MVP에서는 수비 LookAt 로직을 단순화해도 된다.
|
||||
|
||||
사용자가 LookAt을 직접 설정한 경우 자동값보다 사용자 설정값이 우선한다.
|
||||
|
||||
---
|
||||
|
||||
# 9. Facing 자동 계산
|
||||
|
||||
사용자가 Facing을 직접 입력하지 않아도 되도록 한다.
|
||||
|
||||
Action N에서 Action N+1로 이동하는 경우:
|
||||
|
||||
```text
|
||||
현재 위치
|
||||
→
|
||||
다음 위치
|
||||
```
|
||||
|
||||
벡터를 계산해서 기본 Facing을 자동 지정한다.
|
||||
|
||||
예:
|
||||
|
||||
```js
|
||||
direction = nextPosition - currentPosition
|
||||
rotationY = atan2(...)
|
||||
```
|
||||
|
||||
이동하지 않는 선수의 기본 Facing은 LookAt 방향을 사용할 수 있다.
|
||||
|
||||
사용자 override 기능은 추후 쉽게 추가 가능하도록 데이터 구조만 준비한다.
|
||||
|
||||
---
|
||||
|
||||
# 10. Action 입력 UX
|
||||
|
||||
사용자가 선수 하나를 선택한다.
|
||||
|
||||
이후 코트 위치를 계속 클릭하면 해당 선수의 Action이 연속 생성된다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
1번 선택
|
||||
|
||||
코트 클릭
|
||||
→ Action 1
|
||||
|
||||
코트 클릭
|
||||
→ Action 2
|
||||
|
||||
코트 클릭
|
||||
→ Action 3
|
||||
```
|
||||
|
||||
즉 사용자가 별도의:
|
||||
|
||||
```text
|
||||
Action 추가
|
||||
Move 추가
|
||||
Duration 입력
|
||||
```
|
||||
|
||||
같은 작업을 반복할 필요가 없어야 한다.
|
||||
|
||||
가능한 한:
|
||||
|
||||
```text
|
||||
선수 선택
|
||||
→ 위치 클릭
|
||||
→ 위치 클릭
|
||||
→ 위치 클릭
|
||||
```
|
||||
|
||||
만으로 경로를 만든다.
|
||||
|
||||
---
|
||||
|
||||
# 11. LookAt 입력 UX
|
||||
|
||||
LookAt은 기본 자동값을 사용한다.
|
||||
|
||||
사용자가 특정 Action의 LookAt을 수정하고 싶은 경우에만 설정한다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
선수 선택
|
||||
→ 특정 Action 선택
|
||||
→ LookAt 모드
|
||||
→ 다른 선수 또는 코트 위치 클릭
|
||||
```
|
||||
|
||||
LookAt target은 다음을 지원할 수 있도록 설계한다.
|
||||
|
||||
```text
|
||||
rim
|
||||
ball
|
||||
player
|
||||
location
|
||||
```
|
||||
|
||||
예:
|
||||
|
||||
```js
|
||||
lookAt: {
|
||||
type: "player",
|
||||
targetId: "offense-4"
|
||||
}
|
||||
```
|
||||
|
||||
또는:
|
||||
|
||||
```js
|
||||
lookAt: {
|
||||
type: "location",
|
||||
x: 4.2,
|
||||
z: 6.1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 12. Sequence 편집
|
||||
|
||||
화면 하단 또는 측면에 Sequence UI를 둔다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
[Sequence 1] [Sequence 2] [Sequence 3] [+]
|
||||
```
|
||||
|
||||
현재 Sequence를 선택하면 해당 Sequence의 선수 위치와 Action 경로를 편집한다.
|
||||
|
||||
새 Sequence 생성 시:
|
||||
|
||||
**직전 Sequence의 마지막 위치를 새로운 Sequence의 시작 위치로 자동 상속한다.**
|
||||
|
||||
즉 사용자가 선수 위치를 다시 배치할 필요가 없어야 한다.
|
||||
|
||||
---
|
||||
|
||||
# 13. Sequence Duration 자동 계산
|
||||
|
||||
사용자가 Duration을 직접 입력하지 않게 한다.
|
||||
|
||||
각 PlayerTrack의 예상 수행시간을 자동 계산한다.
|
||||
|
||||
기본적으로:
|
||||
|
||||
```text
|
||||
이동거리 / 기본 선수 이동속도
|
||||
```
|
||||
|
||||
를 기반으로 한다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
Player 1 = 2.8초
|
||||
Player 2 = 0.9초
|
||||
Player 3 = 0초
|
||||
Player 4 = 1.4초
|
||||
Player 5 = 0초
|
||||
```
|
||||
|
||||
Sequence Duration은:
|
||||
|
||||
```text
|
||||
max(PlayerTrack duration)
|
||||
```
|
||||
|
||||
으로 자동 결정한다.
|
||||
|
||||
위 예에서는:
|
||||
|
||||
```text
|
||||
Sequence Duration = 2.8초
|
||||
```
|
||||
|
||||
이다.
|
||||
|
||||
Player 2는 0.9초에 Action이 끝난 후:
|
||||
|
||||
```text
|
||||
0.9초 ~ 2.8초
|
||||
마지막 위치 유지
|
||||
```
|
||||
|
||||
한다.
|
||||
|
||||
모든 선수는 동시에 다음 Sequence로 넘어간다.
|
||||
|
||||
---
|
||||
|
||||
# 14. Action Playback
|
||||
|
||||
Action 사이의 위치는 부드럽게 보간한다.
|
||||
|
||||
Three.js의 Vector3.lerp 또는 유사 interpolation을 사용한다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
Action 1
|
||||
↓
|
||||
interpolation
|
||||
↓
|
||||
Action 2
|
||||
```
|
||||
|
||||
Facing 역시 갑자기 회전하지 않고 자연스럽게 보간한다.
|
||||
|
||||
LookAt 역시 Action 사이에서 가능한 경우 자연스럽게 변화시킨다.
|
||||
|
||||
---
|
||||
|
||||
# 15. 전체 전술 재생
|
||||
|
||||
Play 버튼을 누르면:
|
||||
|
||||
```text
|
||||
Sequence 1
|
||||
↓
|
||||
Sequence 2
|
||||
↓
|
||||
Sequence 3
|
||||
↓
|
||||
...
|
||||
```
|
||||
|
||||
순서대로 자동 재생한다.
|
||||
|
||||
UI:
|
||||
|
||||
```text
|
||||
▶ Play
|
||||
⏸ Pause
|
||||
⏹ Reset
|
||||
```
|
||||
|
||||
정도만 우선 구현한다.
|
||||
|
||||
Playback은 기본 Tactical Camera에서 실행한다.
|
||||
|
||||
---
|
||||
|
||||
# 16. Player POV 재생
|
||||
|
||||
공격 선수를 선택한 후:
|
||||
|
||||
```text
|
||||
Player View
|
||||
```
|
||||
|
||||
버튼을 누르면 해당 선수 POV로 전환한다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
[ Tactical View ]
|
||||
|
||||
[ Player 1 POV ]
|
||||
[ Player 2 POV ]
|
||||
...
|
||||
```
|
||||
|
||||
Player POV 재생 중 카메라는 선수 이동을 따라간다.
|
||||
|
||||
Camera Position:
|
||||
|
||||
```text
|
||||
player location
|
||||
+
|
||||
eye height
|
||||
```
|
||||
|
||||
Camera Direction:
|
||||
|
||||
```text
|
||||
lookAt
|
||||
```
|
||||
|
||||
을 기준으로 한다.
|
||||
|
||||
lookAt이 없는 경우:
|
||||
|
||||
```text
|
||||
facing
|
||||
```
|
||||
|
||||
을 사용한다.
|
||||
|
||||
---
|
||||
|
||||
# 17. 시야 시각화
|
||||
|
||||
편집 화면에서 선택된 선수의 시야를 시각적으로 표현한다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
╱────────
|
||||
/
|
||||
Player ───────→ LookAt
|
||||
\
|
||||
╲────────
|
||||
```
|
||||
|
||||
구현 방식은:
|
||||
|
||||
- Arrow
|
||||
- Line
|
||||
- Sector
|
||||
- Cone
|
||||
|
||||
중 간단한 방식으로 먼저 구현한다.
|
||||
|
||||
MVP에서는 LookAt 방향 Arrow만 구현하고, 이후 Vision Cone으로 확장해도 된다.
|
||||
|
||||
---
|
||||
|
||||
# 18. 상태 관리
|
||||
|
||||
Three.js Object 자체를 전술 데이터의 Source of Truth로 사용하지 않는다.
|
||||
|
||||
반드시:
|
||||
|
||||
```text
|
||||
Application State
|
||||
↓
|
||||
Three.js Renderer
|
||||
```
|
||||
|
||||
형태로 분리한다.
|
||||
|
||||
즉:
|
||||
|
||||
```js
|
||||
play.sequences[0].playerTracks...
|
||||
```
|
||||
|
||||
가 실제 데이터이고,
|
||||
|
||||
Three.js Scene은 이를 렌더링하는 역할만 한다.
|
||||
|
||||
사용자가 선수를 이동하면:
|
||||
|
||||
```text
|
||||
UI Interaction
|
||||
→ State 변경
|
||||
→ Scene 업데이트
|
||||
```
|
||||
|
||||
순서를 따른다.
|
||||
|
||||
이 구조는 추후:
|
||||
|
||||
- 저장
|
||||
- 불러오기
|
||||
- Undo/Redo
|
||||
- 서버 연동
|
||||
- 공유
|
||||
- Replay
|
||||
|
||||
구현을 쉽게 하기 위함이다.
|
||||
|
||||
---
|
||||
|
||||
# 19. 파일 구조
|
||||
|
||||
과도하게 복잡하게 만들지 말고 기능 기준으로 분리한다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
src/
|
||||
├─ main.js
|
||||
│
|
||||
├─ three/
|
||||
│ ├─ scene.js
|
||||
│ ├─ court.js
|
||||
│ ├─ player.js
|
||||
│ ├─ cameras.js
|
||||
│ └─ renderer.js
|
||||
│
|
||||
├─ domain/
|
||||
│ ├─ play.js
|
||||
│ ├─ sequence.js
|
||||
│ ├─ action.js
|
||||
│ └─ player.js
|
||||
│
|
||||
├─ playback/
|
||||
│ ├─ playbackController.js
|
||||
│ ├─ interpolation.js
|
||||
│ └─ durationCalculator.js
|
||||
│
|
||||
├─ editor/
|
||||
│ ├─ playerSelection.js
|
||||
│ ├─ actionEditor.js
|
||||
│ ├─ lookAtEditor.js
|
||||
│ └─ sequenceEditor.js
|
||||
│
|
||||
├─ state/
|
||||
│ └─ playStore.js
|
||||
│
|
||||
└─ ui/
|
||||
├─ toolbar.js
|
||||
├─ sequenceBar.js
|
||||
└─ controls.js
|
||||
```
|
||||
|
||||
불필요한 클래스화나 추상화는 피한다.
|
||||
|
||||
---
|
||||
|
||||
# 20. MVP 화면 구성
|
||||
|
||||
하나의 메인 화면으로 시작한다.
|
||||
|
||||
```text
|
||||
┌───────────────────────────────────────┐
|
||||
│ Play Name Defense: Man to Man │
|
||||
├───────────────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ THREE.JS COURT │
|
||||
│ │
|
||||
│ │
|
||||
├───────────────────────────────────────┤
|
||||
│ Sequence 1 | Sequence 2 | + │
|
||||
├───────────────────────────────────────┤
|
||||
│ Move | LookAt | Play | Player View │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
편집 화면이 최대한 넓게 보이도록 한다.
|
||||
|
||||
---
|
||||
|
||||
# 21. 구현 우선순위
|
||||
|
||||
다음 순서대로 구현한다.
|
||||
|
||||
## Phase 1
|
||||
|
||||
Three.js 기본 Scene 구축.
|
||||
|
||||
- Court
|
||||
- Basket
|
||||
- 공격 5명
|
||||
- 수비 5명
|
||||
- Tactical Camera
|
||||
- Player Selection
|
||||
|
||||
---
|
||||
|
||||
## Phase 2
|
||||
|
||||
전술 데이터 모델 구현.
|
||||
|
||||
- Play
|
||||
- Sequence
|
||||
- PlayerTrack
|
||||
- Action
|
||||
- State ↔ Renderer 연결
|
||||
|
||||
---
|
||||
|
||||
## Phase 3
|
||||
|
||||
Action 편집.
|
||||
|
||||
- 선수 선택
|
||||
- 코트 클릭
|
||||
- Action.location 추가
|
||||
- 이동 경로 표시
|
||||
- Action 삭제
|
||||
|
||||
---
|
||||
|
||||
## Phase 4
|
||||
|
||||
Sequence.
|
||||
|
||||
- Sequence 추가
|
||||
- Sequence 선택
|
||||
- 직전 Sequence 위치 상속
|
||||
- Sequence 삭제
|
||||
|
||||
---
|
||||
|
||||
## Phase 5
|
||||
|
||||
Playback.
|
||||
|
||||
- Action interpolation
|
||||
- PlayerTrack duration 자동 계산
|
||||
- Sequence duration 자동 계산
|
||||
- Sequence 순차 재생
|
||||
- Play/Pause/Reset
|
||||
|
||||
---
|
||||
|
||||
## Phase 6
|
||||
|
||||
Facing / LookAt.
|
||||
|
||||
- 자동 Facing 계산
|
||||
- 기본 LookAt
|
||||
- 사용자 LookAt override
|
||||
- LookAt Arrow 표시
|
||||
|
||||
---
|
||||
|
||||
## Phase 7
|
||||
|
||||
Player POV.
|
||||
|
||||
- Player Camera
|
||||
- 선수 위치 추적
|
||||
- LookAt 기반 Camera rotation
|
||||
- Tactical / Player View 전환
|
||||
|
||||
---
|
||||
|
||||
# 22. MVP에서 제외
|
||||
|
||||
현재 단계에서는 다음 기능을 구현하지 않는다.
|
||||
|
||||
- 로그인
|
||||
- 서버 DB
|
||||
- 팀 관리
|
||||
- 전술 공유
|
||||
- 영상/GIF Export
|
||||
- 실제 사람 3D 모델
|
||||
- 수비 AI
|
||||
- 자동 수비 움직임
|
||||
- Read / Decision 분기
|
||||
- 패스 물리
|
||||
- 드리블 애니메이션
|
||||
- 슛 애니메이션
|
||||
- 복잡한 Timeline Editor
|
||||
- 개별 Action Duration 직접 입력
|
||||
- Multiplayer
|
||||
- WebSocket
|
||||
|
||||
MVP 완성 후 단계적으로 추가한다.
|
||||
|
||||
---
|
||||
|
||||
# 23. 핵심 UX 원칙
|
||||
|
||||
가장 중요한 요구사항이다.
|
||||
|
||||
**사용자가 전술 하나를 만들기 위해 입력해야 하는 값을 최소화한다.**
|
||||
|
||||
기본적으로 사용자는:
|
||||
|
||||
```text
|
||||
전술 생성
|
||||
→ 수비 형태 선택
|
||||
→ 선수 선택
|
||||
→ 이동 위치 연속 클릭
|
||||
→ 필요한 경우에만 LookAt 수정
|
||||
→ 다음 Sequence
|
||||
→ Play
|
||||
```
|
||||
|
||||
정도만 수행하면 전술이 만들어져야 한다.
|
||||
|
||||
다음 항목은 사용자에게 기본적으로 입력시키지 않는다.
|
||||
|
||||
- Facing angle
|
||||
- Duration
|
||||
- Velocity
|
||||
- Action weight
|
||||
- Sequence duration
|
||||
- Camera rotation
|
||||
- 정확한 좌표값
|
||||
|
||||
이 값들은 앱이 자동 계산한다.
|
||||
|
||||
---
|
||||
|
||||
# 24. 구현 원칙
|
||||
|
||||
1. 먼저 동작 가능한 MVP를 만든다.
|
||||
2. UI 디자인보다 편집 UX와 Playback 로직을 우선한다.
|
||||
3. 데이터 모델과 Three.js 렌더링 로직을 분리한다.
|
||||
4. 모든 전술 위치는 실제 Court 좌표로 저장한다.
|
||||
5. Player POV 확장을 고려해 처음부터 실제 3D Scene을 사용한다.
|
||||
6. Action/Sequence 데이터는 JSON Serialize 가능한 형태로 유지한다.
|
||||
7. 하드코딩된 전술에 종속된 구조를 만들지 않는다.
|
||||
8. 사용자의 Action 개수가 선수마다 달라도 정상 재생되어야 한다.
|
||||
9. Action이 먼저 끝난 선수는 Sequence 종료 시점까지 마지막 State를 유지한다.
|
||||
10. 모든 PlayerTrack이 완료된 후 다음 Sequence로 넘어간다.
|
||||
|
||||
---
|
||||
|
||||
# 25. 최초 완료 기준
|
||||
|
||||
다음 시나리오가 정상 동작하면 1차 MVP 완료로 판단한다.
|
||||
|
||||
1. 웹페이지 실행
|
||||
2. 하프코트 표시
|
||||
3. 공격 5명 / 수비 5명 표시
|
||||
4. Man to Man / 2-3 / 3-2 중 하나 선택
|
||||
5. Sequence 1 자동 생성
|
||||
6. 공격 1번 선택
|
||||
7. 코트상의 세 위치를 클릭
|
||||
8. Action 3개 자동 생성
|
||||
9. 공격 2번 선택
|
||||
10. 위치 하나 클릭
|
||||
11. Sequence 2 추가
|
||||
12. Sequence 1의 마지막 위치가 Sequence 2 시작 위치로 유지
|
||||
13. Sequence 2에서 추가 Action 작성
|
||||
14. Play 실행
|
||||
15. Sequence 1 → Sequence 2 자동 재생
|
||||
16. 선수별 Action 개수가 달라도 Sequence 종료 타이밍 정상 처리
|
||||
17. Tactical View에서 정상 재생
|
||||
18. Player 1 POV로 전환
|
||||
19. Player 1 이동을 카메라가 따라감
|
||||
20. 기본 LookAt 방향에 맞춰 카메라가 회전
|
||||
|
||||
이 상태까지 우선 구현한다.
|
||||
|
||||
구현 중 기존 요구사항과 충돌하지 않는 범위에서는 합리적인 기술적 판단으로 진행하되, 기능을 임의로 추가해 MVP 범위를 확대하지 않는다.
|
||||
Reference in New Issue
Block a user