import { CSSProperties, Dispatch, RefObject, SetStateAction, useCallback, useEffect, 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 {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic" import {fetchAPI} from "../Fetcher" import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" import {BALL_TYPE} from "../model/tactic/CourtObjects" import {CourtAction} from "./editor/CourtAction" import {ActionPreview, BasketCourt} from "../components/editor/BasketCourt" import {overlaps} from "../geo/Box" import { dropBallOnComponent, getComponentCollided, getRackPlayers, moveComponent, placeBallAt, placeObjectAt, placePlayerAt, removeBall, updateComponent, } from "../editor/TacticContentDomains" import {BallState, Player, PlayerInfo, PlayerPhantom, PlayerTeam,} from "../model/tactic/Player" import {RackedCourtObject, RackedPlayer} from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" import {createAction, getActionKind, isActionValid, removeAction} from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" import {middlePos, Pos, ratioWithinBase} from "../geo/Pos" import {Action, ActionKind} from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" import {changePlayerBallState, getOrigin, removePlayer,} from "../editor/PlayerDomains" import {CourtBall} from "../components/editor/CourtBall" import {BASE} from "../Constants" 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" } 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 [previewAction, setPreviewAction] = useState( null, ) const courtRef = useRef(null) const setComponents = (action: SetStateAction) => { setContent((c) => ({ ...c, components: typeof action == "function" ? action(c.components) : action, })) } const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) useEffect(() => { setObjects(isBallOnCourt(content) ? [] : [{key: "ball"}]) }, [setObjects, content]); const insertRackedPlayer = (player: Player) => { let setter switch (player.team) { case PlayerTeam.Opponents: setter = setOpponents break case PlayerTeam.Allies: setter = setAllies } if (player.ballState == BallState.HOLDS_BY_PASS) { setObjects([{key: "ball"}]) } setter((players) => [ ...players, { team: player.team, pos: player.role, key: player.role, }, ]) } const doRemovePlayer = useCallback((component: Player | PlayerPhantom) => { setContent((c) => removePlayer(component, c)) if (component.type == "player") insertRackedPlayer(component) }, [setContent]) const doMoveBall = useCallback((newBounds: DOMRect, from?: Player | PlayerPhantom) => { setContent((content) => { if (from) { content = changePlayerBallState(from, BallState.NONE, content) } content = placeBallAt( newBounds, courtBounds(), content, ) return content }) }, [courtBounds, setContent]) const validatePlayerPosition = useCallback((player: Player | PlayerPhantom, info: PlayerInfo, newPos: Pos) => { setContent((content) => moveComponent( newPos, player, info, courtBounds(), content, (content) => { if (player.type == "player") insertRackedPlayer(player) return removePlayer(player, content) }, ), ) }, [courtBounds, setContent]) const renderAvailablePlayerActions = useCallback((info: PlayerInfo, player: Player | PlayerPhantom) => { let canPlaceArrows: boolean if (player.type == "player") { canPlaceArrows = player.path == null || player.actions.findIndex( (p) => p.type != ActionKind.SHOOT, ) == -1 } else { const origin = getOrigin(player, content.components) const path = origin.path! // phantoms can only place other arrows if they are the head of the path canPlaceArrows = path.items.indexOf(player.id) == path.items.length - 1 if (canPlaceArrows) { // and if their only action is to shoot the ball const phantomActions = player.actions canPlaceArrows = phantomActions.length == 0 || phantomActions.findIndex( (c) => c.type != ActionKind.SHOOT, ) == -1 } } return [ canPlaceArrows && ( ), (info.ballState === BallState.HOLDS_ORIGIN || info.ballState === BallState.PASSED_ORIGIN) && ( { doMoveBall(ballBounds, player) }}/> ), ] }, [content, doMoveBall, previewAction?.isInvalid, setContent]) const renderPlayer = useCallback((component: Player | PlayerPhantom) => { let info: PlayerInfo const isPhantom = component.type == "phantom" if (isPhantom) { const origin = getOrigin(component, content.components) info = { id: component.id, team: origin.team, role: origin.role, bottomRatio: component.bottomRatio, rightRatio: component.rightRatio, ballState: component.ballState, } } else { info = component } return ( validatePlayerPosition(component, info, newPos)} onRemove={() => doRemovePlayer(component)} courtRef={courtRef} availableActions={() => renderAvailablePlayerActions(info, component)} /> ) }, [content.components, doRemovePlayer, renderAvailablePlayerActions, validatePlayerPosition]) const doDeleteAction = useCallback(( action: Action, idx: number, origin: TacticComponent, ) => { setContent((content) => removeAction(origin, action, idx, content)) }, [setContent]) const doUpdateAction = useCallback((component: TacticComponent, action: Action, actionIndex: number) => { setContent((content) => updateComponent( { ...component, actions: component.actions.toSpliced( actionIndex, 1, action, ), }, content, ), ) }, [setContent]) const renderComponent = useCallback((component: TacticComponent) => { if ( component.type == "player" || component.type == "phantom" ) { return renderPlayer(component) } if (component.type == BALL_TYPE) { return ( { setContent((content) => removeBall(content), ) setObjects((objects) => [ ...objects, {key: "ball"}, ]) }} /> ) } throw new Error( "unknown tactic component " + component, ) }, [renderPlayer, doMoveBall, setContent]) const renderActions = useCallback((component: TacticComponent) => component.actions.map((action, i) => { return ( { doDeleteAction(action, i, component) }} onActionChanges={(action) => doUpdateAction(component, action, i) } /> ) }), [doDeleteAction, doUpdateAction]) return (
{ onNameChange(new_name).then((success) => { setTitleStyle(success ? {} : ERROR_STYLE) }) }, [onNameChange])} />
overlaps(courtBounds(), div.getBoundingClientRect()) , [courtBounds])} onElementDetached={useCallback((r, e: RackedCourtObject) => setContent((content) => placeObjectAt( r.getBoundingClientRect(), courtBounds(), e, content, ), ) , [courtBounds, setContent])} render={renderCourtObject} />
} courtRef={courtRef} previewAction={previewAction} renderComponent={renderComponent} renderActions={renderActions} />
) } interface PlayerRackProps { id: string objects: RackedPlayer[] setObjects: (state: RackedPlayer[]) => void setComponents: (f: (components: TacticComponent[]) => TacticComponent[]) => void courtRef: RefObject } function PlayerRack({id, objects, setObjects, courtRef, setComponents}: PlayerRackProps) { const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) return ( overlaps(courtBounds(), div.getBoundingClientRect()) , [courtBounds])} onElementDetached={useCallback((r, e: RackedPlayer) => setComponents((components) => [ ...components, placePlayerAt( r.getBoundingClientRect(), courtBounds(), e, ), ]) , [courtBounds, setComponents])} render={useCallback(({team, key}: { team: PlayerTeam, key: string }) => ( ), [])} /> ) } interface CourtPlayerArrowActionProps { playerInfo: PlayerInfo player: Player | PlayerPhantom isInvalid: boolean content: TacticContent setContent: (state: SetStateAction) => void setPreviewAction: (state: SetStateAction) => void courtRef: RefObject } function CourtPlayerArrowAction({ playerInfo, player, isInvalid, content, setContent, setPreviewAction, courtRef }: CourtPlayerArrowActionProps) { const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) return ( { const arrowHeadPos = middlePos(headPos) const targetIdx = getComponentCollided( headPos, content.components, ) const target = content.components[targetIdx] setPreviewAction((action) => ({ ...action!, segments: [ { next: ratioWithinBase( arrowHeadPos, courtBounds(), ), }, ], type: getActionKind( target, playerInfo.ballState, ), isInvalid: !overlaps(headPos, courtBounds()) || !isActionValid(player, target, content.components) })) }} onHeadPicked={(headPos) => { (document.activeElement as HTMLElement).blur() setPreviewAction({ origin: playerInfo.id, type: getActionKind(null, playerInfo.ballState), target: ratioWithinBase( headPos, courtBounds(), ), segments: [ { next: ratioWithinBase( middlePos(headPos), courtBounds(), ), }, ], isInvalid: false }) }} onHeadDropped={(headRect) => { if (isInvalid) { setPreviewAction(null) return } setContent((content) => { let {createdAction, newContent} = createAction( player, courtBounds(), headRect, content, ) if ( createdAction.type == ActionKind.SHOOT ) { const targetIdx = newContent.components.findIndex( (c) => c.id == createdAction.target, ) newContent = dropBallOnComponent( targetIdx, newContent, false ) newContent = updateComponent( { ...(newContent.components.find( (c) => c.id == player.id, )! as Player | PlayerPhantom), ballState: BallState.PASSED, }, newContent, ) } return newContent }) setPreviewAction(null) }} /> ) } function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( (c) => (c.type == "player" && (c.ballState === BallState.HOLDS_ORIGIN || c.ballState === BallState.HOLDS_BY_PASS)) || 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 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] }