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 { 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 } from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" import { getActionKind, placeArrow } 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 actionsReRenderHooks = [] 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 phantomActions = component.actions canPlaceArrows = phantomActions.length == 0 || phantomActions.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 || component.actions.findIndex( (p) => p.type != ActionKind.SHOOT, ) == -1 } return ( { 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={() => [ 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({ origin: component.id, type: getActionKind(false, info.ballState), target: ratioWithinBase( headPos, 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.target, ) 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 && ( ), ]} /> ) } const doDeleteAction = ( action: Action, idx: number, component: TacticComponent, ) => { setContent((content) => { content = updateComponent( { ...component, actions: component.actions.toSpliced(idx, 1), }, content, ) if (action.target == null) return content const target = content.components.find( (c) => action.target == c.id, )! if (target.type == "phantom") { let path = null if (component.type == "player") { path = component.path } else if (component.type == "phantom") { path = getOrigin(component, content.components).path } if ( path == null || path.items.find((c) => c == target.id) == null ) { return content } content = removePlayer(target, content) } return content }) } 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 ( { setContent((content) => removeBall(content), ) setObjects((objects) => [ ...objects, { key: "ball" }, ]) }} /> ) } throw new Error( "unknown tactic component " + component, ) }} renderActions={(component) => component.actions.map((action, i) => ( { doDeleteAction(action, i, component) }} onActionChanges={(a) => setContent((content) => updateComponent( { ...component, actions: component.actions.toSpliced( i, 1, a, ), }, content, ), ) } /> )) } />
) } 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] }