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
+353
View File
@@ -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 };