diff --git a/.eslintrc.js b/.eslintrc.js index aa4a8bc..16f7f84 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,6 +13,8 @@ module.exports = { 'plugin:react/jsx-runtime', 'plugin:react-hooks/recommended' ], + rules: { + }, settings: { react: { version: 'detect' diff --git a/front/components/TitleInput.tsx b/front/components/TitleInput.tsx index 477e3d0..25f4697 100644 --- a/front/components/TitleInput.tsx +++ b/front/components/TitleInput.tsx @@ -4,13 +4,13 @@ import "../style/title_input.css" export interface TitleInputOptions { style: CSSProperties default_value: string - on_validated: (a: string) => void + onValidated: (a: string) => void } export default function TitleInput({ style, default_value, - on_validated, + onValidated, }: TitleInputOptions) { const [value, setValue] = useState(default_value) const ref = useRef(null) @@ -23,7 +23,7 @@ export default function TitleInput({ type="text" value={value} onChange={(event) => setValue(event.target.value)} - onBlur={(_) => on_validated(value)} + onBlur={(_) => onValidated(value)} onKeyUp={(event) => { if (event.key == "Enter") ref.current?.blur() }} diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index 00a661c..8fbae5f 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -44,18 +44,16 @@ export default function ArrowAction({ ) } -export function ScreenHead() { +export function ScreenHead({ color }: { color: string }) { return ( -
+
) } -export function MoveToHead() { +export function MoveToHead({ color }: { color: string }) { return ( - + ) } diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx index a26785c..87779df 100644 --- a/front/components/actions/BallAction.tsx +++ b/front/components/actions/BallAction.tsx @@ -1,15 +1,19 @@ import { BallPiece } from "../editor/BallPiece" import Draggable from "react-draggable" 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) { 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 b8f0f19..7a4760b 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -1,5 +1,6 @@ import { CSSProperties, + MouseEvent as ReactMouseEvent, ReactElement, RefObject, useCallback, @@ -7,29 +8,29 @@ import { useLayoutEffect, useRef, useState, - MouseEvent as ReactMouseEvent, } from "react" import { add, angle, - middle, distance, + middle, middlePos, minus, mul, + norm, + NULL_POS, Pos, posWithinBase, ratioWithinBase, relativeTo, - norm, -} from "./Pos" +} from "../../geo/Pos" import "../../style/bendable_arrows.css" import Draggable from "react-draggable" export interface BendableArrowProps { area: RefObject - startPos: Pos + startPos: Pos | string segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void forceStraight: boolean @@ -46,16 +47,18 @@ export interface BendableArrowProps { export interface ArrowStyle { width?: number dashArray?: string + color: string head?: () => ReactElement tail?: () => ReactElement } const ArrowStyleDefaults: ArrowStyle = { width: 3, + color: "black", } export interface Segment { - next: Pos + next: Pos | string controlPoint?: Pos } @@ -134,7 +137,7 @@ export default function BendableArrow({ } }) }, - [segments, startPos], + [startPos], ) // Cache the segments so that when the user is changing the segments (it moves an ArrowPoint), @@ -147,7 +150,7 @@ export default function BendableArrow({ // If the (original) segments changes, overwrite the current ones. useLayoutEffect(() => { setInternalSegments(computeInternalSegments(segments)) - }, [startPos, segments, computeInternalSegments]) + }, [computeInternalSegments, segments]) const [isSelected, setIsSelected] = useState(false) @@ -162,8 +165,8 @@ export default function BendableArrow({ return segments.flatMap(({ next, controlPoint }, i) => { const prev = i == 0 ? startPos : segments[i - 1].next - const prevRelative = posWithinBase(prev, parentBase) - const nextRelative = posWithinBase(next, parentBase) + const prevRelative = getPosWithinBase(prev, parentBase) + const nextRelative = getPosWithinBase(next, parentBase) const cpPos = controlPoint || @@ -204,7 +207,7 @@ export default function BendableArrow({ { const currentSegment = segments[i] @@ -252,19 +255,19 @@ export default function BendableArrow({ const lastSegment = internalSegments[internalSegments.length - 1] - const startRelative = posWithinBase(startPos, parentBase) - const endRelative = posWithinBase(lastSegment.end, parentBase) + const startRelative = getPosWithinBase(startPos, parentBase) + const endRelative = getPosWithinBase(lastSegment.end, parentBase) const startNext = segment.controlPoint && !forceStraight ? posWithinBase(segment.controlPoint, parentBase) - : posWithinBase(segment.end, parentBase) + : getPosWithinBase(segment.end, parentBase) const endPrevious = forceStraight ? startRelative : lastSegment.controlPoint ? posWithinBase(lastSegment.controlPoint, parentBase) - : posWithinBase(lastSegment.start, parentBase) + : getPosWithinBase(lastSegment.start, parentBase) const tailPos = constraintInCircle( startRelative, @@ -309,15 +312,15 @@ export default function BendableArrow({ }, ] : internalSegments - ).map(({ start, controlPoint, end }, idx) => { + ).map(({ start, controlPoint, end }) => { const svgPosRelativeToBase = { x: left, y: top } const nextRelative = relativeTo( - posWithinBase(end, parentBase), + getPosWithinBase(end, parentBase), svgPosRelativeToBase, ) const startRelative = relativeTo( - posWithinBase(start, parentBase), + getPosWithinBase(start, parentBase), svgPosRelativeToBase, ) const controlPointRelative = @@ -355,14 +358,14 @@ export default function BendableArrow({ ? add(start, previousSegmentCpAndCurrentPosVector) : cp - if (wavy) { - return wavyBezier(start, smoothCp, cp, end, 10, 10) - } - if (forceStraight) { return `L${end.x} ${end.y}` } + if (wavy) { + return wavyBezier(start, smoothCp, cp, end, 10, 10) + } + return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}` }) .join(" ") @@ -371,17 +374,34 @@ export default function BendableArrow({ pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) }, [ - startPos, + area, internalSegments, + startPos, forceStraight, startRadius, endRadius, - style, + wavy, ]) // Will update the arrow when the props change useEffect(update, [update]) + useEffect(() => { + const observer = new MutationObserver(update) + const config = { attributes: true } + if (typeof startPos == "string") { + observer.observe(document.getElementById(startPos)!, config) + } + + for (const segment of segments) { + if (typeof segment.next == "string") { + observer.observe(document.getElementById(segment.next)!, config) + } + } + + return () => observer.disconnect() + }, [startPos, segments, update]) + // Adds a selection handler // Also force an update when the window is resized useEffect(() => { @@ -418,10 +438,16 @@ export default function BendableArrow({ for (let i = 0; i < segments.length; i++) { const segment = segments[i] const beforeSegment = i != 0 ? segments[i - 1] : undefined - const beforeSegmentPos = i > 1 ? segments[i - 2].next : startPos + const beforeSegmentPos = getRatioWithinBase( + i > 1 ? segments[i - 2].next : startPos, + parentBase, + ) - const currentPos = beforeSegment ? beforeSegment.next : startPos - const nextPos = segment.next + const currentPos = getRatioWithinBase( + beforeSegment ? beforeSegment.next : startPos, + parentBase, + ) + const nextPos = getRatioWithinBase(segment.next, parentBase) const segmentCp = segment.controlPoint ? segment.controlPoint : middle(currentPos, nextPos) @@ -493,7 +519,7 @@ export default function BendableArrow({ + return } diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 525e232..69bad37 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,149 +1,42 @@ -import { CourtBall } from "./CourtBall" - import { ReactElement, + ReactNode, RefObject, - useCallback, + useEffect, useLayoutEffect, useState, } from "react" -import CourtPlayer from "./CourtPlayer" +import { Action } from "../../model/tactic/Action" -import { Player } from "../../model/tactic/Player" -import { Action, ActionKind } from "../../model/tactic/Action" -import ArrowAction from "../actions/ArrowAction" -import { middlePos, ratioWithinBase } from "../arrows/Pos" -import BallAction from "../actions/BallAction" -import { CourtObject } from "../../model/tactic/Ball" -import { contains } from "../arrows/Box" import { CourtAction } from "../../views/editor/CourtAction" +import { ComponentId, TacticComponent } from "../../model/tactic/Tactic" export interface BasketCourtProps { - players: Player[] - actions: Action[] - objects: CourtObject[] - - renderAction: (a: Action, key: number) => ReactElement - setActions: (f: (a: Action[]) => Action[]) => void + components: TacticComponent[] + previewAction: ActionPreview | null - onPlayerRemove: (p: Player) => void - onPlayerChange: (p: Player) => void - - onBallRemove: () => void - onBallMoved: (ball: DOMRect) => void + renderComponent: (comp: TacticComponent) => ReactNode + renderActions: (comp: TacticComponent) => ReactNode[] courtImage: ReactElement courtRef: RefObject } +export interface ActionPreview extends Action { + origin: ComponentId + isInvalid: boolean +} + export function BasketCourt({ - players, - actions, - objects, - renderAction, - setActions, - onPlayerRemove, - onPlayerChange, + components, + previewAction, - onBallMoved, - onBallRemove, + renderComponent, + renderActions, 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 player of players) { - if (player.id == origin.id) { - continue - } - - const playerBounds = document - .getElementById(player.id)! - .getBoundingClientRect() - - if ( - !( - playerBounds.top > arrowHead.bottom || - playerBounds.right < arrowHead.left || - playerBounds.bottom < arrowHead.top || - playerBounds.left > arrowHead.right - ) - ) { - const targetPos = document - .getElementById(player.id)! - .getBoundingClientRect() - - const end = ratioWithinBase(middlePos(targetPos), courtBounds) - - const action: Action = { - fromPlayerId: originRef.id, - toPlayerId: player.id, - type: origin.hasBall ? ActionKind.SHOOT : ActionKind.SCREEN, - moveFrom: start, - segments: [{ next: end }], - } - setActions((actions) => [...actions, action]) - return - } - } - - const action: Action = { - fromPlayerId: 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((player: Player) => { - const newPos = ratioWithinBase( - middlePos( - document.getElementById(player.id)!.getBoundingClientRect(), - ), - courtRef.current!.getBoundingClientRect(), - ) - setActions((actions) => - actions.map((a) => { - if (a.fromPlayerId == player.id) { - return { ...a, moveFrom: newPos } - } - - if (a.toPlayerId == player.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]) - return (
{courtImage} - {players.map((player) => ( - updateActionsRelatedTo(player)} - onChange={onPlayerChange} - onRemove={() => onPlayerRemove(player)} - courtRef={courtRef} - availableActions={(pieceRef) => [ - { - const baseBounds = - courtRef.current!.getBoundingClientRect() - - const arrowHeadPos = middlePos(headPos) - - const target = players.find( - (p) => - p != player && - contains( - document - .getElementById(p.id)! - .getBoundingClientRect(), - arrowHeadPos, - ), - ) - - setPreviewAction((action) => ({ - ...action!, - segments: [ - { - next: ratioWithinBase( - arrowHeadPos, - baseBounds, - ), - }, - ], - type: player.hasBall - ? target - ? ActionKind.SHOOT - : ActionKind.DRIBBLE - : target - ? ActionKind.SCREEN - : ActionKind.MOVE, - })) - }} - onHeadPicked={(headPos) => { - ;(document.activeElement as HTMLElement).blur() - const baseBounds = - courtRef.current!.getBoundingClientRect() - - setPreviewAction({ - type: player.hasBall - ? ActionKind.DRIBBLE - : ActionKind.MOVE, - fromPlayerId: player.id, - toPlayerId: 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()) - } - /> - ), - ]} - /> - ))} - - {internActions.map((action, idx) => renderAction(action, idx))} - - {objects.map((object) => { - if (object.type == "ball") { - return ( - - ) - } - throw new Error("unknown court object" + object.type) - })} + {components.map(renderComponent)} + {components.flatMap(renderActions)} {previewAction && ( {}} onActionChanges={() => {}} /> diff --git a/front/components/editor/CourtBall.tsx b/front/components/editor/CourtBall.tsx index b1fa1d0..b167126 100644 --- a/front/components/editor/CourtBall.tsx +++ b/front/components/editor/CourtBall.tsx @@ -1,15 +1,16 @@ 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 { - onMoved: (rect: DOMRect) => void + onPosValidated: (rect: DOMRect) => void onRemove: () => void ball: Ball } -export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) { +export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) { const pieceRef = useRef(null) const x = ball.rightRatio @@ -17,7 +18,10 @@ export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) { return ( onMoved(pieceRef.current!.getBoundingClientRect())} + onStop={() => + onPosValidated(pieceRef.current!.getBoundingClientRect()) + } + position={NULL_POS} nodeRef={pieceRef}>
void - onChange: (p: Player) => void +export interface CourtPlayerProps { + playerInfo: PlayerInfo + className?: string + + onPositionValidated: (newPos: Pos) => void onRemove: () => void courtRef: RefObject availableActions: (ro: HTMLElement) => ReactNode[] @@ -18,44 +19,37 @@ 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, + + 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 ( { + onStop={useCallback(() => { const pieceBounds = pieceRef.current!.getBoundingClientRect() const parentBounds = courtRef.current!.getBoundingClientRect() - const { x, y } = ratioWithinBase(pieceBounds, parentBounds) + const pos = ratioWithinBase(pieceBounds, parentBounds) - onChange({ - id: player.id, - rightRatio: x, - bottomRatio: y, - team: player.team, - role: player.role, - hasBall: player.hasBall, - }) - }}> + if (pos.x !== x || pos.y != y) onPositionValidated(pos) + }, [courtRef, onPositionValidated, x, y])}>
{ - if (e.key == "Delete") onRemove() - }}> + onKeyUp={useCallback( + (e: React.KeyboardEvent) => { + if (e.key == "Delete") onRemove() + }, + [onRemove], + )}>
{availableActions(pieceRef.current!)}
diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts new file mode 100644 index 0000000..ad0c8bd --- /dev/null +++ b/front/editor/ActionsDomains.ts @@ -0,0 +1,510 @@ +import { + BallState, + Player, + PlayerPhantom, + PlayerLike, +} from "../model/tactic/Player" +import { ratioWithinBase } from "../geo/Pos" +import { + ComponentId, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" +import { overlaps } from "../geo/Box" +import { Action, ActionKind, moves } from "../model/tactic/Action" +import { removeBall, updateComponent } from "./TacticContentDomains" +import { + areInSamePath, + changePlayerBallState, + getOrigin, + isNextInPath, + removePlayer, +} from "./PlayerDomains" +import { BALL_TYPE } from "../model/tactic/CourtObjects" + +export function getActionKind( + target: TacticComponent | null, + ballState: BallState, +): { kind: ActionKind; nextState: BallState } { + switch (ballState) { + case BallState.HOLDS_ORIGIN: + return target + ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN } + : { kind: ActionKind.DRIBBLE, nextState: ballState } + case BallState.HOLDS_BY_PASS: + return target + ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED } + : { kind: ActionKind.DRIBBLE, nextState: ballState } + case BallState.PASSED_ORIGIN: + case BallState.PASSED: + case BallState.NONE: + return { + kind: + target && target.type != BALL_TYPE + ? ActionKind.SCREEN + : ActionKind.MOVE, + nextState: ballState, + } + } +} + +export function getActionKindBetween( + origin: PlayerLike, + target: TacticComponent | null, + state: BallState, +): { kind: ActionKind; nextState: BallState } { + //remove the target if the target is a phantom that is within the origin's path + if ( + target != null && + target.type == "phantom" && + areInSamePath(origin, target) + ) { + target = null + } + + return getActionKind(target, state) +} + +export function isActionValid( + origin: TacticComponent, + target: TacticComponent | null, + components: TacticComponent[], +): boolean { + /// action is valid if the origin is neither a phantom nor a player + if (origin.type != "phantom" && origin.type != "player") { + return true + } + + // action is invalid if the origin already moves (unless the origin holds a ball which will lead to a ball pass) + if ( + origin.ballState != BallState.HOLDS_BY_PASS && + origin.ballState != BallState.HOLDS_ORIGIN && + origin.actions.find((a) => moves(a.type)) + ) { + return false + } + //Action is valid if the target is null + if (target == null) { + return true + } + + // action is invalid if it targets its own origin + if (origin.id === target.id) { + return false + } + + // action is invalid if the target already moves and is not indirectly bound with origin + if ( + target.actions.find((a) => moves(a.type)) && + (hasBoundWith(target, origin, components) || + hasBoundWith(origin, target, components)) + ) { + return false + } + + // Action is invalid if there is already an action between origin and target. + if ( + origin.actions.find((a) => a.target === target?.id) || + target?.actions.find((a) => a.target === origin.id) + ) { + return false + } + + // Action is invalid if there is already an anterior action within the target's path + if (target.type == "phantom" || target.type == "player") { + // cant have an action with current path + if (areInSamePath(origin, target)) return false + + if (alreadyHasAnAnteriorActionWith(origin, target, components)) { + return false + } + } + + return true +} + +function hasBoundWith( + origin: TacticComponent, + target: TacticComponent, + components: TacticComponent[], +): boolean { + const toVisit = [origin.id] + const visited: string[] = [] + + let itemId: string | undefined + while ((itemId = toVisit.pop())) { + if (visited.indexOf(itemId) !== -1) continue + + visited.push(itemId) + + const item = components.find((c) => c.id === itemId)! + + const itemBounds = item.actions.flatMap((a) => + typeof a.target == "string" ? [a.target] : [], + ) + if (itemBounds.indexOf(target.id) !== -1) { + return true + } + + toVisit.push(...itemBounds) + } + + return false +} + +function alreadyHasAnAnteriorActionWith( + origin: PlayerLike, + target: PlayerLike, + components: TacticComponent[], +): boolean { + const targetOrigin = + target.type === "phantom" ? getOrigin(target, components) : target + const targetOriginPath = [ + targetOrigin.id, + ...(targetOrigin.path?.items ?? []), + ] + + const originOrigin = + origin.type === "phantom" ? getOrigin(origin, components) : origin + const originOriginPath = [ + originOrigin.id, + ...(originOrigin.path?.items ?? []), + ] + + const targetIdx = targetOriginPath.indexOf(target.id) + for (let i = targetIdx; i < targetOriginPath.length; i++) { + const phantom = components.find( + (c) => c.id === targetOriginPath[i], + )! as PlayerLike + if ( + phantom.actions.find( + (a) => + typeof a.target === "string" && + moves(a.type) && + originOriginPath.indexOf(a.target) !== -1, + ) + ) { + return true + } + } + + const originIdx = originOriginPath.indexOf(origin.id) + for (let i = 0; i <= originIdx; i++) { + const phantom = components.find( + (c) => c.id === originOriginPath[i], + )! as PlayerLike + if ( + phantom.actions.find( + (a) => + typeof a.target === "string" && + moves(a.type) && + targetOriginPath.indexOf(a.target) > targetIdx, + ) + ) { + return true + } + } + + return false +} + +export function createAction( + origin: PlayerLike, + courtBounds: DOMRect, + arrowHead: DOMRect, + content: TacticContent, +): { createdAction: Action; newContent: TacticContent } { + /** + * Creates a new phantom component. + * Be aware that this function will reassign the `content` parameter. + */ + function createPhantom(forceHasBall: 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, + ) + + let phantomState: BallState + if (forceHasBall) phantomState = BallState.HOLDS_ORIGIN + else + switch (origin.ballState) { + case BallState.HOLDS_ORIGIN: + phantomState = BallState.HOLDS_BY_PASS + break + case BallState.PASSED: + case BallState.PASSED_ORIGIN: + phantomState = BallState.NONE + break + default: + phantomState = origin.ballState + } + + const phantom: PlayerPhantom = { + type: "phantom", + id: phantomId, + rightRatio: x, + bottomRatio: y, + originPlayerId: originPlayer.id, + ballState: phantomState, + actions: [], + } + 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)) { + let toId = component.id + + if (component.type == "ball") { + toId = createPhantom(true) + content = removeBall(content) + } + + const action: Action = { + target: toId, + type: getActionKind(component, origin.ballState).kind, + segments: [{ next: toId }], + } + + return { + newContent: updateComponent( + { + ...content.components.find((c) => c.id == origin.id)!, + actions: [...origin.actions, action], + }, + content, + ), + createdAction: action, + } + } + } + + const phantomId = createPhantom(false) + + const action: Action = { + target: phantomId, + type: getActionKind(null, origin.ballState).kind, + segments: [{ next: phantomId }], + } + return { + newContent: updateComponent( + { + ...content.components.find((c) => c.id == origin.id)!, + actions: [...origin.actions, action], + }, + content, + ), + createdAction: action, + } +} + +export function removeAllActionsTargeting( + componentId: ComponentId, + content: TacticContent, +): TacticContent { + const components = [] + for (let i = 0; i < content.components.length; i++) { + const component = content.components[i] + components.push({ + ...component, + actions: component.actions.filter((a) => a.target != componentId), + }) + } + + return { + ...content, + components, + } +} + +export function removeAction( + origin: TacticComponent, + action: Action, + actionIdx: number, + content: TacticContent, +): TacticContent { + origin = { + ...origin, + actions: origin.actions.toSpliced(actionIdx, 1), + } + content = updateComponent(origin, content) + + if (action.target == null) return content + + const target = content.components.find((c) => action.target == c.id)! + + // if the removed action is a shoot, set the origin as holding the ball + if ( + action.type == ActionKind.SHOOT && + (origin.type === "player" || origin.type === "phantom") + ) { + if (target.type === "player" || target.type === "phantom") + content = changePlayerBallState(target, BallState.NONE, content) + + if (origin.ballState === BallState.PASSED) { + content = changePlayerBallState( + origin, + BallState.HOLDS_BY_PASS, + content, + ) + } else if (origin.ballState === BallState.PASSED_ORIGIN) { + content = changePlayerBallState( + origin, + BallState.HOLDS_ORIGIN, + content, + ) + } + } + + if (target.type === "phantom") { + let path = null + if (origin.type === "player") { + path = origin.path + } else if (origin.type === "phantom") { + path = getOrigin(origin, content.components).path + } + + if (path != null && path.items.find((c) => c === target.id)) { + content = removePlayer(target, content) + } + } + + return content +} + +/** + * Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with + * the given newState. + * @param origin + * @param newState + * @param content + */ +export function spreadNewStateFromOriginStateChange( + origin: PlayerLike, + newState: BallState, + content: TacticContent, +): TacticContent { + if (origin.ballState === newState) { + return content + } + + origin = { + ...origin, + ballState: newState, + } + + content = updateComponent(origin, content) + + for (let i = 0; i < origin.actions.length; i++) { + const action = origin.actions[i] + if (typeof action.target !== "string") { + continue + } + + const actionTarget = content.components.find( + (c) => action.target === c.id, + )! as PlayerLike + + let targetState: BallState = actionTarget.ballState + let deleteAction = false + + if (isNextInPath(origin, actionTarget, content.components)) { + switch (newState) { + case BallState.PASSED: + case BallState.PASSED_ORIGIN: + targetState = BallState.NONE + break + case BallState.HOLDS_ORIGIN: + targetState = BallState.HOLDS_BY_PASS + break + default: + targetState = newState + } + } else if ( + newState === BallState.NONE && + action.type === ActionKind.SHOOT + ) { + /// if the new state removes the ball from the player, remove all actions that were meant to shoot the ball + deleteAction = true + targetState = BallState.NONE // Then remove the ball for the target as well + } else if ( + (newState === BallState.HOLDS_BY_PASS || + newState === BallState.HOLDS_ORIGIN) && + action.type === ActionKind.SCREEN + ) { + targetState = BallState.HOLDS_BY_PASS + } + + if (deleteAction) { + content = removeAction(origin, action, i, content) + origin = content.components.find((c) => c.id === origin.id)! as + | Player + | PlayerPhantom + i-- // step back + } else { + // do not change the action type if it is a shoot action + const { kind, nextState } = getActionKindBetween( + origin, + actionTarget, + newState, + ) + + origin = { + ...origin, + ballState: nextState, + actions: origin.actions.toSpliced(i, 1, { + ...action, + type: kind, + }), + } + content = updateComponent(origin, content) + } + + content = spreadNewStateFromOriginStateChange( + actionTarget, + targetState, + content, + ) + } + + return content +} diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts new file mode 100644 index 0000000..b20ca9d --- /dev/null +++ b/front/editor/PlayerDomains.ts @@ -0,0 +1,149 @@ +import { + BallState, + Player, + PlayerLike, + PlayerPhantom, +} from "../model/tactic/Player" +import { TacticComponent, TacticContent } from "../model/tactic/Tactic" +import { removeComponent, updateComponent } from "./TacticContentDomains" +import { + removeAllActionsTargeting, + spreadNewStateFromOriginStateChange, +} from "./ActionsDomains" +import { ActionKind } from "../model/tactic/Action" + +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 areInSamePath(a: PlayerLike, b: PlayerLike) { + if (a.type === "phantom" && b.type === "phantom") { + return a.originPlayerId === b.originPlayerId + } + if (a.type === "phantom") { + return b.id === a.originPlayerId + } + if (b.type === "phantom") { + return a.id === b.originPlayerId + } + return false +} + +/** + * @param origin + * @param other + * @param components + * @returns true if the `other` player is the phantom next-to the origin's path. + */ +export function isNextInPath( + origin: PlayerLike, + other: PlayerLike, + components: TacticComponent[], +): boolean { + if (origin.type === "player") { + return origin.path?.items[0] === other.id + } + const originPath = getOrigin(origin, components).path! + return ( + originPath.items!.indexOf(origin.id) === + originPath.items!.indexOf(other.id) - 1 + ) +} + +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) + content = removeAllActionsTargeting(pathElement, content) + } + return updateComponent( + { + ...player, + path: null, + }, + content, + ) +} + +export function removePlayer( + player: PlayerLike, + content: TacticContent, +): TacticContent { + content = removeAllActionsTargeting(player.id, content) + + if (player.type == "phantom") { + const origin = getOrigin(player, content.components) + return truncatePlayerPath(origin, player, content) + } + + content = removePlayerPath(player, content) + content = removeComponent(player.id, content) + + for (const action of player.actions) { + if (action.type !== ActionKind.SHOOT) { + continue + } + const actionTarget = content.components.find( + (c) => c.id === action.target, + )! as PlayerLike + return spreadNewStateFromOriginStateChange( + actionTarget, + BallState.NONE, + content, + ) + } + + return content +} + +export function truncatePlayerPath( + player: Player, + phantom: PlayerPhantom, + content: TacticContent, +): TacticContent { + if (player.path == null) return content + + const path = player.path! + + const truncateStartIdx = path.items.indexOf(phantom.id) + + for (let i = truncateStartIdx; i < path.items.length; i++) { + const pathPhantomId = path.items[i] + + //remove the phantom from the tactic + content = removeComponent(pathPhantomId, content) + content = removeAllActionsTargeting(pathPhantomId, content) + } + + return updateComponent( + { + ...player, + path: + truncateStartIdx == 0 + ? null + : { + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, + }, + content, + ) +} + +export function changePlayerBallState( + player: PlayerLike, + newState: BallState, + content: TacticContent, +): TacticContent { + return spreadNewStateFromOriginStateChange(player, newState, 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..5839bee --- /dev/null +++ b/front/editor/TacticContentDomains.ts @@ -0,0 +1,243 @@ +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 { changePlayerBallState } 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, + actions: [], + } +} + +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, true) + } + + courtObject = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + actions: [], + } + break + + default: + throw new Error("unknown court object " + rackedObject.key) + } + + return { + ...content, + components: [...content.components, courtObject], + } +} + +export function dropBallOnComponent( + targetedComponentIdx: number, + content: TacticContent, + setAsOrigin: boolean, +): TacticContent { + const component = content.components[targetedComponentIdx] + + if (component.type === "player" || component.type === "phantom") { + const newState = + setAsOrigin || + component.ballState === BallState.PASSED_ORIGIN || + component.ballState === BallState.HOLDS_ORIGIN + ? BallState.HOLDS_ORIGIN + : BallState.HOLDS_BY_PASS + + content = changePlayerBallState(component, newState, content) + } + + return removeBall(content) +} + +export function removeBall(content: TacticContent): TacticContent { + const ballObjIdx = content.components.findIndex((o) => o.type == "ball") + + if (ballObjIdx == -1) { + return content + } + + return { + ...content, + components: content.components.toSpliced(ballObjIdx, 1), + } +} + +export function placeBallAt( + refBounds: DOMRect, + courtBounds: DOMRect, + content: TacticContent, +): TacticContent { + if (!overlaps(courtBounds, refBounds)) { + return removeBall(content) + } + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + + if (playerCollidedIdx != -1) { + return dropBallOnComponent(playerCollidedIdx, content, true) + } + + const ballIdx = content.components.findIndex((o) => o.type == "ball") + + const { x, y } = ratioWithinBase(refBounds, courtBounds) + + const ball: Ball = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + actions: [], + } + + let components = content.components + + if (ballIdx != -1) { + components = components.toSpliced(ballIdx, 1, ball) + } else { + components = components.concat(ball) + } + + return { + ...content, + components, + } +} + +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 { + return { + ...content, + components: content.components.filter((c) => c.id !== componentId), + } +} + +export function updateComponent( + component: TacticComponent, + content: TacticContent, +): TacticContent { + return { + ...content, + components: content.components.map((c) => + c.id === component.id ? component : c, + ), + } +} + +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/components/arrows/Box.ts b/front/geo/Box.ts similarity index 81% rename from front/components/arrows/Box.ts rename to front/geo/Box.ts index 36a674f..81c18d2 100644 --- a/front/components/arrows/Box.ts +++ b/front/geo/Box.ts @@ -28,6 +28,14 @@ export function surrounds(pos: Pos, width: number, height: number): Box { } } +export function overlaps(a: Box, b: Box): boolean { + if (a.x + a.width < b.x || b.x + b.width < a.x) { + return false + } + + return !(a.y + a.height < b.y || b.y + b.height < a.y) +} + export function contains(box: Box, pos: Pos): boolean { return ( pos.x >= box.x && diff --git a/front/components/arrows/Pos.ts b/front/geo/Pos.ts similarity index 100% rename from front/components/arrows/Pos.ts rename to front/geo/Pos.ts diff --git a/front/model/tactic/Action.ts b/front/model/tactic/Action.ts index 0b5aee5..c97cdd4 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -1,6 +1,6 @@ -import { Pos } from "../../components/arrows/Pos" +import { Pos } from "../../geo/Pos" import { Segment } from "../../components/arrows/BendableArrow" -import { PlayerId } from "./Player" +import { ComponentId } from "./Tactic" export enum ActionKind { SCREEN = "SCREEN", @@ -12,8 +12,10 @@ export enum ActionKind { export type Action = { type: ActionKind } & MovementAction export interface MovementAction { - fromPlayerId: PlayerId - toPlayerId?: PlayerId - moveFrom: Pos + target: ComponentId | Pos segments: Segment[] } + +export function moves(kind: ActionKind): boolean { + return kind != ActionKind.SHOOT +} diff --git a/front/model/tactic/Ball.ts b/front/model/tactic/Ball.ts deleted file mode 100644 index 28e4830..0000000 --- a/front/model/tactic/Ball.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type CourtObject = { type: "ball" } & Ball - -export interface Ball { - /** - * The ball is a "ball" court object - */ - readonly type: "ball" - - /** - * 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 -} diff --git a/front/model/tactic/CourtObjects.ts b/front/model/tactic/CourtObjects.ts new file mode 100644 index 0000000..96cde26 --- /dev/null +++ b/front/model/tactic/CourtObjects.ts @@ -0,0 +1,9 @@ +import { Component } from "./Tactic" + +export const BALL_ID = "ball" +export const BALL_TYPE = "ball" + +//place here all different kinds of objects +export type CourtObject = Ball + +export type Ball = Component diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index f94d6bf..a257103 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -1,13 +1,23 @@ +import { Component, ComponentId } from "./Tactic" + export type PlayerId = string +export type PlayerLike = Player | PlayerPhantom + export enum PlayerTeam { Allies = "allies", Opponents = "opponents", } -export interface Player { +export interface Player extends PlayerInfo, Component<"player"> { readonly id: PlayerId +} +/** + * All information about a player + */ +export interface PlayerInfo { + readonly id: string /** * the player's team * */ @@ -18,6 +28,11 @@ export interface Player { * */ readonly role: string + /** + * True if the player has a basketball + */ + readonly ballState: BallState + /** * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) */ @@ -27,9 +42,34 @@ export interface Player { * 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_ORIGIN, + HOLDS_BY_PASS, + PASSED, + PASSED_ORIGIN, +} + +export interface Player extends Component<"player">, PlayerInfo { /** * True if the player has a basketball */ - readonly hasBall: boolean + 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 2eab85b..dfe1190 100644 --- a/front/model/tactic/Tactic.ts +++ b/front/model/tactic/Tactic.ts @@ -1,6 +1,6 @@ -import { Player } from "./Player" -import { CourtObject } from "./Ball" +import { Player, PlayerPhantom } from "./Player" import { Action } from "./Action" +import { CourtObject } from "./CourtObjects" export interface Tactic { id: number @@ -9,7 +9,31 @@ export interface Tactic { } export interface TacticContent { - players: Player[] - objects: CourtObject[] - actions: Action[] + components: TacticComponent[] + //actions: Action[] +} + +export type TacticComponent = Player | CourtObject | PlayerPhantom +export type ComponentId = string + +export interface Component { + /** + * The component's type + */ + readonly type: T + /** + * The component's identifier + */ + readonly id: ComponentId + /** + * 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 component's position to the right (0 means left, 1 means right, 0.5 means middle) + */ + readonly rightRatio: number + + readonly actions: Action[] } diff --git a/front/style/actions/arrow_action.css b/front/style/actions/arrow_action.css index 3aa88d7..77bfa4c 100644 --- a/front/style/actions/arrow_action.css +++ b/front/style/actions/arrow_action.css @@ -5,6 +5,7 @@ .arrow-action-icon { user-select: none; -moz-user-select: none; + -webkit-user-drag: none; max-width: 17px; max-height: 17px; } 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 cbb2da5..d65d8a6 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,8 +1,10 @@ import { CSSProperties, Dispatch, + RefObject, SetStateAction, useCallback, + useEffect, useMemo, useRef, useState, @@ -16,22 +18,55 @@ 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 { Tactic, TacticComponent, 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 { BALL_TYPE } from "../model/tactic/CourtObjects" import { CourtAction } from "./editor/CourtAction" -import { BasketCourt } from "../components/editor/BasketCourt" -import { ratioWithinBase } from "../components/arrows/Pos" +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, + PlayerLike, + PlayerTeam, +} from "../model/tactic/Player" +import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" +import CourtPlayer from "../components/editor/CourtPlayer" +import { + createAction, + getActionKind, + isActionValid, + removeAction, +} from "../editor/ActionsDomains" +import ArrowAction from "../components/actions/ArrowAction" +import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" import { Action, ActionKind } from "../model/tactic/Action" +import BallAction from "../components/actions/BallAction" +import { + changePlayerBallState, + getOrigin, + removePlayer, +} from "../editor/PlayerDomains" +import { CourtBall } from "../components/editor/CourtBall" import { BASE } from "../Constants" const ERROR_STYLE: CSSProperties = { @@ -55,16 +90,6 @@ 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) { const isInGuestMode = id == -1 @@ -134,228 +159,41 @@ function EditorView({ ), ) - const [allies, setAllies] = useState( - getRackPlayers(PlayerTeam.Allies, content.players), + const [allies, setAllies] = useState(() => + getRackPlayers(PlayerTeam.Allies, content.components), ) - const [opponents, setOpponents] = useState( - getRackPlayers(PlayerTeam.Opponents, content.players), + const [opponents, setOpponents] = useState(() => + getRackPlayers(PlayerTeam.Opponents, content.components), ) - const [objects, setObjects] = useState( + 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 [previewAction, setPreviewAction] = useState( + null, + ) - 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 - } + const courtRef = useRef(null) - 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 setComponents = (action: SetStateAction) => { + setContent((c) => ({ + ...c, + components: + typeof action == "function" ? action(c.components) : action, + })) } - 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 courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) - const players = content.players.map((player) => ({ - ...player, - hasBall: false, - })) + useEffect(() => { + setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) + }, [setObjects, content]) - 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, - ), - })) + const insertRackedPlayer = (player: Player) => { let setter switch (player.team) { case PlayerTeam.Opponents: @@ -364,7 +202,7 @@ function EditorView({ case PlayerTeam.Allies: setter = setAllies } - if (player.hasBall) { + if (player.ballState == BallState.HOLDS_BY_PASS) { setObjects([{ key: "ball" }]) } setter((players) => [ @@ -377,20 +215,222 @@ function EditorView({ ]) } - 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), + const doRemovePlayer = useCallback( + (component: PlayerLike) => { + setContent((c) => removePlayer(component, c)) + if (component.type == "player") insertRackedPlayer(component) + }, + [setContent], + ) + + const doMoveBall = useCallback( + (newBounds: DOMRect, from?: PlayerLike) => { + setContent((content) => { + if (from) { + content = changePlayerBallState( + from, + BallState.NONE, + content, + ) + } + + content = placeBallAt(newBounds, courtBounds(), content) + + return content + }) + }, + [courtBounds, setContent], + ) + + const validatePlayerPosition = useCallback( + (player: PlayerLike, info: PlayerInfo, newPos: Pos) => { + setContent((content) => + moveComponent( + newPos, + player, + info, + courtBounds(), + content, + + (content) => { + if (player.type == "player") insertRackedPlayer(player) + return removePlayer(player, content) + }, + ), + ) + }, + [courtBounds, setContent], + ) + + const renderAvailablePlayerActions = useCallback( + (info: PlayerInfo, player: PlayerLike) => { + let canPlaceArrows: boolean + + if (player.type == "player") { + canPlaceArrows = + player.path == null || + player.actions.findIndex( + (p) => p.type != ActionKind.SHOOT, + ) == -1 + } else { + const origin = getOrigin(player, content.components) + const path = origin.path! + // phantoms can only place other arrows if they are the head of the path + canPlaceArrows = + path.items.indexOf(player.id) == path.items.length - 1 + if (canPlaceArrows) { + // and if their only action is to shoot the ball + const phantomActions = player.actions + canPlaceArrows = + phantomActions.length == 0 || + phantomActions.findIndex( + (c) => c.type != ActionKind.SHOOT, + ) == -1 + } } - }) - setObjects([{ key: "ball" }]) - } + + return [ + canPlaceArrows && ( + + ), + (info.ballState === BallState.HOLDS_ORIGIN || + info.ballState === BallState.PASSED_ORIGIN) && ( + { + doMoveBall(ballBounds, player) + }} + /> + ), + ] + }, + [content, doMoveBall, previewAction?.isInvalid, setContent], + ) + + const renderPlayer = useCallback( + (component: PlayerLike) => { + let info: PlayerInfo + const isPhantom = component.type == "phantom" + if (isPhantom) { + const origin = getOrigin(component, content.components) + info = { + id: component.id, + team: origin.team, + role: origin.role, + bottomRatio: component.bottomRatio, + rightRatio: component.rightRatio, + ballState: component.ballState, + } + } else { + info = component + } + + return ( + + validatePlayerPosition(component, info, newPos) + } + onRemove={() => doRemovePlayer(component)} + courtRef={courtRef} + availableActions={() => + renderAvailablePlayerActions(info, component) + } + /> + ) + }, + [ + content.components, + doRemovePlayer, + renderAvailablePlayerActions, + validatePlayerPosition, + ], + ) + + const doDeleteAction = useCallback( + (action: Action, idx: number, origin: TacticComponent) => { + setContent((content) => removeAction(origin, action, idx, content)) + }, + [setContent], + ) + + const doUpdateAction = useCallback( + (component: TacticComponent, action: Action, actionIndex: number) => { + setContent((content) => + updateComponent( + { + ...component, + actions: component.actions.toSpliced( + actionIndex, + 1, + action, + ), + }, + content, + ), + ) + }, + [setContent], + ) + + const renderComponent = useCallback( + (component: TacticComponent) => { + 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) + }, + [renderPlayer, doMoveBall, setContent], + ) + + const renderActions = useCallback( + (component: TacticComponent) => + component.actions.map((action, i) => { + return ( + { + doDeleteAction(action, i, component) + }} + onActionChanges={(action) => + doUpdateAction(component, action, i) + } + /> + ) + }), + [doDeleteAction, doUpdateAction], + ) return (
@@ -405,125 +445,72 @@ function EditorView({ { - onNameChange(new_name).then((success) => { - setTitleStyle(success ? {} : ERROR_STYLE) - }) - }} + onValidated={useCallback( + (new_name) => { + onNameChange(new_name).then((success) => { + setTitleStyle(success ? {} : ERROR_STYLE) + }) + }, + [onNameChange], + )} />
- - isBoundsOnCourt(div.getBoundingClientRect()) - } - onElementDetached={onPieceDetach} - render={({ team, key }) => ( - - )} + setObjects={setAllies} + setComponents={setComponents} + courtRef={courtRef} /> - isBoundsOnCourt(div.getBoundingClientRect()) - } - onElementDetached={onObjectDetach} + canDetach={useCallback( + (div) => + overlaps( + courtBounds(), + div.getBoundingClientRect(), + ), + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedCourtObject) => + setContent((content) => + placeObjectAt( + r.getBoundingClientRect(), + courtBounds(), + e, + content, + ), + ), + [courtBounds, setContent], + )} render={renderCourtObject} /> - - isBoundsOnCourt(div.getBoundingClientRect()) - } - onElementDetached={onPieceDetach} - render={({ team, key }) => ( - - )} + setObjects={setOpponents} + setComponents={setComponents} + courtRef={courtRef} />
} - 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} + courtRef={courtRef} + previewAction={previewAction} + renderComponent={renderComponent} + renderActions={renderActions} />
@@ -532,11 +519,188 @@ function EditorView({ ) } +interface PlayerRackProps { + id: string + objects: RackedPlayer[] + setObjects: (state: RackedPlayer[]) => void + setComponents: ( + f: (components: TacticComponent[]) => TacticComponent[], + ) => void + courtRef: RefObject +} + +function PlayerRack({ + id, + objects, + setObjects, + courtRef, + setComponents, +}: PlayerRackProps) { + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) + + return ( + overlaps(courtBounds(), div.getBoundingClientRect()), + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedPlayer) => + setComponents((components) => [ + ...components, + placePlayerAt( + r.getBoundingClientRect(), + courtBounds(), + e, + ), + ]), + [courtBounds, setComponents], + )} + render={useCallback( + ({ team, key }: { team: PlayerTeam; key: string }) => ( + + ), + [], + )} + /> + ) +} + +interface CourtPlayerArrowActionProps { + playerInfo: PlayerInfo + player: PlayerLike + isInvalid: boolean + + content: TacticContent + setContent: (state: SetStateAction) => void + setPreviewAction: (state: SetStateAction) => void + courtRef: RefObject +} + +function CourtPlayerArrowAction({ + playerInfo, + player, + isInvalid, + + content, + setContent, + setPreviewAction, + courtRef, +}: CourtPlayerArrowActionProps) { + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) + + return ( + { + const arrowHeadPos = middlePos(headPos) + const targetIdx = getComponentCollided( + headPos, + content.components, + ) + const target = content.components[targetIdx] + + setPreviewAction((action) => ({ + ...action!, + segments: [ + { + next: ratioWithinBase(arrowHeadPos, courtBounds()), + }, + ], + type: getActionKind(target, playerInfo.ballState).kind, + isInvalid: + !overlaps(headPos, courtBounds()) || + !isActionValid(player, target, content.components), + })) + }} + onHeadPicked={(headPos) => { + ;(document.activeElement as HTMLElement).blur() + + setPreviewAction({ + origin: playerInfo.id, + type: getActionKind(null, playerInfo.ballState).kind, + target: ratioWithinBase(headPos, courtBounds()), + segments: [ + { + next: ratioWithinBase( + middlePos(headPos), + courtBounds(), + ), + }, + ], + isInvalid: false, + }) + }} + onHeadDropped={(headRect) => { + if (isInvalid) { + setPreviewAction(null) + return + } + + setContent((content) => { + let { createdAction, newContent } = createAction( + player, + courtBounds(), + headRect, + content, + ) + + if (createdAction.type == ActionKind.SHOOT) { + const targetIdx = newContent.components.findIndex( + (c) => c.id == createdAction.target, + ) + newContent = dropBallOnComponent( + targetIdx, + newContent, + false, + ) + const ballState = + player.ballState === BallState.HOLDS_ORIGIN + ? BallState.PASSED_ORIGIN + : BallState.PASSED + newContent = updateComponent( + { + ...(newContent.components.find( + (c) => c.id == player.id, + )! as PlayerLike), + ballState, + }, + newContent, + ) + } + + return newContent + }) + setPreviewAction(null) + }} + /> + ) +} + function isBallOnCourt(content: TacticContent) { - if (content.players.findIndex((p) => p.hasBall) != -1) { - return true - } - return content.objects.findIndex((o) => o.type == "ball") != -1 + return ( + content.components.findIndex( + (c) => + ((c.type === "player" || c.type === "phantom") && + (c.ballState === BallState.HOLDS_ORIGIN || + c.ballState === BallState.PASSED_ORIGIN)) || + c.type === BALL_TYPE, + ) != -1 + ) } function renderCourtObject(courtObject: RackedCourtObject) { @@ -558,16 +722,6 @@ function Court({ courtType }: { courtType: string }) { ) } -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, @@ -596,6 +750,7 @@ function useContentState( typeof newState === "function" ? (newState as (state: S) => S)(content) : newState + if (state !== content) { setSavingState(SaveStates.Saving) saveStateCallback(state) @@ -610,15 +765,3 @@ function useContentState( 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] : [])) -} diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index de33224..c26c0d9 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -2,29 +2,36 @@ import { Action, ActionKind } from "../../model/tactic/Action" import BendableArrow from "../../components/arrows/BendableArrow" import { RefObject } from "react" import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" +import { ComponentId } from "../../model/tactic/Tactic" export interface CourtActionProps { + origin: ComponentId action: Action onActionChanges: (a: Action) => void onActionDeleted: () => void courtRef: RefObject + isInvalid: boolean } export function CourtAction({ + origin, action, onActionChanges, onActionDeleted, courtRef, + isInvalid, }: CourtActionProps) { + const color = isInvalid ? "red" : "black" + let head switch (action.type) { case ActionKind.DRIBBLE: case ActionKind.MOVE: case ActionKind.SHOOT: - head = () => + head = () => break case ActionKind.SCREEN: - head = () => + head = () => break } @@ -39,19 +46,20 @@ export function CourtAction({ { onActionChanges({ ...action, segments: edges }) }} wavy={action.type == ActionKind.DRIBBLE} //TODO place those magic values in constants - endRadius={action.toPlayerId ? 26 : 17} - startRadius={0} + endRadius={action.target ? 26 : 17} + startRadius={10} onDeleteRequested={onActionDeleted} style={{ head, dashArray, + color, }} /> ) diff --git a/front/views/template/Header.tsx b/front/views/template/Header.tsx index 5c8cbcd..8555133 100644 --- a/front/views/template/Header.tsx +++ b/front/views/template/Header.tsx @@ -17,7 +17,7 @@ export function Header({ username }: { username: string }) { location.pathname = BASE + "/" }}> IQ - Ball + CourtObjects
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 @@ -1, //-1 id means that the editor will not support saves "name" => TacticModel::TACTIC_DEFAULT_NAME, - "content" => '{"players": [], "objects": [], "actions": []}', + "content" => '{"components": []}', "courtType" => $courtType->name(), ]); } diff --git a/src/App/Views/home.twig b/src/App/Views/home.twig index 0fc426a..2438ca1 100644 --- a/src/App/Views/home.twig +++ b/src/App/Views/home.twig @@ -52,7 +52,7 @@
-

IQ Ball

+

IQ CourtObjects