import {CSSProperties, Dispatch, SetStateAction, useCallback, useMemo, useRef, useState,} from "react" import "../style/editor.css" import TitleInput from "../components/TitleInput" import PlainCourt from "../assets/court/full_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react" import {BallPiece} from "../components/editor/BallPiece" import {Rack} from "../components/Rack" import {PlayerPiece} from "../components/editor/PlayerPiece" import {Player, PlayerTeam} from "../model/tactic/Player" import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic" import {fetchAPI} from "../Fetcher" import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" import {BALL_ID, BALL_TYPE, CourtObject, Ball} from "../model/tactic/Ball" import {CourtAction} from "./editor/CourtAction" import {BasketCourt} from "../components/editor/BasketCourt" import {Action, ActionKind} from "../model/tactic/Action" import {BASE} from "../Constants" import {overlaps} from "../geo/Box" import {ratioWithinBase} from "../geo/Pos" const ERROR_STYLE: CSSProperties = { borderColor: "red", } const GUEST_MODE_CONTENT_STORAGE_KEY = "guest_mode_content" const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" export interface EditorViewProps { tactic: Tactic onContentChange: (tactic: TacticContent) => Promise onNameChange: (name: string) => Promise courtType: "PLAIN" | "HALF" } export interface EditorProps { id: number name: string content: string courtType: "PLAIN" | "HALF" } /** * information about a player that is into a rack */ interface RackedPlayer { team: PlayerTeam key: string } type RackedCourtObject = { key: "ball" } export default function Editor({ id, name, courtType, content }: EditorProps) { const isInGuestMode = id == -1 const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) const editorContent = isInGuestMode && storage_content != null ? storage_content : content const storage_name = localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) const editorName = isInGuestMode && storage_name != null ? storage_name : name return ( { if (isInGuestMode) { localStorage.setItem( GUEST_MODE_CONTENT_STORAGE_KEY, JSON.stringify(content), ) return SaveStates.Guest } return fetchAPI(`tactic/${id}/save`, { content }).then((r) => r.ok ? SaveStates.Ok : SaveStates.Err, ) }} onNameChange={async (name: string) => { if (isInGuestMode) { localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) return true //simulate that the name has been changed } return fetchAPI(`tactic/${id}/edit/name`, { name }).then( (r) => r.ok, ) }} courtType={courtType} /> ) } function EditorView({ tactic: { id, name, content: initialContent }, onContentChange, onNameChange, courtType, }: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) const [content, setContent, saveState] = useContentState( initialContent, isInGuestMode ? SaveStates.Guest : SaveStates.Ok, useMemo( () => debounceAsync( (content) => onContentChange(content).then((success) => success ? SaveStates.Ok : SaveStates.Err, ), 250, ), [onContentChange], ), ) const [allies, setAllies] = useState( getRackPlayers(PlayerTeam.Allies, content.components), ) const [opponents, setOpponents] = useState( getRackPlayers(PlayerTeam.Opponents, content.components), ) const [objects, setObjects] = useState( isBallOnCourt(content) ? [] : [{ key: "ball" }], ) const courtDivContentRef = useRef(null) const isBoundsOnCourt = (bounds: DOMRect) => { const courtBounds = courtDivContentRef.current!.getBoundingClientRect() // check if refBounds overlaps courtBounds return overlaps(courtBounds, bounds) } const onRackPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const { x, y } = ratioWithinBase(refBounds, courtBounds) setContent((content) => { return { ...content, components: [ ...content.components, { type: "player", id: "player-" + element.key + "-" + element.team, team: element.team, role: element.key, rightRatio: x, bottomRatio: y, hasBall: false, } as Player, ], actions: content.actions, } }) } const onRackedObjectDetach = ( ref: HTMLDivElement, rackedObject: RackedCourtObject, ) => { const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const { x, y } = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject switch (rackedObject.key) { case BALL_TYPE: const ballObj = content.components.findIndex( (o) => o.type == BALL_TYPE, ) const playerCollidedIdx = getComponentCollided( refBounds, content.components.toSpliced(ballObj, 1), ) if (playerCollidedIdx != -1) { onBallDropOnComponent(playerCollidedIdx) return } courtObject = { type: BALL_TYPE, id: BALL_ID, rightRatio: x, bottomRatio: y, } break default: throw new Error("unknown court object " + rackedObject.key) } setContent((content) => { return { ...content, components: [...content.components, courtObject], } }) } const getComponentCollided = ( bounds: DOMRect, components: TacticComponent[], ): number | -1 => { for (let i = 0; i < components.length; i++) { const component = components[i] const playerBounds = document .getElementById(component.id)! .getBoundingClientRect() if (overlaps(playerBounds, bounds)) { return i } } return -1 } function updateActions(actions: Action[], components: TacticComponent[]) { return actions.map((action) => { const originHasBall = ( components.find( (p) => p.type == "player" && p.id == action.fromId, )! as Player ).hasBall let type = action.type if (originHasBall && type == ActionKind.MOVE) { type = ActionKind.DRIBBLE } else if (originHasBall && type == ActionKind.SCREEN) { type = ActionKind.SHOOT } else if (type == ActionKind.DRIBBLE) { type = ActionKind.MOVE } else if (type == ActionKind.SHOOT) { type = ActionKind.SCREEN } return { ...action, type, } }) } const onBallDropOnComponent = (collidedComponentIdx: number) => { setContent((content) => { const ballObj = content.components.findIndex( (p) => p.type == BALL_TYPE, ) let component = content.components[collidedComponentIdx] if (component.type != "player") { return content //do nothing if the ball isn't dropped on a player. } const components = content.components.toSpliced( collidedComponentIdx, 1, { ...component, hasBall: true, }, ) // Maybe the ball is not present on the court as an object component // if so, don't bother removing it from the court. // This can occur if the user drags and drop the ball from a player that already has the ball // to another component if (ballObj != -1) { components.splice(ballObj, 1) } return { ...content, actions: updateActions(content.actions, components), components, } }) } const onBallMoved = (refBounds: DOMRect) => { if (!isBoundsOnCourt(refBounds)) { removeCourtBall() return } const playerCollidedIdx = getComponentCollided( refBounds, content.components, ) if (playerCollidedIdx != -1) { setContent((content) => { return { ...content, components: content.components.map((c) => c.type == "player" ? { ...c, hasBall: false, } : c, ), } }) onBallDropOnComponent(playerCollidedIdx) return } if (content.components.findIndex((o) => o.type == "ball") != -1) { return } const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const { x, y } = ratioWithinBase(refBounds, courtBounds) const courtObject = { type: BALL_TYPE, id: BALL_ID, rightRatio: x, bottomRatio: y, } as Ball let components = content.components.map((c) => c.type == "player" ? { ...c, hasBall: false, } : c, ) components = [...components, courtObject] setContent((content) => ({ ...content, actions: updateActions(content.actions, components), components, })) } const removePlayer = (player: Player) => { setContent((content) => ({ ...content, components: replaceOrInsert(content.components, player, false), actions: content.actions.filter( (a) => a.toId !== player.id && a.fromId !== player.id, ), })) let setter switch (player.team) { case PlayerTeam.Opponents: setter = setOpponents break case PlayerTeam.Allies: setter = setAllies } if (player.hasBall) { setObjects([{ key: "ball" }]) } setter((players) => [ ...players, { team: player.team, pos: player.role, key: player.role, }, ]) } const removeCourtBall = () => { setContent((content) => { const ballObj = content.components.findIndex( (o) => o.type == "ball", ) const components = content.components.map((c) => c.type == "player" ? ({ ...c, hasBall: false, } as Player) : c, ) components.splice(ballObj, 1) return { ...content, components, } }) setObjects([{ key: "ball" }]) } return (
{ onNameChange(new_name).then((success) => { setTitleStyle(success ? {} : ERROR_STYLE) }) }} />
isBoundsOnCourt(div.getBoundingClientRect()) } onElementDetached={onRackPieceDetach} render={({ team, key }) => ( )} /> isBoundsOnCourt(div.getBoundingClientRect()) } onElementDetached={onRackedObjectDetach} render={renderCourtObject} /> isBoundsOnCourt(div.getBoundingClientRect()) } onElementDetached={onRackPieceDetach} render={({ team, key }) => ( )} />
} courtRef={courtDivContentRef} setActions={(actions) => setContent((content) => ({ ...content, actions: actions(content.actions), })) } renderAction={(action, i) => ( { setContent((content) => ({ ...content, actions: content.actions.toSpliced( i, 1, ), })) }} onActionChanges={(a) => setContent((content) => ({ ...content, actions: content.actions.toSpliced( i, 1, a, ), })) } /> )} onPlayerChange={(player) => { const playerBounds = document .getElementById(player.id)! .getBoundingClientRect() if (!isBoundsOnCourt(playerBounds)) { removePlayer(player) return } setContent((content) => ({ ...content, components: replaceOrInsert( content.components, player, true, ), })) }} onPlayerRemove={removePlayer} onBallRemove={removeCourtBall} />
) } function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( (c) => (c.type == "player" && c.hasBall) || c.type == BALL_TYPE, ) != -1 ) } function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { return } throw new Error("unknown racked court object " + courtObject.key) } function Court({ courtType }: { courtType: string }) { return (
{courtType == "PLAIN" ? ( ) : ( )}
) } function getRackPlayers( team: PlayerTeam, components: TacticComponent[], ): RackedPlayer[] { return ["1", "2", "3", "4", "5"] .filter( (role) => components.findIndex( (c) => c.type == "player" && c.team == team && c.role == role, ) == -1, ) .map((key) => ({ team, key })) } function debounceAsync( f: (args: A) => Promise, delay = 1000, ): (args: A) => Promise { let task = 0 return (args: A) => { clearTimeout(task) return new Promise((resolve, reject) => { task = setTimeout(() => f(args).then(resolve).catch(reject), delay) }) } } function useContentState( initialContent: S, initialSaveState: SaveState, saveStateCallback: (s: S) => Promise, ): [S, Dispatch>, SaveState] { const [content, setContent] = useState(initialContent) const [savingState, setSavingState] = useState(initialSaveState) const setContentSynced = useCallback( (newState: SetStateAction) => { setContent((content) => { const state = typeof newState === "function" ? (newState as (state: S) => S)(content) : newState if (state !== content) { setSavingState(SaveStates.Saving) saveStateCallback(state) .then(setSavingState) .catch(() => setSavingState(SaveStates.Err)) } return state }) }, [saveStateCallback], ) return [content, setContentSynced, savingState] } function replaceOrInsert( array: A[], it: A, replace: boolean, ): A[] { const idx = array.findIndex((i) => i.id == it.id) return array.toSpliced(idx, 1, ...(replace ? [it] : [])) }