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..86e1a49 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -44,18 +44,18 @@ 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/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 46598f2..5a3ac2d 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,21 +8,21 @@ 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 "../../geo/Pos" import "../../style/bendable_arrows.css" @@ -46,12 +47,14 @@ 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 { @@ -96,20 +99,20 @@ function constraintInCircle(center: Pos, reference: Pos, radius: number): Pos { * @constructor */ export default function BendableArrow({ - area, - startPos, + area, + startPos, - segments, - onSegmentsChanges, + segments, + onSegmentsChanges, - forceStraight, - wavy, + forceStraight, + wavy, - style, - startRadius = 0, - endRadius = 0, - onDeleteRequested, -}: BendableArrowProps) { + style, + startRadius = 0, + endRadius = 0, + onDeleteRequested, + }: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) const pathRef = useRef(null) @@ -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)) - }, [computeInternalSegments]) + }, [computeInternalSegments, segments]) const [isSelected, setIsSelected] = useState(false) @@ -159,7 +162,7 @@ export default function BendableArrow({ * @param parentBase */ function computePoints(parentBase: DOMRect) { - return segments.flatMap(({ next, controlPoint }, i) => { + return segments.flatMap(({next, controlPoint}, i) => { const prev = i == 0 ? startPos : segments[i - 1].next const prevRelative = getPosWithinBase(prev, parentBase) @@ -245,6 +248,8 @@ export default function BendableArrow({ * Updates the states based on given parameters, which causes the arrow to re-render. */ const update = useCallback(() => { + + const parentBase = area.current!.getBoundingClientRect() const segment = internalSegments[0] ?? null @@ -263,8 +268,8 @@ export default function BendableArrow({ const endPrevious = forceStraight ? startRelative : lastSegment.controlPoint - ? posWithinBase(lastSegment.controlPoint, parentBase) - : getPosWithinBase(lastSegment.start, parentBase) + ? posWithinBase(lastSegment.controlPoint, parentBase) + : getPosWithinBase(lastSegment.start, parentBase) const tailPos = constraintInCircle( startRelative, @@ -302,15 +307,15 @@ export default function BendableArrow({ const segmentsRelatives = ( forceStraight ? [ - { - start: startPos, - controlPoint: undefined, - end: lastSegment.end, - }, - ] + { + start: startPos, + controlPoint: undefined, + end: lastSegment.end, + }, + ] : internalSegments - ).map(({ start, controlPoint, end }, idx) => { - const svgPosRelativeToBase = { x: left, y: top } + ).map(({start, controlPoint, end}) => { + const svgPosRelativeToBase = {x: left, y: top} const nextRelative = relativeTo( getPosWithinBase(end, parentBase), @@ -323,9 +328,9 @@ export default function BendableArrow({ const controlPointRelative = controlPoint && !forceStraight ? relativeTo( - posWithinBase(controlPoint, parentBase), - svgPosRelativeToBase, - ) + posWithinBase(controlPoint, parentBase), + svgPosRelativeToBase, + ) : middle(startRelative, nextRelative) return { @@ -336,7 +341,7 @@ export default function BendableArrow({ }) const computedSegments = segmentsRelatives - .map(({ start, cp, end: e }, idx) => { + .map(({start, cp, end: e}, idx) => { let end = e if (idx == segmentsRelatives.length - 1) { //if it is the last element @@ -355,14 +360,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(" ") @@ -370,21 +375,14 @@ export default function BendableArrow({ const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) - }, [ - startPos, - internalSegments, - forceStraight, - startRadius, - endRadius, - style, - ]) + }, [area, internalSegments, startPos, forceStraight, startRadius, endRadius, wavy]) // Will update the arrow when the props change useEffect(update, [update]) useEffect(() => { const observer = new MutationObserver(update) - const config = { attributes: true } + const config = {attributes: true} if (typeof startPos == "string") { observer.observe(document.getElementById(startPos)!, config) } @@ -396,7 +394,7 @@ export default function BendableArrow({ } return () => observer.disconnect() - }, [startPos, segments]) + }, [startPos, segments, update]) // Adds a selection handler // Also force an update when the window is resized @@ -423,7 +421,7 @@ export default function BendableArrow({ if (forceStraight) return const parentBase = area.current!.getBoundingClientRect() - const clickAbsolutePos: Pos = { x: e.pageX, y: e.pageY } + const clickAbsolutePos: Pos = {x: e.pageX, y: e.pageY} const clickPosBaseRatio = ratioWithinBase( clickAbsolutePos, parentBase, @@ -450,13 +448,13 @@ export default function BendableArrow({ const smoothCp = beforeSegment ? add( - currentPos, - minus( - currentPos, - beforeSegment.controlPoint ?? - middle(beforeSegmentPos, currentPos), - ), - ) + currentPos, + minus( + currentPos, + beforeSegment.controlPoint ?? + middle(beforeSegmentPos, currentPos), + ), + ) : segmentCp const result = searchOnSegment( @@ -504,7 +502,7 @@ export default function BendableArrow({ return (
+ style={{position: "absolute", top: 0, left: 0}}> {style?.head?.call(style)}
{style?.tail?.call(style)}
@@ -556,8 +554,8 @@ function getPosWithinBase(target: Pos | string, area: DOMRect): Pos { return posWithinBase(target, area) } - const targetPos = document.getElementById(target)!.getBoundingClientRect() - return relativeTo(middlePos(targetPos), area) + const targetPos = document.getElementById(target)?.getBoundingClientRect() + return targetPos ? relativeTo(middlePos(targetPos), area) : NULL_POS } function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos { @@ -565,8 +563,8 @@ function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos { return target } - const targetPos = document.getElementById(target)!.getBoundingClientRect() - return ratioWithinBase(middlePos(targetPos), area) + const targetPos = document.getElementById(target)?.getBoundingClientRect() + return targetPos ? ratioWithinBase(middlePos(targetPos), area) : NULL_POS } interface ControlPointProps { @@ -613,7 +611,7 @@ function wavyBezier( const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t) const velocityLength = norm(velocity) //rotate the velocity by 90 deg - const projection = { x: velocity.y, y: -velocity.x } + const projection = {x: velocity.y, y: -velocity.x} return { x: (projection.x / velocityLength) * amplitude, @@ -635,7 +633,7 @@ function wavyBezier( // 3 : down to middle let phase = 0 - for (let t = step; t <= 1; ) { + for (let t = step; t <= 1;) { const pos = cubicBeziers(start, cp1, cp2, end, t) const amplification = getVerticalAmplification(t) @@ -753,14 +751,14 @@ function searchOnSegment( * @constructor */ function ArrowPoint({ - className, - posRatio, - parentBase, - onMoves, - onPosValidated, - onRemove, - radius = 7, -}: ControlPointProps) { + className, + posRatio, + parentBase, + onMoves, + onPosValidated, + onRemove, + radius = 7, + }: ControlPointProps) { const ref = useRef(null) const pos = posWithinBase(posRatio, parentBase) @@ -776,7 +774,7 @@ function ArrowPoint({ const pointPos = middlePos(ref.current!.getBoundingClientRect()) onMoves(ratioWithinBase(pointPos, parentBase)) }} - position={{ x: pos.x - radius, y: pos.y - radius }}> + position={{x: pos.x - radius, y: pos.y - radius}}>
{}} onActionChanges={() => {}} diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index 43058fe..fbea302 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -1,9 +1,9 @@ -import { ReactNode, RefObject, useRef } from "react" +import React, {ReactNode, RefObject, useCallback, useRef} from "react" import "../../style/player.css" import Draggable from "react-draggable" -import { PlayerPiece } from "./PlayerPiece" -import { BallState, PlayerInfo } from "../../model/tactic/Player" -import { NULL_POS, Pos, ratioWithinBase } from "../../geo/Pos" +import {PlayerPiece} from "./PlayerPiece" +import {BallState, PlayerInfo} from "../../model/tactic/Player" +import {NULL_POS, Pos, ratioWithinBase} from "../../geo/Pos" export interface CourtPlayerProps { playerInfo: PlayerInfo @@ -19,14 +19,14 @@ export interface CourtPlayerProps { * A player that is placed on the court, which can be selected, and moved in the associated bounds * */ export default function CourtPlayer({ - playerInfo, - className, + playerInfo, + className, - onPositionValidated, - onRemove, - courtRef, - availableActions, -}: CourtPlayerProps) { + onPositionValidated, + onRemove, + courtRef, + availableActions, + }: CourtPlayerProps) { const usesBall = playerInfo.ballState != BallState.NONE const x = playerInfo.rightRatio const y = playerInfo.bottomRatio @@ -38,13 +38,15 @@ export default function CourtPlayer({ nodeRef={pieceRef} //The piece is positioned using top/bottom style attributes instead position={NULL_POS} - onStop={() => { + onStop={useCallback(() => { const pieceBounds = pieceRef.current!.getBoundingClientRect() const parentBounds = courtRef.current!.getBoundingClientRect() const pos = ratioWithinBase(pieceBounds, parentBounds) - onPositionValidated(pos) - }}> + + if (pos.x !== x || pos.y != y) + onPositionValidated(pos) + }, [courtRef, onPositionValidated, x, y])}>
{ + 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 index 54b6198..cbb21c2 100644 --- a/front/editor/ActionsDomains.ts +++ b/front/editor/ActionsDomains.ts @@ -1,61 +1,139 @@ -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[], +import {BallState, Player, PlayerPhantom} 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, ): ActionKind { - const origin = components.find((p) => p.id == originId)! - const target = components.find((p) => p.id == targetId) + switch (ballState) { + case BallState.HOLDS_ORIGIN: + case BallState.HOLDS_BY_PASS: + return target + ? ActionKind.SHOOT + : ActionKind.DRIBBLE + case BallState.PASSED_ORIGIN: + case BallState.PASSED: + case BallState.NONE: + return target && target.type != BALL_TYPE + ? ActionKind.SCREEN + : ActionKind.MOVE + } +} + +export function getActionKindBetween(origin: Player | PlayerPhantom, target: TacticComponent | null, state: BallState): ActionKind { + //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.actions.find(a => moves(a.type)) && origin.ballState != BallState.HOLDS_BY_PASS) { + 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 + } - let ballState = BallState.NONE - if (origin.type == "player" || origin.type == "phantom") { - ballState = origin.ballState + // 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 + } } - let hasTarget = target - ? target.type != "phantom" || target.originPlayerId != origin.id - : 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)! - return getActionKind(hasTarget, ballState) + 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 } -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 +function alreadyHasAnAnteriorActionWith(origin: Player | PlayerPhantom, target: Player | PlayerPhantom, 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 (Player | PlayerPhantom) + if (phantom.actions.find(a => typeof a.target === "string" && (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 (Player | PlayerPhantom) + if (phantom.actions.find(a => typeof a.target === "string" && targetOriginPath.indexOf(a.target) > targetIdx)) { + return true; + } } + + return false; } -export function placeArrow( +export function createAction( origin: Player | PlayerPhantom, courtBounds: DOMRect, arrowHead: DOMRect, @@ -64,10 +142,9 @@ export function placeArrow( /** * 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) + function createPhantom(originState: BallState): ComponentId { + const {x, y} = ratioWithinBase(arrowHead, courtBounds) let itemIndex: number let originPlayer: Player @@ -99,20 +176,27 @@ export function placeArrow( content, ) - const ballState = receivesBall - ? BallState.HOLDS - : origin.ballState == BallState.HOLDS - ? BallState.HOLDS - : BallState.NONE + let phantomState: BallState + switch (originState) { + case BallState.HOLDS_ORIGIN: + phantomState = BallState.HOLDS_BY_PASS + break + case BallState.PASSED: + case BallState.PASSED_ORIGIN: + phantomState = BallState.NONE + break + default: + phantomState = originState + } const phantom: PlayerPhantom = { - actions: [], type: "phantom", id: phantomId, rightRatio: x, bottomRatio: y, originPlayerId: originPlayer.id, - ballState, + ballState: phantomState, + actions: [], } content = { ...content, @@ -140,14 +224,14 @@ export function placeArrow( const action: Action = { target: toId, - type: getActionKind(true, origin.ballState), - segments: [{ next: component.id }], + type: getActionKind(component, origin.ballState), + segments: [{next: toId}], } return { newContent: updateComponent( { - ...origin, + ...content.components.find((c) => c.id == origin.id)!, actions: [...origin.actions, action], }, content, @@ -157,12 +241,12 @@ export function placeArrow( } } - const phantomId = createPhantom(origin.ballState == BallState.HOLDS) + const phantomId = createPhantom(origin.ballState) const action: Action = { target: phantomId, - type: getActionKind(false, origin.ballState), - segments: [{ next: phantomId }], + type: getActionKind(null, origin.ballState), + segments: [{next: phantomId}], } return { newContent: updateComponent( @@ -180,7 +264,7 @@ export function removeAllActionsTargeting( componentId: ComponentId, content: TacticContent, ): TacticContent { - let components = [] + const components = [] for (let i = 0; i < content.components.length; i++) { const component = content.components[i] components.push({ @@ -194,3 +278,119 @@ export function removeAllActionsTargeting( 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 (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 === "player" || target.type === "phantom") + content = changePlayerBallState(target, BallState.NONE, 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: Player | PlayerPhantom, 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 Player | PlayerPhantom; + + let targetState: BallState = actionTarget.ballState + let deleteAction = false + + if (isNextInPath(origin, actionTarget, content.components)) { + /// If the target is the next phantom from the origin, its state is propagated. + targetState = (newState === BallState.PASSED || newState === BallState.PASSED_ORIGIN) ? BallState.NONE : 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 type = action.type == ActionKind.SHOOT + ? ActionKind.SHOOT + : getActionKindBetween(origin, actionTarget, newState) + + origin = { + ...origin, + actions: origin.actions.toSpliced(i, 1, { + ...action, + type + }) + } + content = updateComponent(origin, content) + } + + content = spreadNewStateFromOriginStateChange(actionTarget, targetState, content) + } + + return content +} \ No newline at end of file diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts index b7c69df..08f70b8 100644 --- a/front/editor/PlayerDomains.ts +++ b/front/editor/PlayerDomains.ts @@ -1,7 +1,8 @@ -import { Player, PlayerPhantom } from "../model/tactic/Player" -import { TacticComponent, TacticContent } from "../model/tactic/Tactic" -import { removeComponent, updateComponent } from "./TacticContentDomains" -import { removeAllActionsTargeting } from "./ActionsDomains" +import {BallState, Player, 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, @@ -11,6 +12,36 @@ export function getOrigin( return components.find((c) => c.id == pathItem.originPlayerId)! as Player } +export function areInSamePath( + a: Player | PlayerPhantom, + b: Player | PlayerPhantom, +) { + 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: Player | PlayerPhantom, other: Player | PlayerPhantom, 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, @@ -21,6 +52,7 @@ export function removePlayerPath( for (const pathElement of player.path.items) { content = removeComponent(pathElement, content) + content = removeAllActionsTargeting(pathElement, content) } return updateComponent( { @@ -43,7 +75,17 @@ export function removePlayer( } content = removePlayerPath(player, content) - return removeComponent(player.id, 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 (Player | PlayerPhantom) + return spreadNewStateFromOriginStateChange(actionTarget, BallState.NONE, content) + } + + return content } export function truncatePlayerPath( @@ -55,16 +97,14 @@ export function truncatePlayerPath( const path = player.path! - let truncateStartIdx = -1 + const truncateStartIdx = path.items.indexOf(phantom.id) - for (let i = 0; i < path.items.length; i++) { + for (let i = truncateStartIdx; i < path.items.length; i++) { const pathPhantomId = path.items[i] - if (truncateStartIdx != -1 || pathPhantomId == phantom.id) { - if (truncateStartIdx == -1) truncateStartIdx = i - //remove the phantom from the tactic - content = removeComponent(pathPhantomId, content) - } + //remove the phantom from the tactic + content = removeComponent(pathPhantomId, content) + content = removeAllActionsTargeting(pathPhantomId, content) } return updateComponent( @@ -74,10 +114,14 @@ export function truncatePlayerPath( truncateStartIdx == 0 ? null : { - ...path, - items: path.items.toSpliced(truncateStartIdx), - }, + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, }, content, ) } + +export function changePlayerBallState(player: Player | PlayerPhantom, newState: BallState, content: TacticContent): TacticContent { + return spreadNewStateFromOriginStateChange(player, newState, content) +} \ No newline at end of file diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts index d0a24ba..d252a10 100644 --- a/front/editor/TacticContentDomains.ts +++ b/front/editor/TacticContentDomains.ts @@ -1,31 +1,17 @@ -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 { getOrigin } from "./PlayerDomains" +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) + const {x, y} = ratioWithinBase(refBounds, courtBounds) return { type: "player", @@ -46,7 +32,7 @@ export function placeObjectAt( rackedObject: RackedCourtObject, content: TacticContent, ): TacticContent { - const { x, y } = ratioWithinBase(refBounds, courtBounds) + const {x, y} = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject @@ -58,7 +44,7 @@ export function placeObjectAt( BALL_ID, ) if (playerCollidedIdx != -1) { - return dropBallOnComponent(playerCollidedIdx, content) + return dropBallOnComponent(playerCollidedIdx, content, true) } courtObject = { @@ -83,77 +69,31 @@ export function placeObjectAt( export function dropBallOnComponent( targetedComponentIdx: number, content: TacticContent, + setAsOrigin: boolean ): TacticContent { - let components = content.components - let component = components[targetedComponentIdx] + const component = content.components[targetedComponentIdx] - let origin - let isPhantom: boolean + if ((component.type == 'player' || component.type == 'phantom')) { + const newState = setAsOrigin + ? (component.ballState === BallState.PASSED || component.ballState === BallState.PASSED_ORIGIN) ? BallState.PASSED_ORIGIN : BallState.HOLDS_ORIGIN + : BallState.HOLDS_BY_PASS - if (component.type == "phantom") { - isPhantom = true - origin = getOrigin(component, components) - } else if (component.type == "player") { - isPhantom = false - origin = component - } else { - return content + content = changePlayerBallState(component, newState, 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, - components, - } + return removeBall(content) } 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, - ) + const ballObjIdx = content.components.findIndex((o) => o.type == "ball") - // if the ball is already not on the court, do nothing - if (ballObj != -1) { - components.splice(ballObj, 1) + if (ballObjIdx == -1) { + return content } return { ...content, - components, + components: content.components.toSpliced(ballObjIdx, 1), } } @@ -161,47 +101,23 @@ export function placeBallAt( refBounds: DOMRect, courtBounds: DOMRect, content: TacticContent, -): { - newContent: TacticContent - removed: boolean -} { +): TacticContent { if (!overlaps(courtBounds, refBounds)) { - return { newContent: removeBall(content), removed: true } + return removeBall(content) } 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, - } + return dropBallOnComponent(playerCollidedIdx, content, true) } 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 {x, y} = ratioWithinBase(refBounds, courtBounds) const ball: Ball = { type: BALL_TYPE, @@ -210,18 +126,18 @@ export function placeBallAt( bottomRatio: y, actions: [], } + + let components = content.components + if (ballIdx != -1) { - components.splice(ballIdx, 1, ball) + components = components.toSpliced(ballIdx, 1, ball) } else { - components.push(ball) + components = components.concat(ball) } return { - newContent: { - ...content, - components, - }, - removed: false, + ...content, + components, } } @@ -311,5 +227,5 @@ export function getRackPlayers( c.type == "player" && c.team == team && c.role == role, ) == -1, ) - .map((key) => ({ team, key })) + .map((key) => ({team, key})) } diff --git a/front/model/tactic/Action.ts b/front/model/tactic/Action.ts index be5b155..e590696 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -12,7 +12,10 @@ export enum ActionKind { export type Action = { type: ActionKind } & MovementAction export interface MovementAction { - // fromId: ComponentId target: ComponentId | Pos segments: Segment[] } + +export function moves(kind: ActionKind): boolean { + return kind != ActionKind.SHOOT +} \ No newline at end of file diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index 41738d3..ad95b2c 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -44,8 +44,10 @@ export interface PlayerInfo { export enum BallState { NONE, - HOLDS, - SHOOTED, + HOLDS_ORIGIN, + HOLDS_BY_PASS, + PASSED, + PASSED_ORIGIN, } export interface Player extends Component<"player">, PlayerInfo { diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 7a2321b..90f278e 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, @@ -12,23 +14,20 @@ import TitleInput from "../components/TitleInput" import PlainCourt from "../assets/court/full_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react" -import { BallPiece } from "../components/editor/BallPiece" +import {BallPiece} from "../components/editor/BallPiece" -import { Rack } from "../components/Rack" -import { PlayerPiece } from "../components/editor/PlayerPiece" +import {Rack} from "../components/Rack" +import {PlayerPiece} from "../components/editor/PlayerPiece" -import { Tactic, TacticComponent, TacticContent } from "../model/tactic/Tactic" -import { fetchAPI } from "../Fetcher" +import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic" +import {fetchAPI} from "../Fetcher" -import SavingState, { - SaveState, - SaveStates, -} from "../components/editor/SavingState" +import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" -import { BALL_TYPE } from "../model/tactic/CourtObjects" -import { CourtAction } from "./editor/CourtAction" -import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt" -import { overlaps } from "../geo/Box" +import {BALL_TYPE} from "../model/tactic/CourtObjects" +import {CourtAction} from "./editor/CourtAction" +import {ActionPreview, BasketCourt} from "../components/editor/BasketCourt" +import {overlaps} from "../geo/Box" import { dropBallOnComponent, getComponentCollided, @@ -40,27 +39,17 @@ import { removeBall, updateComponent, } from "../editor/TacticContentDomains" -import { - BallState, - Player, - PlayerInfo, - PlayerPhantom, - PlayerTeam, -} from "../model/tactic/Player" -import { RackedCourtObject } from "../editor/RackedItems" +import {BallState, Player, PlayerInfo, PlayerPhantom, PlayerTeam,} from "../model/tactic/Player" +import {RackedCourtObject, RackedPlayer} from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" -import { getActionKind, placeArrow } from "../editor/ActionsDomains" +import {createAction, getActionKind, isActionValid, removeAction} from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" -import { middlePos, ratioWithinBase } from "../geo/Pos" -import { Action, ActionKind } from "../model/tactic/Action" +import {middlePos, Pos, 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 {changePlayerBallState, getOrigin, removePlayer,} from "../editor/PlayerDomains" +import {CourtBall} from "../components/editor/CourtBall" +import {BASE} from "../Constants" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -83,7 +72,7 @@ export interface EditorProps { courtType: "PLAIN" | "HALF" } -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) @@ -109,7 +98,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, ) }} @@ -118,7 +107,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, ) }} @@ -128,11 +117,12 @@ 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({}) @@ -160,7 +150,7 @@ function EditorView({ ) const [objects, setObjects] = useState(() => - isBallOnCourt(content) ? [] : [{ key: "ball" }], + isBallOnCourt(content) ? [] : [{key: "ball"}], ) const [previewAction, setPreviewAction] = useState( @@ -169,8 +159,6 @@ function EditorView({ const courtRef = useRef(null) - const actionsReRenderHooks = [] - const setComponents = (action: SetStateAction) => { setContent((c) => ({ ...c, @@ -179,6 +167,12 @@ function EditorView({ })) } + const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) + + useEffect(() => { + setObjects(isBallOnCourt(content) ? [] : [{key: "ball"}]) + }, [setObjects, content]); + const insertRackedPlayer = (player: Player) => { let setter switch (player.team) { @@ -188,8 +182,8 @@ function EditorView({ case PlayerTeam.Allies: setter = setAllies } - if (player.ballState == BallState.HOLDS) { - setObjects([{ key: "ball" }]) + if (player.ballState == BallState.HOLDS_BY_PASS) { + setObjects([{key: "ball"}]) } setter((players) => [ ...players, @@ -201,47 +195,97 @@ function EditorView({ ]) } - const doMoveBall = (newBounds: DOMRect) => { + const doRemovePlayer = useCallback((component: Player | PlayerPhantom) => { + setContent((c) => removePlayer(component, c)) + if (component.type == "player") insertRackedPlayer(component) + }, [setContent]) + + const doMoveBall = useCallback((newBounds: DOMRect, from?: Player | PlayerPhantom) => { setContent((content) => { - const { newContent, removed } = placeBallAt( + if (from) { + content = changePlayerBallState(from, BallState.NONE, content) + } + + content = placeBallAt( newBounds, courtBounds(), content, ) - if (removed) { - setObjects((objects) => [...objects, { key: "ball" }]) - } - - return newContent + return content }) - } + }, [courtBounds, setContent]) + + const validatePlayerPosition = useCallback((player: Player | PlayerPhantom, info: PlayerInfo, newPos: Pos) => { + setContent((content) => + moveComponent( + newPos, + player, + info, + courtBounds(), + content, - const courtBounds = () => courtRef.current!.getBoundingClientRect() + (content) => { + if (player.type == "player") insertRackedPlayer(player) + return removePlayer(player, content) + }, + ), + ) + }, [courtBounds, setContent]) - const renderPlayer = (component: Player | PlayerPhantom) => { - let info: PlayerInfo + const renderAvailablePlayerActions = useCallback((info: PlayerInfo, player: Player | PlayerPhantom) => { let canPlaceArrows: boolean - const isPhantom = component.type == "phantom" - if (isPhantom) { - const origin = getOrigin(component, content.components) + 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(component.id) == path.items.length - 1 + path.items.indexOf(player.id) == path.items.length - 1 if (canPlaceArrows) { // and if their only action is to shoot the ball - - // list the actions the phantoms does - const phantomActions = component.actions + const phantomActions = player.actions canPlaceArrows = phantomActions.length == 0 || phantomActions.findIndex( (c) => c.type != ActionKind.SHOOT, ) == -1 } + } + + return [ + canPlaceArrows && ( + + ), + (info.ballState === BallState.HOLDS_ORIGIN || info.ballState === BallState.PASSED_ORIGIN) && ( + { + doMoveBall(ballBounds, player) + }}/> + ), + ] + }, [content, doMoveBall, previewAction?.isInvalid, setContent]) + + const renderPlayer = useCallback((component: Player | PlayerPhantom) => { + let info: PlayerInfo + const isPhantom = component.type == "phantom" + if (isPhantom) { + const origin = getOrigin(component, content.components) info = { id: component.id, team: origin.team, @@ -251,14 +295,7 @@ function EditorView({ ballState: component.ballState, } } else { - // a player info = component - // can place arrows only if the - canPlaceArrows = - component.path == null || - component.actions.findIndex( - (p) => p.type != ActionKind.SHOOT, - ) == -1 } return ( @@ -266,165 +303,87 @@ function EditorView({ key={component.id} className={isPhantom ? "phantom" : "player"} playerInfo={info} - 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) - }} + onPositionValidated={(newPos) => validatePlayerPosition(component, info, newPos)} + onRemove={() => doRemovePlayer(component)} courtRef={courtRef} - availableActions={() => [ - canPlaceArrows && ( - { - const arrowHeadPos = middlePos(headPos) - const targetIdx = getComponentCollided( - headPos, - content.components, - ) - - setPreviewAction((action) => ({ - ...action!, - segments: [ - { - next: ratioWithinBase( - arrowHeadPos, - courtBounds(), - ), - }, - ], - type: getActionKind( - targetIdx != -1, - info.ballState, - ), - })) - }} - onHeadPicked={(headPos) => { - ;(document.activeElement as HTMLElement).blur() - - setPreviewAction({ - origin: component.id, - type: getActionKind(false, info.ballState), - target: ratioWithinBase( - headPos, - courtBounds(), - ), - segments: [ - { - next: ratioWithinBase( - middlePos(headPos), - courtBounds(), - ), - }, - ], - }) - }} - onHeadDropped={(headRect) => { - setContent((content) => { - let { createdAction, newContent } = - placeArrow( - component, - courtBounds(), - headRect, - content, - ) - - let originNewBallState = component.ballState - - if ( - createdAction.type == ActionKind.SHOOT - ) { - const targetIdx = - newContent.components.findIndex( - (c) => - c.id == - createdAction.target, - ) - newContent = dropBallOnComponent( - targetIdx, - newContent, - ) - originNewBallState = BallState.SHOOTED - } - - newContent = updateComponent( - { - ...(newContent.components.find( - (c) => c.id == component.id, - )! as Player | PlayerPhantom), - ballState: originNewBallState, - }, - newContent, - ) - return newContent - }) - setPreviewAction(null) - }} - /> - ), - info.ballState != BallState.NONE && ( - - ), - ]} + availableActions={() => renderAvailablePlayerActions(info, component)} /> ) - } + }, [content.components, doRemovePlayer, renderAvailablePlayerActions, validatePlayerPosition]) - const doDeleteAction = ( + const doDeleteAction = useCallback(( action: Action, idx: number, - component: TacticComponent, + origin: TacticComponent, ) => { - setContent((content) => { - content = updateComponent( + 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(idx, 1), + 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"}, + ]) + }} + /> ) - - if (action.target == null) return content - - const target = content.components.find( - (c) => action.target == c.id, - )! - - if (target.type == "phantom") { - let path = null - if (component.type == "player") { - path = component.path - } else if (component.type == "phantom") { - path = getOrigin(component, content.components).path - } - - if ( - path == null || - path.items.find((c) => c == target.id) == null - ) { - return content - } - content = removePlayer(target, content) - } - - return content - }) - } + } + 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 (
@@ -433,162 +392,58 @@ function EditorView({ Home
- +
{ + onValidated={useCallback((new_name) => { onNameChange(new_name).then((success) => { setTitleStyle(success ? {} : ERROR_STYLE) }) - }} + }, [onNameChange])} />
-
+
- - overlaps(courtBounds(), div.getBoundingClientRect()) - } - onElementDetached={(r, e) => - setComponents((components) => [ - ...components, - placePlayerAt( - r.getBoundingClientRect(), - courtBounds(), - e, - ), - ]) - } - render={({ team, key }) => ( - - )} - /> + - overlaps(courtBounds(), div.getBoundingClientRect()) - } - onElementDetached={(r, e) => - setContent((content) => - placeObjectAt( - r.getBoundingClientRect(), - courtBounds(), - e, - content, - ), - ) - } + 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} /> - - overlaps(courtBounds(), div.getBoundingClientRect()) - } - onElementDetached={(r, e) => - setComponents((components) => [ - ...components, - placePlayerAt( - r.getBoundingClientRect(), - courtBounds(), - e, - ), - ]) - } - render={({ team, key }) => ( - - )} - /> +
} + courtImage={} courtRef={courtRef} previewAction={previewAction} - renderComponent={(component) => { - if ( - component.type == "player" || - component.type == "phantom" - ) { - return renderPlayer(component) - } - if (component.type == BALL_TYPE) { - return ( - { - setContent((content) => - removeBall(content), - ) - setObjects((objects) => [ - ...objects, - { key: "ball" }, - ]) - }} - /> - ) - } - throw new Error( - "unknown tactic component " + component, - ) - }} - renderActions={(component) => - component.actions.map((action, i) => ( - { - doDeleteAction(action, i, component) - }} - onActionChanges={(a) => - setContent((content) => - updateComponent( - { - ...component, - actions: - component.actions.toSpliced( - i, - 1, - a, - ), - }, - content, - ), - ) - } - /> - )) - } + renderComponent={renderComponent} + renderActions={renderActions} />
@@ -597,11 +452,175 @@ 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: Player | PlayerPhantom + 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, + ), + 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), + 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 + ) + newContent = updateComponent( + { + ...(newContent.components.find( + (c) => c.id == player.id, + )! as Player | PlayerPhantom), + ballState: BallState.PASSED, + }, + newContent, + ) + } + + + return newContent + }) + setPreviewAction(null) + }} + /> + ) +} + function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( (c) => - (c.type == "player" && c.ballState == BallState.HOLDS) || + (c.type == "player" && (c.ballState === BallState.HOLDS_ORIGIN || c.ballState === BallState.HOLDS_BY_PASS)) || c.type == BALL_TYPE, ) != -1 ) @@ -609,18 +628,18 @@ function isBallOnCourt(content: TacticContent) { 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" ? ( - + ) : ( - + )}
) diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index e4f5fa9..986854d 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -1,9 +1,8 @@ -import { Action, ActionKind } from "../../model/tactic/Action" +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" -import { middlePos, Pos, ratioWithinBase } from "../../geo/Pos" +import {RefObject} from "react" +import {MoveToHead, ScreenHead} from "../../components/actions/ArrowAction" +import {ComponentId} from "../../model/tactic/Tactic" export interface CourtActionProps { origin: ComponentId @@ -11,6 +10,7 @@ export interface CourtActionProps { onActionChanges: (a: Action) => void onActionDeleted: () => void courtRef: RefObject + isInvalid: boolean } export function CourtAction({ @@ -19,16 +19,20 @@ export function CourtAction({ 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 } @@ -56,6 +60,7 @@ export function CourtAction({ style={{ head, dashArray, + color }} /> ) diff --git a/src/Core/Gateway/AccountGateway.php b/src/Core/Gateway/AccountGateway.php index a9c3e18..c848391 100644 --- a/src/Core/Gateway/AccountGateway.php +++ b/src/Core/Gateway/AccountGateway.php @@ -81,6 +81,4 @@ class AccountGateway { return new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profilePicture"])); } - - }