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 };