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 } from "../model/tactic/Player" import { Tactic, TacticContent } from "../model/tactic/Tactic" import { fetchAPI } from "../Fetcher" import { PlayerTeam } from "../model/tactic/Player" import SavingState, { SaveState, SaveStates, } from "../components/editor/SavingState" import { CourtObject } from "../model/tactic/Ball" import { CourtAction } from "./editor/CourtAction" import { BasketCourt } from "../components/editor/BasketCourt" import { ratioWithinBase } from "../components/arrows/Pos" import { Action, ActionKind } from "../model/tactic/Action" 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" } /** * 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.players), ) const [opponents, setOpponents] = useState( getRackPlayers(PlayerTeam.Opponents, content.players), ) 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 !( bounds.top > courtBounds.bottom || bounds.right < courtBounds.left || bounds.bottom < courtBounds.top || bounds.left > courtBounds.right ) } const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const { x, y } = ratioWithinBase(refBounds, courtBounds) setContent((content) => { return { ...content, players: [ ...content.players, { id: "player-" + element.key + "-" + element.team, team: element.team, role: element.key, rightRatio: x, bottomRatio: y, hasBall: false, }, ], actions: content.actions, } }) } const onObjectDetach = ( 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": const ballObj = content.objects.findIndex( (o) => o.type == "ball", ) const playerCollidedIdx = getPlayerCollided( refBounds, content.players, ) if (playerCollidedIdx != -1) { onBallDropOnPlayer(playerCollidedIdx) setContent((content) => { return { ...content, objects: content.objects.toSpliced(ballObj, 1), } }) return } courtObject = { type: "ball", rightRatio: x, bottomRatio: y, } break default: throw new Error("unknown court object " + rackedObject.key) } setContent((content) => { return { ...content, objects: [...content.objects, courtObject], } }) } const getPlayerCollided = ( bounds: DOMRect, players: Player[], ): number | -1 => { for (let i = 0; i < players.length; i++) { const player = players[i] const playerBounds = document .getElementById(player.id)! .getBoundingClientRect() const doesOverlap = !( bounds.top > playerBounds.bottom || bounds.right < playerBounds.left || bounds.bottom < playerBounds.top || bounds.left > playerBounds.right ) if (doesOverlap) { return i } } return -1 } function updateActions(actions: Action[], players: Player[]) { return actions.map((action) => { const originHasBall = players.find( (p) => p.id == action.fromPlayerId, )!.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 onBallDropOnPlayer = (playerCollidedIdx: number) => { setContent((content) => { const ballObj = content.objects.findIndex((o) => o.type == "ball") let player = content.players.at(playerCollidedIdx) as Player const players = content.players.toSpliced(playerCollidedIdx, 1, { ...player, hasBall: true, }) return { ...content, actions: updateActions(content.actions, players), players, objects: content.objects.toSpliced(ballObj, 1), } }) } const onBallDrop = (refBounds: DOMRect) => { if (!isBoundsOnCourt(refBounds)) { removeCourtBall() return } const playerCollidedIdx = getPlayerCollided(refBounds, content.players) if (playerCollidedIdx != -1) { setContent((content) => { return { ...content, players: content.players.map((player) => ({ ...player, hasBall: false, })), } }) onBallDropOnPlayer(playerCollidedIdx) return } if (content.objects.findIndex((o) => o.type == "ball") != -1) { return } const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const { x, y } = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject courtObject = { type: "ball", rightRatio: x, bottomRatio: y, } const players = content.players.map((player) => ({ ...player, hasBall: false, })) setContent((content) => { return { ...content, actions: updateActions(content.actions, players), players, objects: [...content.objects, courtObject], } }) } const removePlayer = (player: Player) => { setContent((content) => ({ ...content, players: toSplicedPlayers(content.players, player, false), objects: [...content.objects], actions: content.actions.filter( (a) => a.toPlayerId !== player.id && a.fromPlayerId !== 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.objects.findIndex((o) => o.type == "ball") return { ...content, players: content.players.map((player) => ({ ...player, hasBall: false, })), objects: content.objects.toSpliced(ballObj, 1), } }) setObjects([{ key: "ball" }]) } return (
{ onNameChange(new_name).then((success) => { setTitleStyle(success ? {} : ERROR_STYLE) }) }} />
isBoundsOnCourt(div.getBoundingClientRect()) } onElementDetached={onPieceDetach} render={({ team, key }) => ( )} /> isBoundsOnCourt(div.getBoundingClientRect()) } onElementDetached={onObjectDetach} render={renderCourtObject} /> isBoundsOnCourt(div.getBoundingClientRect()) } onElementDetached={onPieceDetach} render={({ team, key }) => ( )} />
} courtRef={courtDivContentRef} setActions={(actions) => setContent((content) => ({ ...content, players: content.players, 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, players: toSplicedPlayers( content.players, player, true, ), })) }} onPlayerRemove={removePlayer} onBallRemove={removeCourtBall} />
) } function isBallOnCourt(content: TacticContent) { if (content.players.findIndex((p) => p.hasBall) != -1) { return true } return content.objects.findIndex((o) => o.type == "ball") != -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, players: Player[]): RackedPlayer[] { return ["1", "2", "3", "4", "5"] .filter( (role) => players.findIndex((p) => p.team == team && p.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 toSplicedPlayers( players: Player[], player: Player, replace: boolean, ): Player[] { const idx = players.findIndex( (p) => p.team === player.team && p.role === player.role, ) return players.toSpliced(idx, 1, ...(replace ? [player] : [])) }