You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Application-Web/front/views/Editor.tsx

631 lines
21 KiB

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<SaveState>
onNameChange: (name: string) => Promise<boolean>
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 (
<EditorView
tactic={{
name: editorName,
id,
content: JSON.parse(editorContent),
}}
onContentChange={async (content: TacticContent) => {
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<CSSProperties>({})
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<RackedCourtObject[]>(
isBallOnCourt(content) ? [] : [{ key: "ball" }],
)
const courtDivContentRef = useRef<HTMLDivElement>(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 (
<div id="main-div">
<div id="topbar-div">
<button onClick={() => (location.pathname = BASE + "/")}>
Home
</button>
<div id="topbar-left">
<SavingState state={saveState} />
</div>
<div id="title-input-div">
<TitleInput
style={titleStyle}
default_value={name}
on_validated={(new_name) => {
onNameChange(new_name).then((success) => {
setTitleStyle(success ? {} : ERROR_STYLE)
})
}}
/>
</div>
<div id="topbar-right" />
</div>
<div id="edit-div">
<div id="racks">
<Rack
id="allies-rack"
objects={allies}
onChange={setAllies}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onRackPieceDetach}
render={({ team, key }) => (
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
)}
/>
<Rack
id={"objects"}
objects={objects}
onChange={setObjects}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onRackedObjectDetach}
render={renderCourtObject}
/>
<Rack
id="opponent-rack"
objects={opponents}
onChange={setOpponents}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onRackPieceDetach}
render={({ team, key }) => (
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
)}
/>
</div>
<div id="court-div">
<div id="court-div-bounds">
<BasketCourt
components={content.components}
actions={content.actions}
onBallMoved={onBallMoved}
courtImage={<Court courtType={courtType} />}
courtRef={courtDivContentRef}
setActions={(actions) =>
setContent((content) => ({
...content,
actions: actions(content.actions),
}))
}
renderAction={(action, i) => (
<CourtAction
key={i}
action={action}
courtRef={courtDivContentRef}
onActionDeleted={() => {
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}
/>
</div>
</div>
</div>
</div>
)
}
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 <BallPiece />
}
throw new Error("unknown racked court object " + courtObject.key)
}
function Court({ courtType }: { courtType: string }) {
return (
<div id="court-image-div">
{courtType == "PLAIN" ? (
<PlainCourt id="court-image" />
) : (
<HalfCourt id="court-image" />
)}
</div>
)
}
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<A, B>(
f: (args: A) => Promise<B>,
delay = 1000,
): (args: A) => Promise<B> {
let task = 0
return (args: A) => {
clearTimeout(task)
return new Promise((resolve, reject) => {
task = setTimeout(() => f(args).then(resolve).catch(reject), delay)
})
}
}
function useContentState<S>(
initialContent: S,
initialSaveState: SaveState,
saveStateCallback: (s: S) => Promise<SaveState>,
): [S, Dispatch<SetStateAction<S>>, SaveState] {
const [content, setContent] = useState(initialContent)
const [savingState, setSavingState] = useState(initialSaveState)
const setContentSynced = useCallback(
(newState: SetStateAction<S>) => {
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<A extends TacticComponent>(
array: A[],
it: A,
replace: boolean,
): A[] {
const idx = array.findIndex((i) => i.id == it.id)
return array.toSpliced(idx, 1, ...(replace ? [it] : []))
}