diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx index a26785c..f4af373 100644 --- a/front/components/actions/BallAction.tsx +++ b/front/components/actions/BallAction.tsx @@ -1,17 +1,21 @@ -import { BallPiece } from "../editor/BallPiece" +import {BallPiece} from "../editor/BallPiece" import Draggable from "react-draggable" -import { useRef } from "react" +import {useRef} from "react" +import {NULL_POS} from "../../geo/Pos"; export interface BallActionProps { - onDrop: (el: HTMLElement) => void + onDrop: (el: DOMRect) => void } -export default function BallAction({ onDrop }: BallActionProps) { +export default function BallAction({onDrop}: BallActionProps) { const ref = useRef(null) return ( - onDrop(ref.current!)} nodeRef={ref}> + onDrop(ref.current!.getBoundingClientRect())} + position={NULL_POS}>
- +
) diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index e2219bb..e46fb7b 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -147,7 +147,7 @@ export default function BendableArrow({ // If the (original) segments changes, overwrite the current ones. useLayoutEffect(() => { setInternalSegments(computeInternalSegments(segments)) - }, [startPos, segments, computeInternalSegments]) + }, [computeInternalSegments]) const [isSelected, setIsSelected] = useState(false) diff --git a/front/components/editor/BallPiece.tsx b/front/components/editor/BallPiece.tsx index d72ad75..1156780 100644 --- a/front/components/editor/BallPiece.tsx +++ b/front/components/editor/BallPiece.tsx @@ -1,8 +1,8 @@ import "../../style/ball.css" import BallSvg from "../../assets/icon/ball.svg?react" -import {BALL_ID} from "../../model/tactic/Ball"; +import { BALL_ID } from "../../model/tactic/CourtObjects" export function BallPiece() { - return + return } diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 7aba76c..f684e1b 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,37 +1,16 @@ -import { CourtBall } from "./CourtBall" +import {ReactElement, ReactNode, RefObject, useLayoutEffect, useState,} from "react" +import {Action} from "../../model/tactic/Action" -import { - ReactElement, - RefObject, - useCallback, - useLayoutEffect, - useState, -} from "react" -import CourtPlayer from "./CourtPlayer" - -import { Player } from "../../model/tactic/Player" -import { Action, ActionKind } from "../../model/tactic/Action" -import ArrowAction from "../actions/ArrowAction" -import { middlePos, ratioWithinBase } from "../../geo/Pos" -import BallAction from "../actions/BallAction" -import {BALL_ID} from "../../model/tactic/Ball" -import { contains, overlaps } from "../../geo/Box" - -import { CourtAction } from "../../views/editor/CourtAction" -import { TacticComponent } from "../../model/tactic/Tactic" +import {CourtAction} from "../../views/editor/CourtAction" +import {TacticComponent} from "../../model/tactic/Tactic" export interface BasketCourtProps { components: TacticComponent[] actions: Action[] + previewAction: Action | null - renderAction: (a: Action, key: number) => ReactElement - setActions: (f: (a: Action[]) => Action[]) => void - - onPlayerRemove: (p: Player) => void - onPlayerChange: (p: Player) => void - - onBallRemove: () => void - onBallMoved: (ball: DOMRect) => void + renderComponent: (comp: TacticComponent) => ReactNode + renderAction: (action: Action, idx: number) => ReactNode courtImage: ReactElement courtRef: RefObject @@ -40,104 +19,14 @@ export interface BasketCourtProps { export function BasketCourt({ components, actions, - renderAction, - setActions, - onPlayerRemove, - onPlayerChange, + previewAction, - onBallMoved, - onBallRemove, + renderComponent, + renderAction, courtImage, courtRef, }: BasketCourtProps) { - function placeArrow(origin: Player, arrowHead: DOMRect) { - const originRef = document.getElementById(origin.id)! - const courtBounds = courtRef.current!.getBoundingClientRect() - const start = ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ) - - for (const component of components) { - if (component.id == origin.id) { - continue - } - - const playerBounds = document - .getElementById(component.id)! - .getBoundingClientRect() - - if (overlaps(playerBounds, arrowHead)) { - const targetPos = document - .getElementById(component.id)! - .getBoundingClientRect() - - const end = ratioWithinBase(middlePos(targetPos), courtBounds) - - const action: Action = { - fromId: originRef.id, - toId: component.id, - type: - component.type == "player" - ? origin.hasBall - ? ActionKind.SHOOT - : ActionKind.SCREEN - : ActionKind.MOVE, - moveFrom: start, - segments: [{ next: end }], - } - setActions((actions) => [...actions, action]) - return - } - } - - const action: Action = { - fromId: originRef.id, - type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE, - moveFrom: ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ), - segments: [ - { next: ratioWithinBase(middlePos(arrowHead), courtBounds) }, - ], - } - setActions((actions) => [...actions, action]) - } - - const [previewAction, setPreviewAction] = useState(null) - - const updateActionsRelatedTo = useCallback((comp: TacticComponent) => { - const newPos = ratioWithinBase( - middlePos( - document.getElementById(comp.id)!.getBoundingClientRect(), - ), - courtRef.current!.getBoundingClientRect(), - ) - setActions((actions) => - actions.map((a) => { - if (a.fromId == comp.id) { - return { ...a, moveFrom: newPos } - } - - if (a.toId == comp.id) { - const segments = a.segments.toSpliced( - a.segments.length - 1, - 1, - { - ...a.segments[a.segments.length - 1], - next: newPos, - }, - ) - return { ...a, segments } - } - - return a - }), - ) - }, []) - const [internActions, setInternActions] = useState([]) useLayoutEffect(() => setInternActions(actions), [actions]) @@ -149,122 +38,7 @@ export function BasketCourt({ style={{ position: "relative" }}> {courtImage} - {components.map((component) => { - if (component.type == "player") { - const player = component - return ( - updateActionsRelatedTo(player)} - onChange={onPlayerChange} - onRemove={() => onPlayerRemove(player)} - courtRef={courtRef} - availableActions={(pieceRef) => [ - { - const baseBounds = - courtRef.current!.getBoundingClientRect() - - const arrowHeadPos = middlePos(headPos) - - const target = components.find( - (c) => - c.id != player.id && - contains( - document - .getElementById(c.id)! - .getBoundingClientRect(), - arrowHeadPos, - ), - ) - - const type = - target?.type == "player" - ? player.hasBall - ? target - ? ActionKind.SHOOT - : ActionKind.DRIBBLE - : target - ? ActionKind.SCREEN - : ActionKind.MOVE - : ActionKind.MOVE - - setPreviewAction((action) => ({ - ...action!, - segments: [ - { - next: ratioWithinBase( - arrowHeadPos, - baseBounds, - ), - }, - ], - type, - })) - }} - onHeadPicked={(headPos) => { - ;( - document.activeElement as HTMLElement - ).blur() - const baseBounds = - courtRef.current!.getBoundingClientRect() - - setPreviewAction({ - type: player.hasBall - ? ActionKind.DRIBBLE - : ActionKind.MOVE, - fromId: player.id, - toId: undefined, - moveFrom: ratioWithinBase( - middlePos( - pieceRef.getBoundingClientRect(), - ), - baseBounds, - ), - segments: [ - { - next: ratioWithinBase( - middlePos(headPos), - baseBounds, - ), - }, - ], - }) - }} - onHeadDropped={(headRect) => { - placeArrow(player, headRect) - setPreviewAction(null) - }} - />, - player.hasBall && ( - - onBallMoved( - ref.getBoundingClientRect(), - ) - } - /> - ), - ]} - /> - ) - } - if (component.type == BALL_ID) { - return ( - updateActionsRelatedTo(component)} - ball={component} - onRemove={onBallRemove} - key="ball" - /> - ) - } - throw new Error("unknown tactic component " + component) - })} + {components.map(renderComponent)} {internActions.map((action, idx) => renderAction(action, idx))} @@ -272,7 +46,7 @@ export function BasketCourt({ {}} onActionChanges={() => {}} /> diff --git a/front/components/editor/CourtBall.tsx b/front/components/editor/CourtBall.tsx index 1e208be..53ae408 100644 --- a/front/components/editor/CourtBall.tsx +++ b/front/components/editor/CourtBall.tsx @@ -1,7 +1,8 @@ import React, { useRef } from "react" import Draggable from "react-draggable" import { BallPiece } from "./BallPiece" -import { Ball } from "../../model/tactic/Ball" +import { NULL_POS } from "../../geo/Pos" +import { Ball } from "../../model/tactic/CourtObjects" export interface CourtBallProps { onPosValidated: (rect: DOMRect) => void @@ -27,6 +28,7 @@ export function CourtBall({ onPosValidated(pieceRef.current!.getBoundingClientRect()) } onDrag={onMoves} + position={NULL_POS} nodeRef={pieceRef}>
void - onChange: (p: Player) => void +export interface CourtPlayerProps { + playerInfo: PlayerInfo + className?: string + + onMoves: () => void + onPositionValidated: (newPos: Pos) => void onRemove: () => void courtRef: RefObject availableActions: (ro: HTMLElement) => ReactNode[] @@ -18,45 +20,38 @@ export interface PlayerProps { * A player that is placed on the court, which can be selected, and moved in the associated bounds * */ export default function CourtPlayer({ - player, - onDrag, - onChange, + playerInfo, + className, + + onMoves, + onPositionValidated, onRemove, courtRef, availableActions, -}: PlayerProps) { - const hasBall = player.hasBall - const x = player.rightRatio - const y = player.bottomRatio +}: CourtPlayerProps) { + const usesBall = playerInfo.ballState != BallState.NONE + const x = playerInfo.rightRatio + const y = playerInfo.bottomRatio const pieceRef = useRef(null) return ( { const pieceBounds = pieceRef.current!.getBoundingClientRect() const parentBounds = courtRef.current!.getBoundingClientRect() - const { x, y } = ratioWithinBase(pieceBounds, parentBounds) - - onChange({ - type: "player", - id: player.id, - rightRatio: x, - bottomRatio: y, - team: player.team, - role: player.role, - hasBall: player.hasBall, - } as Player) + const pos = ratioWithinBase(pieceBounds, parentBounds) + onPositionValidated(pos) }}>
diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts new file mode 100644 index 0000000..1179dc7 --- /dev/null +++ b/front/editor/ActionsDomains.ts @@ -0,0 +1,214 @@ +import {BallState, Player, PlayerPhantom} from "../model/tactic/Player" +import {middlePos, ratioWithinBase} from "../geo/Pos" +import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" +import {overlaps} from "../geo/Box" +import {Action, ActionKind} from "../model/tactic/Action" +import {removeBall, updateComponent} from "./TacticContentDomains" +import {getOrigin} from "./PlayerDomains" + +export function refreshAllActions( + actions: Action[], + components: TacticComponent[], +) { + return actions.map((action) => ({ + ...action, + type: getActionKindFrom(action.fromId, action.toId, components), + })) +} + +export function getActionKindFrom( + originId: ComponentId, + targetId: ComponentId | null, + components: TacticComponent[], +): ActionKind { + const origin = components.find((p) => p.id == originId)! + const target = components.find(p => p.id == targetId) + + let ballState = BallState.NONE + + if (origin.type == "player" || origin.type == "phantom") { + ballState = origin.ballState + } + + let hasTarget = target ? (target.type != 'phantom' || target.originPlayerId != origin.id) : false + + return getActionKind(hasTarget, ballState) +} + +export function getActionKind(hasTarget: boolean, ballState: BallState): ActionKind { + switch (ballState) { + case BallState.HOLDS: + return hasTarget ? ActionKind.SHOOT : ActionKind.DRIBBLE + case BallState.SHOOTED: + return ActionKind.MOVE + case BallState.NONE: + return hasTarget ? ActionKind.SCREEN : ActionKind.MOVE + } +} + +export function placeArrow( + origin: Player | PlayerPhantom, + courtBounds: DOMRect, + arrowHead: DOMRect, + content: TacticContent, +): { createdAction: Action, newContent: TacticContent } { + const originRef = document.getElementById(origin.id)! + const start = ratioWithinBase( + middlePos(originRef.getBoundingClientRect()), + courtBounds, + ) + + /** + * Creates a new phantom component. + * Be aware that this function will reassign the `content` parameter. + * @param receivesBall + */ + function createPhantom(receivesBall: boolean): ComponentId { + const {x, y} = ratioWithinBase(arrowHead, courtBounds) + + let itemIndex: number + let originPlayer: Player + + if (origin.type == "phantom") { + // if we create a phantom from another phantom, + // simply add it to the phantom's path + const originPlr = getOrigin(origin, content.components)! + itemIndex = originPlr.path!.items.length + originPlayer = originPlr + } else { + // if we create a phantom directly from a player + // create a new path and add it into + itemIndex = 0 + originPlayer = origin + } + + const path = originPlayer.path + + const phantomId = "phantom-" + itemIndex + "-" + originPlayer.id + + content = updateComponent( + { + ...originPlayer, + path: { + items: path ? [...path.items, phantomId] : [phantomId], + }, + }, + content, + ) + + const ballState = receivesBall + ? BallState.HOLDS + : origin.ballState == BallState.HOLDS + ? BallState.HOLDS + : BallState.NONE + + const phantom: PlayerPhantom = { + type: "phantom", + id: phantomId, + rightRatio: x, + bottomRatio: y, + originPlayerId: originPlayer.id, + ballState + } + content = { + ...content, + components: [...content.components, phantom], + } + return phantom.id + } + + for (const component of content.components) { + if (component.id == origin.id) { + continue + } + + const componentBounds = document + .getElementById(component.id)! + .getBoundingClientRect() + + if (overlaps(componentBounds, arrowHead)) { + const targetPos = document + .getElementById(component.id)! + .getBoundingClientRect() + + const end = ratioWithinBase(middlePos(targetPos), courtBounds) + + let toId = component.id + + if (component.type == "ball") { + toId = createPhantom(true) + content = removeBall(content) + } + + const action: Action = { + fromId: originRef.id, + toId, + type: getActionKind(true, origin.ballState), + moveFrom: start, + segments: [{next: end}], + } + + return { + newContent: { + ...content, + actions: [...content.actions, action], + }, + createdAction: action + } + } + } + + const phantomId = createPhantom(origin.ballState == BallState.HOLDS) + + const action: Action = { + fromId: originRef.id, + toId: phantomId, + type: getActionKind(false, origin.ballState), + moveFrom: ratioWithinBase( + middlePos(originRef.getBoundingClientRect()), + courtBounds, + ), + segments: [ + {next: ratioWithinBase(middlePos(arrowHead), courtBounds)}, + ], + } + return { + newContent: { + ...content, + actions: [...content.actions, action], + }, + createdAction: action + } +} + +export function repositionActionsRelatedTo( + compId: ComponentId, + courtBounds: DOMRect, + actions: Action[], +): Action[] { + const posRect = document.getElementById(compId)?.getBoundingClientRect() + const newPos = posRect != undefined + ? ratioWithinBase(middlePos(posRect), courtBounds) + : undefined + + return actions.flatMap((action) => { + if (newPos == undefined) { + return [] + } + + if (action.fromId == compId) { + return [{...action, moveFrom: newPos}] + } + + if (action.toId == compId) { + const lastIdx = action.segments.length - 1 + const segments = action.segments.toSpliced(lastIdx, 1, { + ...action.segments[lastIdx], + next: newPos!, + }) + return [{...action, segments}] + } + + return action + }) +} diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts new file mode 100644 index 0000000..9ef1d45 --- /dev/null +++ b/front/editor/PlayerDomains.ts @@ -0,0 +1,80 @@ +import { Player, PlayerPhantom } from "../model/tactic/Player" +import { TacticComponent, TacticContent } from "../model/tactic/Tactic" +import { removeComponent, updateComponent } from "./TacticContentDomains" + +export function getOrigin( + pathItem: PlayerPhantom, + components: TacticComponent[], +): Player { + // Trust the components to contains only phantoms with valid player origin identifiers + return components.find((c) => c.id == pathItem.originPlayerId)! as Player +} + +export function removePlayerPath( + player: Player, + content: TacticContent, +): TacticContent { + if (player.path == null) { + return content + } + + for (const pathElement of player.path.items) { + content = removeComponent(pathElement, content) + } + return updateComponent( + { + ...player, + path: null, + }, + content, + ) +} + +export function removePlayer( + player: Player | PlayerPhantom, + content: TacticContent, +): TacticContent { + if (player.type == "phantom") { + const origin = getOrigin(player, content.components) + return truncatePlayerPath(origin, player, content) + } + + content = removePlayerPath(player, content) + return removeComponent(player.id, content) +} + +export function truncatePlayerPath( + player: Player, + phantom: PlayerPhantom, + content: TacticContent, +): TacticContent { + if (player.path == null) return content + + const path = player.path! + + let truncateStartIdx = -1 + + for (let j = 0; j < path.items.length; j++) { + const pathPhantomId = path.items[j] + if (truncateStartIdx != -1 || pathPhantomId == phantom.id) { + if (truncateStartIdx == -1) truncateStartIdx = j + + //remove the phantom from the tactic + content = removeComponent(pathPhantomId, content) + } + } + + return updateComponent( + { + ...player, + path: + truncateStartIdx == 0 + ? null + : { + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, + }, + content, + ) +} diff --git a/front/editor/RackedItems.ts b/front/editor/RackedItems.ts new file mode 100644 index 0000000..f2df151 --- /dev/null +++ b/front/editor/RackedItems.ts @@ -0,0 +1,11 @@ +/** + * information about a player that is into a rack + */ +import { PlayerTeam } from "../model/tactic/Player" + +export interface RackedPlayer { + team: PlayerTeam + key: string +} + +export type RackedCourtObject = { key: "ball" } diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts new file mode 100644 index 0000000..bec65bc --- /dev/null +++ b/front/editor/TacticContentDomains.ts @@ -0,0 +1,299 @@ +import {Pos, ratioWithinBase} from "../geo/Pos" +import {BallState, Player, PlayerInfo, PlayerTeam} from "../model/tactic/Player" +import {Ball, BALL_ID, BALL_TYPE, CourtObject} from "../model/tactic/CourtObjects" +import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" +import {overlaps} from "../geo/Box" +import {RackedCourtObject, RackedPlayer} from "./RackedItems" +import {refreshAllActions} from "./ActionsDomains" +import {getOrigin} from "./PlayerDomains"; + +export function placePlayerAt( + refBounds: DOMRect, + courtBounds: DOMRect, + element: RackedPlayer, +): Player { + const {x, y} = ratioWithinBase(refBounds, courtBounds) + + return { + type: "player", + id: "player-" + element.key + "-" + element.team, + team: element.team, + role: element.key, + rightRatio: x, + bottomRatio: y, + ballState: BallState.NONE, + path: null, + } +} + +export function placeObjectAt( + refBounds: DOMRect, + courtBounds: DOMRect, + rackedObject: RackedCourtObject, + content: TacticContent, +): TacticContent { + const {x, y} = ratioWithinBase(refBounds, courtBounds) + + let courtObject: CourtObject + + switch (rackedObject.key) { + case BALL_TYPE: + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + if (playerCollidedIdx != -1) { + return dropBallOnComponent(playerCollidedIdx, content) + } + + courtObject = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + } + break + + default: + throw new Error("unknown court object " + rackedObject.key) + } + + return { + ...content, + components: [...content.components, courtObject], + } +} + +export function dropBallOnComponent( + targetedComponentIdx: number, + content: TacticContent, +): TacticContent { + let components = content.components + let component = components[targetedComponentIdx] + + let origin + let isPhantom: boolean + + if (component.type == 'phantom') { + isPhantom = true + origin = getOrigin(component, components) + } else if (component.type == 'player') { + isPhantom = false + origin = component + } else { + return content + } + + components = components.toSpliced(targetedComponentIdx, 1, { + ...component, + ballState: BallState.HOLDS, + }) + if (origin.path != null) { + const phantoms = origin.path!.items + const headingPhantoms = isPhantom ? phantoms.slice(phantoms.indexOf(component.id)) : phantoms + components = components.map(c => headingPhantoms.indexOf(c.id) != -1 ? { + ...c, + hasBall: true + } : c) + } + + const ballObj = components.findIndex((p) => p.type == BALL_TYPE) + + // 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: refreshAllActions(content.actions, components), + components, + } +} + +export function removeBall(content: TacticContent): TacticContent { + const ballObj = content.components.findIndex((o) => o.type == "ball") + + const components = content.components.map((c) => + (c.type == 'player' || c.type == 'phantom') + ? { + ...c, + hasBall: false, + } + : c, + ) + + // if the ball is already not on the court, do nothing + if (ballObj != -1) { + components.splice(ballObj, 1) + } + + return { + ...content, + actions: refreshAllActions(content.actions, components), + components, + } +} + +export function placeBallAt( + refBounds: DOMRect, + courtBounds: DOMRect, + content: TacticContent, +): { + newContent: TacticContent + removed: boolean +} { + if (!overlaps(courtBounds, refBounds)) { + return {newContent: removeBall(content), removed: true} + } + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + if (playerCollidedIdx != -1) { + return { + newContent: dropBallOnComponent(playerCollidedIdx, { + ...content, + components: content.components.map((c) => + c.type == "player" || c.type == 'phantom' + ? { + ...c, + hasBall: false, + } + : c, + ), + }), + removed: false, + } + } + + const ballIdx = content.components.findIndex((o) => o.type == "ball") + + const {x, y} = ratioWithinBase(refBounds, courtBounds) + + const components = content.components.map((c) => + c.type == "player" || c.type == "phantom" + ? { + ...c, + hasBall: false, + } + : c, + ) + + const ball: Ball = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + } + if (ballIdx != -1) { + components.splice(ballIdx, 1, ball) + } else { + components.push(ball) + } + + return { + newContent: { + ...content, + actions: refreshAllActions(content.actions, components), + components, + }, + removed: false, + } +} + +export function moveComponent( + newPos: Pos, + component: TacticComponent, + info: PlayerInfo, + courtBounds: DOMRect, + content: TacticContent, + removed: (content: TacticContent) => TacticContent, +): TacticContent { + const playerBounds = document + .getElementById(info.id)! + .getBoundingClientRect() + + // if the piece is no longer on the court, remove it + if (!overlaps(playerBounds, courtBounds)) { + return removed(content) + } + return updateComponent( + { + ...component, + rightRatio: newPos.x, + bottomRatio: newPos.y, + }, + content, + ) +} + +export function removeComponent( + componentId: ComponentId, + content: TacticContent, +): TacticContent { + const componentIdx = content.components.findIndex( + (c) => c.id == componentId, + ) + + return { + ...content, + components: content.components.toSpliced(componentIdx, 1), + actions: content.actions.filter( + (a) => a.toId !== componentId && a.fromId !== componentId, + ), + } +} + +export function updateComponent( + component: TacticComponent, + content: TacticContent, +): TacticContent { + const componentIdx = content.components.findIndex( + (c) => c.id == component.id, + ) + return { + ...content, + components: content.components.toSpliced(componentIdx, 1, component), + } +} + +export function getComponentCollided( + bounds: DOMRect, + components: TacticComponent[], + ignore?: ComponentId, +): number | -1 { + for (let i = 0; i < components.length; i++) { + const component = components[i] + + if (component.id == ignore) continue + + const playerBounds = document + .getElementById(component.id)! + .getBoundingClientRect() + + if (overlaps(playerBounds, bounds)) { + return i + } + } + return -1 +} + +export 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})) +} diff --git a/front/model/tactic/Action.ts b/front/model/tactic/Action.ts index d238398..f22dfaf 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -1,4 +1,3 @@ - import { Pos } from "../../geo/Pos" import { Segment } from "../../components/arrows/BendableArrow" import { ComponentId } from "./Tactic" @@ -14,7 +13,7 @@ export type Action = { type: ActionKind } & MovementAction export interface MovementAction { fromId: ComponentId - toId?: ComponentId + toId: ComponentId | null moveFrom: Pos segments: Segment[] } diff --git a/front/model/tactic/Ball.ts b/front/model/tactic/CourtObjects.ts similarity index 100% rename from front/model/tactic/Ball.ts rename to front/model/tactic/CourtObjects.ts diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index e558496..7df59ec 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -1,4 +1,4 @@ -import {Component} from "./Tactic"; +import { Component, ComponentId } from "./Tactic" export type PlayerId = string @@ -7,11 +7,15 @@ export enum PlayerTeam { Opponents = "opponents", } -export interface Player { +export interface Player extends PlayerInfo, Component<"player"> { readonly id: PlayerId } -export interface Player extends Component<"player"> { +/** + * All information about a player + */ +export interface PlayerInfo { + readonly id: string /** * the player's team * */ @@ -25,6 +29,43 @@ export interface Player extends Component<"player"> { /** * True if the player has a basketball */ - readonly hasBall: boolean + readonly ballState: BallState + + /** + * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) + */ + readonly bottomRatio: number + + /** + * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) + */ + readonly rightRatio: number +} + +export enum BallState { + NONE, + HOLDS, + SHOOTED } +export interface Player extends Component<"player">, PlayerInfo { + /** + * True if the player has a basketball + */ + readonly ballState: BallState + + readonly path: MovementPath | null +} + +export interface MovementPath { + readonly items: ComponentId[] +} + +/** + * A player phantom is a kind of component that represents the future state of a player + * according to the court's step information + */ +export interface PlayerPhantom extends Component<"phantom"> { + readonly originPlayerId: ComponentId + readonly ballState: BallState +} diff --git a/front/model/tactic/Tactic.ts b/front/model/tactic/Tactic.ts index 6580dbb..c641ac4 100644 --- a/front/model/tactic/Tactic.ts +++ b/front/model/tactic/Tactic.ts @@ -1,6 +1,6 @@ -import {Player} from "./Player" -import {Action} from "./Action" -import {CourtObject} from "./Ball" +import { Player, PlayerPhantom } from "./Player" +import { Action } from "./Action" +import { CourtObject } from "./CourtObjects" export interface Tactic { id: number @@ -13,7 +13,7 @@ export interface TacticContent { actions: Action[] } -export type TacticComponent = Player | CourtObject +export type TacticComponent = Player | CourtObject | PlayerPhantom export type ComponentId = string export interface Component { @@ -26,12 +26,12 @@ export interface Component { */ readonly id: ComponentId /** - * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) + * Percentage of the component's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) */ readonly bottomRatio: number /** - * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) + * Percentage of the component's position to the right (0 means left, 1 means right, 0.5 means middle) */ readonly rightRatio: number } diff --git a/front/style/player.css b/front/style/player.css index 22afe4e..b03123b 100644 --- a/front/style/player.css +++ b/front/style/player.css @@ -2,6 +2,10 @@ pointer-events: none; } +.phantom { + opacity: 50%; +} + .player-content { display: flex; flex-direction: column; diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 387ad74..5163bcc 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -8,20 +8,37 @@ 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 {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" -import {overlaps} from "../geo/Box" -import {ratioWithinBase} from "../geo/Pos" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -44,17 +61,7 @@ export interface EditorProps { 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) { +export default function Editor({id, name, courtType, content}: EditorProps) { const isInGuestMode = id == -1 const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) @@ -80,7 +87,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { ) return SaveStates.Guest } - return fetchAPI(`tactic/${id}/save`, { content }).then((r) => + return fetchAPI(`tactic/${id}/save`, {content}).then((r) => r.ok ? SaveStates.Ok : SaveStates.Err, ) }} @@ -89,7 +96,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { 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( + return fetchAPI(`tactic/${id}/edit/name`, {name}).then( (r) => r.ok, ) }} @@ -99,11 +106,11 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { } function EditorView({ - tactic: { id, name, content: initialContent }, - onContentChange, - onNameChange, - courtType, -}: EditorViewProps) { + tactic: {id, name, content: initialContent}, + onContentChange, + onNameChange, + courtType, + }: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) @@ -124,235 +131,36 @@ function EditorView({ ) const [allies, setAllies] = useState( - getRackPlayers(PlayerTeam.Allies, content.components), + () => getRackPlayers(PlayerTeam.Allies, content.components), ) const [opponents, setOpponents] = useState( - getRackPlayers(PlayerTeam.Opponents, content.components), + () => getRackPlayers(PlayerTeam.Opponents, content.components), ) const [objects, setObjects] = useState( - isBallOnCourt(content) ? [] : [{ key: "ball" }], + () => isBallOnCourt(content) ? [] : [{key: "ball"}], ) - const courtDivContentRef = useRef(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) + const [previewAction, setPreviewAction] = useState(null) - 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 - } + const courtRef = useRef(null) - 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 setActions = (action: SetStateAction) => { + setContent((c) => ({ + ...c, + actions: typeof action == "function" ? action(c.actions) : action, + })) } - 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 setComponents = (action: SetStateAction) => { + setContent((c) => ({ + ...c, + components: + typeof action == "function" ? action(c.components) : action, })) } - 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, - ), - })) + const insertRackedPlayer = (player: Player) => { let setter switch (player.team) { case PlayerTeam.Opponents: @@ -361,8 +169,8 @@ function EditorView({ case PlayerTeam.Allies: setter = setAllies } - if (player.hasBall) { - setObjects([{ key: "ball" }]) + if (player.ballState == BallState.HOLDS) { + setObjects([{key: "ball"}]) } setter((players) => [ ...players, @@ -374,26 +182,168 @@ function EditorView({ ]) } - const removeCourtBall = () => { + const doMoveBall = (newBounds: DOMRect) => { setContent((content) => { - const ballObj = content.components.findIndex( - (o) => o.type == "ball", + const {newContent, removed} = placeBallAt( + newBounds, + courtBounds(), + content, ) - const components = content.components.map((c) => - c.type == "player" - ? ({ - ...c, - hasBall: false, - } as Player) - : c, - ) - components.splice(ballObj, 1) - return { - ...content, - components, + + if (removed) { + setObjects((objects) => [...objects, {key: "ball"}]) } + + return newContent }) - setObjects([{ key: "ball" }]) + } + + 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 ( @@ -403,7 +353,7 @@ function EditorView({ Home
- +
-
+
@@ -425,10 +375,19 @@ function EditorView({ objects={allies} onChange={setAllies} canDetach={(div) => - isBoundsOnCourt(div.getBoundingClientRect()) + overlaps(courtBounds(), div.getBoundingClientRect()) } - onElementDetached={onRackPieceDetach} - render={({ team, key }) => ( + onElementDetached={(r, e) => + setComponents((components) => [ + ...components, + placePlayerAt( + r.getBoundingClientRect(), + courtBounds(), + e, + ), + ]) + } + render={({team, key}) => ( - isBoundsOnCourt(div.getBoundingClientRect()) + overlaps(courtBounds(), div.getBoundingClientRect()) + } + onElementDetached={(r, e) => + setContent((content) => + placeObjectAt( + r.getBoundingClientRect(), + courtBounds(), + e, + content, + ), + ) } - onElementDetached={onRackedObjectDetach} render={renderCourtObject} /> @@ -454,10 +422,19 @@ function EditorView({ objects={opponents} onChange={setOpponents} canDetach={(div) => - isBoundsOnCourt(div.getBoundingClientRect()) + overlaps(courtBounds(), div.getBoundingClientRect()) } - onElementDetached={onRackPieceDetach} - render={({ team, key }) => ( + onElementDetached={(r, e) => + setComponents((components) => [ + ...components, + placePlayerAt( + r.getBoundingClientRect(), + courtBounds(), + e, + ), + ]) + } + render={({team, key}) => ( } - courtRef={courtDivContentRef} - setActions={(actions) => - setContent((content) => ({ - ...content, - actions: actions(content.actions), - })) - } + courtImage={} + 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, - actions: content.actions.toSpliced( - i, - 1, - ), - })) + 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) => ({ @@ -507,25 +541,6 @@ function EditorView({ } /> )} - 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} />
@@ -537,46 +552,30 @@ function EditorView({ function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( - (c) => (c.type == "player" && c.hasBall) || c.type == BALL_TYPE, + (c) => (c.type == "player" && c.ballState == BallState.HOLDS) || c.type == BALL_TYPE, ) != -1 ) } function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { - return + return } throw new Error("unknown racked court object " + courtObject.key) } -function Court({ courtType }: { courtType: string }) { +function Court({courtType}: { courtType: string }) { return (
{courtType == "PLAIN" ? ( - + ) : ( - + )}
) } - -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( f: (args: A) => Promise, delay = 1000, @@ -605,6 +604,7 @@ function useContentState( typeof newState === "function" ? (newState as (state: S) => S)(content) : newState + if (state !== content) { setSavingState(SaveStates.Saving) saveStateCallback(state) @@ -619,12 +619,3 @@ function useContentState( return [content, setContentSynced, savingState] } - -function replaceOrInsert( - array: A[], - it: A, - replace: boolean, -): A[] { - const idx = array.findIndex((i) => i.id == it.id) - return array.toSpliced(idx, 1, ...(replace ? [it] : [])) -} diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index cef1e86..22a4147 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -47,7 +47,7 @@ export function CourtAction({ wavy={action.type == ActionKind.DRIBBLE} //TODO place those magic values in constants endRadius={action.toId ? 26 : 17} - startRadius={0} + startRadius={10} onDeleteRequested={onActionDeleted} style={{ head, diff --git a/sql/database.php b/sql/database.php index 69b53e7..336416c 100644 --- a/sql/database.php +++ b/sql/database.php @@ -1,7 +1,7 @@