Initial commit: courtlab tactical board with team management and MP4 export

This commit is contained in:
2026-09-08 12:22:20 +09:00
commit d2e39a452e
43 changed files with 7812 additions and 0 deletions
+467
View File
@@ -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);