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 {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 {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} from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" import {getActionKind, placeArrow, repositionActionsRelatedTo,} from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" import {middlePos, ratioWithinBase} from "../geo/Pos" import {Action, ActionKind} from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" import {getOrigin, removePlayer, truncatePlayerPath,} 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 setActions = (action: SetStateAction) => { setContent((c) => ({ ...c, actions: typeof action == "function" ? action(c.actions) : action, })) } const setComponents = (action: SetStateAction) => { setContent((c) => ({ ...c, components: typeof action == "function" ? action(c.components) : action, })) } 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) { setObjects([{key: "ball"}]) } setter((players) => [ ...players, { team: player.team, pos: player.role, key: player.role, }, ]) } const doMoveBall = (newBounds: DOMRect) => { setContent((content) => { const {newContent, removed} = placeBallAt( newBounds, courtBounds(), content, ) if (removed) { setObjects((objects) => [...objects, {key: "ball"}]) } return newContent }) } const courtBounds = () => courtRef.current!.getBoundingClientRect() const renderPlayer = (component: Player | PlayerPhantom) => { let info: PlayerInfo let canPlaceArrows: boolean const isPhantom = component.type == "phantom" if (isPhantom) { const origin = getOrigin(component, content.components) const path = origin.path! // phantoms can only place other arrows if they are the head of the path canPlaceArrows = path.items.indexOf(component.id) == path.items.length - 1 if (canPlaceArrows) { // and if their only action is to shoot the ball // list the actions the phantoms does const phantomArrows = content.actions.filter(c => c.fromId == component.id) canPlaceArrows = phantomArrows.length == 0 || phantomArrows.findIndex(c => c.type != ActionKind.SHOOT) == -1 } info = { id: component.id, team: origin.team, role: origin.role, bottomRatio: component.bottomRatio, rightRatio: component.rightRatio, ballState: component.ballState, } } else { // a player info = component // can place arrows only if the canPlaceArrows = component.path == null || content.actions.findIndex(p => p.fromId == component.id && p.type != ActionKind.SHOOT) == -1 } return ( setActions((actions) => repositionActionsRelatedTo(info.id, courtBounds(), actions), ) } onPositionValidated={(newPos) => { setContent((content) => moveComponent( newPos, component, info, courtBounds(), content, (content) => { if (!isPhantom) insertRackedPlayer(component) return removePlayer(component, content) }, ), ) }} onRemove={() => { setContent((c) => removePlayer(component, c)) if (!isPhantom) insertRackedPlayer(component) }} courtRef={courtRef} availableActions={(pieceRef) => [ canPlaceArrows && ( { const arrowHeadPos = middlePos(headPos) const targetIdx = getComponentCollided(headPos, content.components) setPreviewAction((action) => ({ ...action!, segments: [ { next: ratioWithinBase( arrowHeadPos, courtBounds(), ), }, ], type: getActionKind(targetIdx != -1, info.ballState), })) }} onHeadPicked={(headPos) => { (document.activeElement as HTMLElement).blur() setPreviewAction({ type: getActionKind(false, info.ballState), fromId: info.id, toId: null, moveFrom: ratioWithinBase( middlePos( pieceRef.getBoundingClientRect(), ), courtBounds(), ), segments: [ { next: ratioWithinBase( middlePos(headPos), courtBounds(), ), }, ], }) }} onHeadDropped={(headRect) => { setContent((content) => { let {createdAction, newContent} = placeArrow( component, courtBounds(), headRect, content, ) let originNewBallState = component.ballState if (createdAction.type == ActionKind.SHOOT) { const targetIdx = newContent.components.findIndex(c => c.id == createdAction.toId) newContent = dropBallOnComponent(targetIdx, newContent) originNewBallState = BallState.SHOOTED } newContent = updateComponent({ ...(newContent.components.find(c => c.id == component.id)! as Player | PlayerPhantom), ballState: originNewBallState }, newContent) return newContent }) setPreviewAction(null) }} /> ), info.ballState != BallState.NONE && ( ), ]} /> ) } return (
{ onNameChange(new_name).then((success) => { setTitleStyle(success ? {} : ERROR_STYLE) }) }} />
overlaps(courtBounds(), div.getBoundingClientRect()) } onElementDetached={(r, e) => setComponents((components) => [ ...components, placePlayerAt( r.getBoundingClientRect(), courtBounds(), e, ), ]) } render={({team, key}) => ( )} /> overlaps(courtBounds(), div.getBoundingClientRect()) } onElementDetached={(r, e) => setContent((content) => placeObjectAt( r.getBoundingClientRect(), courtBounds(), e, content, ), ) } render={renderCourtObject} /> overlaps(courtBounds(), div.getBoundingClientRect()) } onElementDetached={(r, e) => setComponents((components) => [ ...components, placePlayerAt( r.getBoundingClientRect(), courtBounds(), e, ), ]) } render={({team, key}) => ( )} />
} courtRef={courtRef} previewAction={previewAction} renderComponent={(component) => { if ( component.type == "player" || component.type == "phantom" ) { return renderPlayer(component) } if (component.type == BALL_TYPE) { return ( setActions((actions) => repositionActionsRelatedTo( component.id, courtBounds(), actions, ), ) } onRemove={() => { setContent((content) => removeBall(content), ) setObjects(objects => [...objects, {key: "ball"}]) }} /> ) } throw new Error( "unknown tactic component " + component, ) }} renderAction={(action, i) => ( { setContent((content) => { content = { ...content, actions: content.actions.toSpliced( i, 1, ), } if (action.toId == null) return content const target = content.components.find( (c) => action.toId == c.id, )! if (target.type == "phantom") { const origin = getOrigin( target, content.components, ) if (origin.id != action.fromId) { return content } content = truncatePlayerPath( origin, target, content, ) } return content }) }} onActionChanges={(a) => setContent((content) => ({ ...content, actions: content.actions.toSpliced( i, 1, a, ), })) } /> )} />
) } function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( (c) => (c.type == "player" && c.ballState == BallState.HOLDS) || 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] }