From 8d444c38b48d16eff592bb6791f4ebc18b5b6a90 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Thu, 4 Jan 2024 19:20:37 +0100 Subject: [PATCH 1/7] use one array of TacticComponent --- front/components/arrows/BendableArrow.tsx | 2 +- front/components/editor/BallPiece.tsx | 3 +- front/components/editor/BasketCourt.tsx | 256 +++++++++---------- front/components/editor/CourtBall.tsx | 15 +- front/components/editor/CourtPlayer.tsx | 5 +- front/{components/arrows => geo}/Box.ts | 8 + front/{components/arrows => geo}/Pos.ts | 0 front/model/tactic/Action.ts | 9 +- front/model/tactic/Ball.ts | 22 +- front/model/tactic/Player.ts | 15 +- front/model/tactic/Tactic.ts | 32 ++- front/views/Editor.tsx | 284 +++++++++++----------- front/views/editor/CourtAction.tsx | 2 +- front/views/template/Header.tsx | 2 +- sql/setup-tables.sql | 2 +- src/App/Controller/EditorController.php | 2 +- src/App/Views/home.twig | 2 +- 17 files changed, 353 insertions(+), 308 deletions(-) rename front/{components/arrows => geo}/Box.ts (81%) rename front/{components/arrows => geo}/Pos.ts (100%) diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index b8f0f19..e2219bb 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -22,7 +22,7 @@ import { ratioWithinBase, relativeTo, norm, -} from "./Pos" +} from "../../geo/Pos" import "../../style/bendable_arrows.css" import Draggable from "react-draggable" diff --git a/front/components/editor/BallPiece.tsx b/front/components/editor/BallPiece.tsx index 2741249..d72ad75 100644 --- a/front/components/editor/BallPiece.tsx +++ b/front/components/editor/BallPiece.tsx @@ -1,7 +1,8 @@ import "../../style/ball.css" import BallSvg from "../../assets/icon/ball.svg?react" +import {BALL_ID} from "../../model/tactic/Ball"; export function BallPiece() { - return + return } diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 525e232..7aba76c 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -12,16 +12,17 @@ import CourtPlayer from "./CourtPlayer" import { Player } from "../../model/tactic/Player" import { Action, ActionKind } from "../../model/tactic/Action" import ArrowAction from "../actions/ArrowAction" -import { middlePos, ratioWithinBase } from "../arrows/Pos" +import { middlePos, ratioWithinBase } from "../../geo/Pos" import BallAction from "../actions/BallAction" -import { CourtObject } from "../../model/tactic/Ball" -import { contains } from "../arrows/Box" +import {BALL_ID} from "../../model/tactic/Ball" +import { contains, overlaps } from "../../geo/Box" + import { CourtAction } from "../../views/editor/CourtAction" +import { TacticComponent } from "../../model/tactic/Tactic" export interface BasketCourtProps { - players: Player[] + components: TacticComponent[] actions: Action[] - objects: CourtObject[] renderAction: (a: Action, key: number) => ReactElement setActions: (f: (a: Action[]) => Action[]) => void @@ -37,9 +38,8 @@ export interface BasketCourtProps { } export function BasketCourt({ - players, + components, actions, - objects, renderAction, setActions, onPlayerRemove, @@ -59,33 +59,31 @@ export function BasketCourt({ courtBounds, ) - for (const player of players) { - if (player.id == origin.id) { + for (const component of components) { + if (component.id == origin.id) { continue } const playerBounds = document - .getElementById(player.id)! + .getElementById(component.id)! .getBoundingClientRect() - if ( - !( - playerBounds.top > arrowHead.bottom || - playerBounds.right < arrowHead.left || - playerBounds.bottom < arrowHead.top || - playerBounds.left > arrowHead.right - ) - ) { + if (overlaps(playerBounds, arrowHead)) { const targetPos = document - .getElementById(player.id)! + .getElementById(component.id)! .getBoundingClientRect() const end = ratioWithinBase(middlePos(targetPos), courtBounds) const action: Action = { - fromPlayerId: originRef.id, - toPlayerId: player.id, - type: origin.hasBall ? ActionKind.SHOOT : ActionKind.SCREEN, + fromId: originRef.id, + toId: component.id, + type: + component.type == "player" + ? origin.hasBall + ? ActionKind.SHOOT + : ActionKind.SCREEN + : ActionKind.MOVE, moveFrom: start, segments: [{ next: end }], } @@ -95,7 +93,7 @@ export function BasketCourt({ } const action: Action = { - fromPlayerId: originRef.id, + fromId: originRef.id, type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE, moveFrom: ratioWithinBase( middlePos(originRef.getBoundingClientRect()), @@ -110,20 +108,20 @@ export function BasketCourt({ const [previewAction, setPreviewAction] = useState(null) - const updateActionsRelatedTo = useCallback((player: Player) => { + const updateActionsRelatedTo = useCallback((comp: TacticComponent) => { const newPos = ratioWithinBase( middlePos( - document.getElementById(player.id)!.getBoundingClientRect(), + document.getElementById(comp.id)!.getBoundingClientRect(), ), courtRef.current!.getBoundingClientRect(), ) setActions((actions) => actions.map((a) => { - if (a.fromPlayerId == player.id) { + if (a.fromId == comp.id) { return { ...a, moveFrom: newPos } } - if (a.toPlayerId == player.id) { + if (a.toId == comp.id) { const segments = a.segments.toSpliced( a.segments.length - 1, 1, @@ -151,113 +149,125 @@ export function BasketCourt({ style={{ position: "relative" }}> {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()) - } - /> - ), - ]} - /> - ))} + {components.map((component) => { + if (component.type == "player") { + const player = component + return ( + updateActionsRelatedTo(player)} + onChange={onPlayerChange} + onRemove={() => onPlayerRemove(player)} + courtRef={courtRef} + availableActions={(pieceRef) => [ + { + const baseBounds = + courtRef.current!.getBoundingClientRect() - {internActions.map((action, idx) => renderAction(action, idx))} + const arrowHeadPos = middlePos(headPos) + + const target = components.find( + (c) => + c.id != player.id && + contains( + document + .getElementById(c.id)! + .getBoundingClientRect(), + arrowHeadPos, + ), + ) - {objects.map((object) => { - if (object.type == "ball") { + const type = + target?.type == "player" + ? player.hasBall + ? target + ? ActionKind.SHOOT + : ActionKind.DRIBBLE + : target + ? ActionKind.SCREEN + : ActionKind.MOVE + : ActionKind.MOVE + + setPreviewAction((action) => ({ + ...action!, + segments: [ + { + next: ratioWithinBase( + arrowHeadPos, + baseBounds, + ), + }, + ], + type, + })) + }} + onHeadPicked={(headPos) => { + ;( + document.activeElement as HTMLElement + ).blur() + const baseBounds = + courtRef.current!.getBoundingClientRect() + + setPreviewAction({ + type: player.hasBall + ? ActionKind.DRIBBLE + : ActionKind.MOVE, + fromId: player.id, + toId: undefined, + moveFrom: ratioWithinBase( + middlePos( + pieceRef.getBoundingClientRect(), + ), + baseBounds, + ), + segments: [ + { + next: ratioWithinBase( + middlePos(headPos), + baseBounds, + ), + }, + ], + }) + }} + onHeadDropped={(headRect) => { + placeArrow(player, headRect) + setPreviewAction(null) + }} + />, + player.hasBall && ( + + onBallMoved( + ref.getBoundingClientRect(), + ) + } + /> + ), + ]} + /> + ) + } + if (component.type == BALL_ID) { return ( updateActionsRelatedTo(component)} + ball={component} onRemove={onBallRemove} key="ball" /> ) } - throw new Error("unknown court object" + object.type) + throw new Error("unknown tactic component " + component) })} + {internActions.map((action, idx) => renderAction(action, idx))} + {previewAction && ( void + onPosValidated: (rect: DOMRect) => void + onMoves: () => void onRemove: () => void ball: Ball } -export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) { +export function CourtBall({ + onPosValidated, + ball, + onRemove, + onMoves, +}: CourtBallProps) { const pieceRef = useRef(null) const x = ball.rightRatio @@ -17,7 +23,10 @@ export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) { return ( onMoved(pieceRef.current!.getBoundingClientRect())} + onStop={() => + onPosValidated(pieceRef.current!.getBoundingClientRect()) + } + onDrag={onMoves} nodeRef={pieceRef}>
= 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..d238398 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -1,6 +1,7 @@ -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 +13,8 @@ export enum ActionKind { export type Action = { type: ActionKind } & MovementAction export interface MovementAction { - fromPlayerId: PlayerId - toPlayerId?: PlayerId + fromId: ComponentId + toId?: ComponentId moveFrom: Pos segments: Segment[] } diff --git a/front/model/tactic/Ball.ts b/front/model/tactic/Ball.ts index 28e4830..96cde26 100644 --- a/front/model/tactic/Ball.ts +++ b/front/model/tactic/Ball.ts @@ -1,17 +1,9 @@ -export type CourtObject = { type: "ball" } & Ball +import { Component } from "./Tactic" -export interface Ball { - /** - * The ball is a "ball" court object - */ - readonly type: "ball" +export const BALL_ID = "ball" +export const BALL_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 -} +//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..e558496 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -1,3 +1,5 @@ +import {Component} from "./Tactic"; + export type PlayerId = string export enum PlayerTeam { @@ -7,7 +9,9 @@ export enum PlayerTeam { export interface Player { readonly id: PlayerId +} +export interface Player extends Component<"player"> { /** * the player's team * */ @@ -18,18 +22,9 @@ export interface Player { * */ readonly role: string - /** - * 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 - /** * True if the player has a basketball */ readonly hasBall: boolean } + diff --git a/front/model/tactic/Tactic.ts b/front/model/tactic/Tactic.ts index 2eab85b..6580dbb 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 { Action } from "./Action" +import {Player} from "./Player" +import {Action} from "./Action" +import {CourtObject} from "./Ball" export interface Tactic { id: number @@ -9,7 +9,29 @@ export interface Tactic { } export interface TacticContent { - players: Player[] - objects: CourtObject[] + components: TacticComponent[] actions: Action[] } + +export type TacticComponent = Player | CourtObject +export type ComponentId = string + +export interface Component { + /** + * The component's type + */ + readonly type: T + /** + * The component's identifier + */ + readonly id: ComponentId + /** + * 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/views/Editor.tsx b/front/views/Editor.tsx index cbb2da5..387ad74 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,38 +1,27 @@ -import { - CSSProperties, - Dispatch, - SetStateAction, - useCallback, - useMemo, - useRef, - useState, -} from "react" +import {CSSProperties, Dispatch, SetStateAction, useCallback, useMemo, useRef, useState,} from "react" import "../style/editor.css" import TitleInput from "../components/TitleInput" import PlainCourt from "../assets/court/full_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react" -import { BallPiece } from "../components/editor/BallPiece" +import {BallPiece} from "../components/editor/BallPiece" -import { Rack } from "../components/Rack" -import { PlayerPiece } from "../components/editor/PlayerPiece" -import { Player } from "../model/tactic/Player" +import {Rack} from "../components/Rack" +import {PlayerPiece} from "../components/editor/PlayerPiece" +import {Player, PlayerTeam} from "../model/tactic/Player" -import { Tactic, TacticContent } from "../model/tactic/Tactic" -import { fetchAPI } from "../Fetcher" -import { PlayerTeam } from "../model/tactic/Player" +import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic" +import {fetchAPI} from "../Fetcher" -import SavingState, { - SaveState, - SaveStates, -} from "../components/editor/SavingState" +import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" -import { CourtObject } from "../model/tactic/Ball" -import { CourtAction } from "./editor/CourtAction" -import { BasketCourt } from "../components/editor/BasketCourt" -import { ratioWithinBase } from "../components/arrows/Pos" -import { Action, ActionKind } from "../model/tactic/Action" -import { BASE } from "../Constants" +import {BALL_ID, BALL_TYPE, CourtObject, Ball} from "../model/tactic/Ball" +import {CourtAction} from "./editor/CourtAction" +import {BasketCourt} from "../components/editor/BasketCourt" +import {Action, ActionKind} from "../model/tactic/Action" +import {BASE} from "../Constants" +import {overlaps} from "../geo/Box" +import {ratioWithinBase} from "../geo/Pos" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -135,10 +124,10 @@ function EditorView({ ) const [allies, setAllies] = useState( - getRackPlayers(PlayerTeam.Allies, content.players), + getRackPlayers(PlayerTeam.Allies, content.components), ) const [opponents, setOpponents] = useState( - getRackPlayers(PlayerTeam.Opponents, content.players), + getRackPlayers(PlayerTeam.Opponents, content.components), ) const [objects, setObjects] = useState( @@ -151,15 +140,10 @@ function EditorView({ 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 - ) + return overlaps(courtBounds, bounds) } - const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { + const onRackPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() @@ -168,23 +152,24 @@ function EditorView({ setContent((content) => { return { ...content, - players: [ - ...content.players, + components: [ + ...content.components, { + type: "player", id: "player-" + element.key + "-" + element.team, team: element.team, role: element.key, rightRatio: x, bottomRatio: y, hasBall: false, - }, + } as Player, ], actions: content.actions, } }) } - const onObjectDetach = ( + const onRackedObjectDetach = ( ref: HTMLDivElement, rackedObject: RackedCourtObject, ) => { @@ -196,27 +181,22 @@ function EditorView({ let courtObject: CourtObject switch (rackedObject.key) { - case "ball": - const ballObj = content.objects.findIndex( - (o) => o.type == "ball", + case BALL_TYPE: + const ballObj = content.components.findIndex( + (o) => o.type == BALL_TYPE, ) - const playerCollidedIdx = getPlayerCollided( + const playerCollidedIdx = getComponentCollided( refBounds, - content.players, + content.components.toSpliced(ballObj, 1), ) if (playerCollidedIdx != -1) { - onBallDropOnPlayer(playerCollidedIdx) - setContent((content) => { - return { - ...content, - objects: content.objects.toSpliced(ballObj, 1), - } - }) + onBallDropOnComponent(playerCollidedIdx) return } courtObject = { - type: "ball", + type: BALL_TYPE, + id: BALL_ID, rightRatio: x, bottomRatio: y, } @@ -229,38 +209,34 @@ function EditorView({ setContent((content) => { return { ...content, - objects: [...content.objects, courtObject], + components: [...content.components, courtObject], } }) } - const getPlayerCollided = ( + const getComponentCollided = ( bounds: DOMRect, - players: Player[], + components: TacticComponent[], ): number | -1 => { - for (let i = 0; i < players.length; i++) { - const player = players[i] + for (let i = 0; i < components.length; i++) { + const component = components[i] const playerBounds = document - .getElementById(player.id)! + .getElementById(component.id)! .getBoundingClientRect() - const doesOverlap = !( - bounds.top > playerBounds.bottom || - bounds.right < playerBounds.left || - bounds.bottom < playerBounds.top || - bounds.left > playerBounds.right - ) - if (doesOverlap) { + if (overlaps(playerBounds, bounds)) { return i } } return -1 } - function updateActions(actions: Action[], players: Player[]) { + function updateActions(actions: Action[], components: TacticComponent[]) { return actions.map((action) => { - const originHasBall = players.find( - (p) => p.id == action.fromPlayerId, - )!.hasBall + const originHasBall = ( + components.find( + (p) => p.type == "player" && p.id == action.fromId, + )! as Player + ).hasBall let type = action.type @@ -280,80 +256,101 @@ function EditorView({ }) } - const onBallDropOnPlayer = (playerCollidedIdx: number) => { + const onBallDropOnComponent = (collidedComponentIdx: 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, - }) + const ballObj = content.components.findIndex( + (p) => p.type == BALL_TYPE, + ) + let component = content.components[collidedComponentIdx] + if (component.type != "player") { + return content //do nothing if the ball isn't dropped on a player. + } + const components = content.components.toSpliced( + collidedComponentIdx, + 1, + { + ...component, + hasBall: true, + }, + ) + // Maybe the ball is not present on the court as an object component + // if so, don't bother removing it from the court. + // This can occur if the user drags and drop the ball from a player that already has the ball + // to another component + if (ballObj != -1) { + components.splice(ballObj, 1) + } return { ...content, - actions: updateActions(content.actions, players), - players, - objects: content.objects.toSpliced(ballObj, 1), + actions: updateActions(content.actions, components), + components, } }) } - const onBallDrop = (refBounds: DOMRect) => { + const onBallMoved = (refBounds: DOMRect) => { if (!isBoundsOnCourt(refBounds)) { removeCourtBall() return } - const playerCollidedIdx = getPlayerCollided(refBounds, content.players) + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + ) if (playerCollidedIdx != -1) { setContent((content) => { return { ...content, - players: content.players.map((player) => ({ - ...player, - hasBall: false, - })), + components: content.components.map((c) => + c.type == "player" + ? { + ...c, + hasBall: false, + } + : c, + ), } }) - onBallDropOnPlayer(playerCollidedIdx) + onBallDropOnComponent(playerCollidedIdx) return } - if (content.objects.findIndex((o) => o.type == "ball") != -1) { + if (content.components.findIndex((o) => o.type == "ball") != -1) { return } const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const { x, y } = ratioWithinBase(refBounds, courtBounds) - let courtObject: CourtObject - - courtObject = { - type: "ball", + const courtObject = { + type: BALL_TYPE, + id: BALL_ID, rightRatio: x, bottomRatio: y, - } + } as Ball + + let components = content.components.map((c) => + c.type == "player" + ? { + ...c, + hasBall: false, + } + : c, + ) + components = [...components, courtObject] - const players = content.players.map((player) => ({ - ...player, - hasBall: false, + setContent((content) => ({ + ...content, + actions: updateActions(content.actions, components), + components, })) - - 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], + components: replaceOrInsert(content.components, player, false), actions: content.actions.filter( - (a) => - a.toPlayerId !== player.id && a.fromPlayerId !== player.id, + (a) => a.toId !== player.id && a.fromId !== player.id, ), })) let setter @@ -379,14 +376,21 @@ function EditorView({ const removeCourtBall = () => { setContent((content) => { - const ballObj = content.objects.findIndex((o) => o.type == "ball") + const ballObj = content.components.findIndex( + (o) => o.type == "ball", + ) + const components = content.components.map((c) => + c.type == "player" + ? ({ + ...c, + hasBall: false, + } as Player) + : c, + ) + components.splice(ballObj, 1) return { ...content, - players: content.players.map((player) => ({ - ...player, - hasBall: false, - })), - objects: content.objects.toSpliced(ballObj, 1), + components, } }) setObjects([{ key: "ball" }]) @@ -423,7 +427,7 @@ function EditorView({ canDetach={(div) => isBoundsOnCourt(div.getBoundingClientRect()) } - onElementDetached={onPieceDetach} + onElementDetached={onRackPieceDetach} render={({ team, key }) => ( isBoundsOnCourt(div.getBoundingClientRect()) } - onElementDetached={onObjectDetach} + onElementDetached={onRackedObjectDetach} render={renderCourtObject} /> @@ -452,7 +456,7 @@ function EditorView({ canDetach={(div) => isBoundsOnCourt(div.getBoundingClientRect()) } - onElementDetached={onPieceDetach} + onElementDetached={onRackPieceDetach} render={({ team, key }) => (
} courtRef={courtDivContentRef} setActions={(actions) => setContent((content) => ({ ...content, - players: content.players, actions: actions(content.actions), })) } @@ -515,8 +517,8 @@ function EditorView({ } setContent((content) => ({ ...content, - players: toSplicedPlayers( - content.players, + components: replaceOrInsert( + content.components, player, true, ), @@ -533,10 +535,11 @@ function EditorView({ } 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.hasBall) || c.type == BALL_TYPE, + ) != -1 + ) } function renderCourtObject(courtObject: RackedCourtObject) { @@ -558,12 +561,18 @@ function Court({ courtType }: { courtType: string }) { ) } -function getRackPlayers(team: PlayerTeam, players: Player[]): RackedPlayer[] { + +function getRackPlayers( + team: PlayerTeam, + components: TacticComponent[], +): RackedPlayer[] { return ["1", "2", "3", "4", "5"] .filter( (role) => - players.findIndex((p) => p.team == team && p.role == role) == - -1, + components.findIndex( + (c) => + c.type == "player" && c.team == team && c.role == role, + ) == -1, ) .map((key) => ({ team, key })) } @@ -611,14 +620,11 @@ function useContentState( return [content, setContentSynced, savingState] } -function toSplicedPlayers( - players: Player[], - player: Player, +function replaceOrInsert( + array: A[], + it: A, replace: boolean, -): Player[] { - const idx = players.findIndex( - (p) => p.team === player.team && p.role === player.role, - ) - - return players.toSpliced(idx, 1, ...(replace ? [player] : [])) +): A[] { + const idx = array.findIndex((i) => i.id == it.id) + return array.toSpliced(idx, 1, ...(replace ? [it] : [])) } diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index de33224..cef1e86 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -46,7 +46,7 @@ export function CourtAction({ }} wavy={action.type == ActionKind.DRIBBLE} //TODO place those magic values in constants - endRadius={action.toPlayerId ? 26 : 17} + endRadius={action.toId ? 26 : 17} startRadius={0} onDeleteRequested={onActionDeleted} style={{ 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/setup-tables.sql b/sql/setup-tables.sql index 77f2b3d..0763818 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -26,7 +26,7 @@ CREATE TABLE Tactic name varchar NOT NULL, creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, owner integer NOT NULL, - content varchar DEFAULT '{"players": [], "actions": [], "objects": []}' NOT NULL, + content varchar DEFAULT '{"components": [], "actions": []}' NOT NULL, court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL, FOREIGN KEY (owner) REFERENCES Account ); diff --git a/src/App/Controller/EditorController.php b/src/App/Controller/EditorController.php index 4bdcfae..b6ebd6e 100644 --- a/src/App/Controller/EditorController.php +++ b/src/App/Controller/EditorController.php @@ -42,7 +42,7 @@ class EditorController { return ViewHttpResponse::react("views/Editor.tsx", [ "id" => -1, //-1 id means that the editor will not support saves "name" => TacticModel::TACTIC_DEFAULT_NAME, - "content" => '{"players": [], "objects": [], "actions": []}', + "content" => '{"components": [], "actions": []}', "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

Date: Fri, 5 Jan 2024 19:33:46 +0100 Subject: [PATCH 2/7] add phantoms for move and dribble --- front/components/actions/BallAction.tsx | 16 +- front/components/arrows/BendableArrow.tsx | 2 +- front/components/editor/BallPiece.tsx | 4 +- front/components/editor/BasketCourt.tsx | 250 +------ front/components/editor/CourtBall.tsx | 4 +- front/components/editor/CourtPlayer.tsx | 59 +- front/editor/ActionsDomains.ts | 214 ++++++ front/editor/PlayerDomains.ts | 80 +++ front/editor/RackedItems.ts | 11 + front/editor/TacticContentDomains.ts | 299 ++++++++ front/model/tactic/Action.ts | 3 +- .../model/tactic/{Ball.ts => CourtObjects.ts} | 0 front/model/tactic/Player.ts | 49 +- front/model/tactic/Tactic.ts | 12 +- front/style/player.css | 4 + front/views/Editor.tsx | 653 +++++++++--------- front/views/editor/CourtAction.tsx | 2 +- sql/database.php | 2 +- 18 files changed, 1039 insertions(+), 625 deletions(-) create mode 100644 front/editor/ActionsDomains.ts create mode 100644 front/editor/PlayerDomains.ts create mode 100644 front/editor/RackedItems.ts create mode 100644 front/editor/TacticContentDomains.ts rename front/model/tactic/{Ball.ts => CourtObjects.ts} (100%) diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx index a26785c..f4af373 100644 --- a/front/components/actions/BallAction.tsx +++ b/front/components/actions/BallAction.tsx @@ -1,17 +1,21 @@ -import { BallPiece } from "../editor/BallPiece" +import {BallPiece} from "../editor/BallPiece" import Draggable from "react-draggable" -import { useRef } from "react" +import {useRef} from "react" +import {NULL_POS} from "../../geo/Pos"; export interface BallActionProps { - onDrop: (el: HTMLElement) => void + onDrop: (el: DOMRect) => void } -export default function BallAction({ onDrop }: BallActionProps) { +export default function BallAction({onDrop}: BallActionProps) { const ref = useRef(null) return ( - onDrop(ref.current!)} nodeRef={ref}> + onDrop(ref.current!.getBoundingClientRect())} + position={NULL_POS}>
- +
) diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index e2219bb..e46fb7b 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -147,7 +147,7 @@ export default function BendableArrow({ // If the (original) segments changes, overwrite the current ones. useLayoutEffect(() => { setInternalSegments(computeInternalSegments(segments)) - }, [startPos, segments, computeInternalSegments]) + }, [computeInternalSegments]) const [isSelected, setIsSelected] = useState(false) diff --git a/front/components/editor/BallPiece.tsx b/front/components/editor/BallPiece.tsx index d72ad75..1156780 100644 --- a/front/components/editor/BallPiece.tsx +++ b/front/components/editor/BallPiece.tsx @@ -1,8 +1,8 @@ import "../../style/ball.css" import BallSvg from "../../assets/icon/ball.svg?react" -import {BALL_ID} from "../../model/tactic/Ball"; +import { BALL_ID } from "../../model/tactic/CourtObjects" export function BallPiece() { - return + return } diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index 7aba76c..f684e1b 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,37 +1,16 @@ -import { CourtBall } from "./CourtBall" +import {ReactElement, ReactNode, RefObject, useLayoutEffect, useState,} from "react" +import {Action} from "../../model/tactic/Action" -import { - ReactElement, - RefObject, - useCallback, - useLayoutEffect, - useState, -} from "react" -import CourtPlayer from "./CourtPlayer" - -import { Player } from "../../model/tactic/Player" -import { Action, ActionKind } from "../../model/tactic/Action" -import ArrowAction from "../actions/ArrowAction" -import { middlePos, ratioWithinBase } from "../../geo/Pos" -import BallAction from "../actions/BallAction" -import {BALL_ID} from "../../model/tactic/Ball" -import { contains, overlaps } from "../../geo/Box" - -import { CourtAction } from "../../views/editor/CourtAction" -import { TacticComponent } from "../../model/tactic/Tactic" +import {CourtAction} from "../../views/editor/CourtAction" +import {TacticComponent} from "../../model/tactic/Tactic" export interface BasketCourtProps { components: TacticComponent[] actions: Action[] + previewAction: Action | null - renderAction: (a: Action, key: number) => ReactElement - setActions: (f: (a: Action[]) => Action[]) => void - - onPlayerRemove: (p: Player) => void - onPlayerChange: (p: Player) => void - - onBallRemove: () => void - onBallMoved: (ball: DOMRect) => void + renderComponent: (comp: TacticComponent) => ReactNode + renderAction: (action: Action, idx: number) => ReactNode courtImage: ReactElement courtRef: RefObject @@ -40,104 +19,14 @@ export interface BasketCourtProps { export function BasketCourt({ components, actions, - renderAction, - setActions, - onPlayerRemove, - onPlayerChange, + previewAction, - onBallMoved, - onBallRemove, + renderComponent, + renderAction, courtImage, courtRef, }: BasketCourtProps) { - function placeArrow(origin: Player, arrowHead: DOMRect) { - const originRef = document.getElementById(origin.id)! - const courtBounds = courtRef.current!.getBoundingClientRect() - const start = ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ) - - for (const component of components) { - if (component.id == origin.id) { - continue - } - - const playerBounds = document - .getElementById(component.id)! - .getBoundingClientRect() - - if (overlaps(playerBounds, arrowHead)) { - const targetPos = document - .getElementById(component.id)! - .getBoundingClientRect() - - const end = ratioWithinBase(middlePos(targetPos), courtBounds) - - const action: Action = { - fromId: originRef.id, - toId: component.id, - type: - component.type == "player" - ? origin.hasBall - ? ActionKind.SHOOT - : ActionKind.SCREEN - : ActionKind.MOVE, - moveFrom: start, - segments: [{ next: end }], - } - setActions((actions) => [...actions, action]) - return - } - } - - const action: Action = { - fromId: originRef.id, - type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE, - moveFrom: ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ), - segments: [ - { next: ratioWithinBase(middlePos(arrowHead), courtBounds) }, - ], - } - setActions((actions) => [...actions, action]) - } - - const [previewAction, setPreviewAction] = useState(null) - - const updateActionsRelatedTo = useCallback((comp: TacticComponent) => { - const newPos = ratioWithinBase( - middlePos( - document.getElementById(comp.id)!.getBoundingClientRect(), - ), - courtRef.current!.getBoundingClientRect(), - ) - setActions((actions) => - actions.map((a) => { - if (a.fromId == comp.id) { - return { ...a, moveFrom: newPos } - } - - if (a.toId == comp.id) { - const segments = a.segments.toSpliced( - a.segments.length - 1, - 1, - { - ...a.segments[a.segments.length - 1], - next: newPos, - }, - ) - return { ...a, segments } - } - - return a - }), - ) - }, []) - const [internActions, setInternActions] = useState([]) useLayoutEffect(() => setInternActions(actions), [actions]) @@ -149,122 +38,7 @@ export function BasketCourt({ style={{ position: "relative" }}> {courtImage} - {components.map((component) => { - if (component.type == "player") { - const player = component - return ( - updateActionsRelatedTo(player)} - onChange={onPlayerChange} - onRemove={() => onPlayerRemove(player)} - courtRef={courtRef} - availableActions={(pieceRef) => [ - { - const baseBounds = - courtRef.current!.getBoundingClientRect() - - const arrowHeadPos = middlePos(headPos) - - const target = components.find( - (c) => - c.id != player.id && - contains( - document - .getElementById(c.id)! - .getBoundingClientRect(), - arrowHeadPos, - ), - ) - - const type = - target?.type == "player" - ? player.hasBall - ? target - ? ActionKind.SHOOT - : ActionKind.DRIBBLE - : target - ? ActionKind.SCREEN - : ActionKind.MOVE - : ActionKind.MOVE - - setPreviewAction((action) => ({ - ...action!, - segments: [ - { - next: ratioWithinBase( - arrowHeadPos, - baseBounds, - ), - }, - ], - type, - })) - }} - onHeadPicked={(headPos) => { - ;( - document.activeElement as HTMLElement - ).blur() - const baseBounds = - courtRef.current!.getBoundingClientRect() - - setPreviewAction({ - type: player.hasBall - ? ActionKind.DRIBBLE - : ActionKind.MOVE, - fromId: player.id, - toId: undefined, - moveFrom: ratioWithinBase( - middlePos( - pieceRef.getBoundingClientRect(), - ), - baseBounds, - ), - segments: [ - { - next: ratioWithinBase( - middlePos(headPos), - baseBounds, - ), - }, - ], - }) - }} - onHeadDropped={(headRect) => { - placeArrow(player, headRect) - setPreviewAction(null) - }} - />, - player.hasBall && ( - - onBallMoved( - ref.getBoundingClientRect(), - ) - } - /> - ), - ]} - /> - ) - } - if (component.type == BALL_ID) { - return ( - updateActionsRelatedTo(component)} - ball={component} - onRemove={onBallRemove} - key="ball" - /> - ) - } - throw new Error("unknown tactic component " + component) - })} + {components.map(renderComponent)} {internActions.map((action, idx) => renderAction(action, idx))} @@ -272,7 +46,7 @@ export function BasketCourt({ {}} onActionChanges={() => {}} /> diff --git a/front/components/editor/CourtBall.tsx b/front/components/editor/CourtBall.tsx index 1e208be..53ae408 100644 --- a/front/components/editor/CourtBall.tsx +++ b/front/components/editor/CourtBall.tsx @@ -1,7 +1,8 @@ import React, { useRef } from "react" import Draggable from "react-draggable" import { BallPiece } from "./BallPiece" -import { Ball } from "../../model/tactic/Ball" +import { NULL_POS } from "../../geo/Pos" +import { Ball } from "../../model/tactic/CourtObjects" export interface CourtBallProps { onPosValidated: (rect: DOMRect) => void @@ -27,6 +28,7 @@ export function CourtBall({ onPosValidated(pieceRef.current!.getBoundingClientRect()) } onDrag={onMoves} + position={NULL_POS} nodeRef={pieceRef}>
void - onChange: (p: Player) => void +export interface CourtPlayerProps { + playerInfo: PlayerInfo + className?: string + + onMoves: () => void + onPositionValidated: (newPos: Pos) => void onRemove: () => void courtRef: RefObject availableActions: (ro: HTMLElement) => ReactNode[] @@ -18,45 +20,38 @@ export interface PlayerProps { * A player that is placed on the court, which can be selected, and moved in the associated bounds * */ export default function CourtPlayer({ - player, - onDrag, - onChange, + playerInfo, + className, + + onMoves, + onPositionValidated, onRemove, courtRef, availableActions, -}: PlayerProps) { - const hasBall = player.hasBall - const x = player.rightRatio - const y = player.bottomRatio +}: CourtPlayerProps) { + const usesBall = playerInfo.ballState != BallState.NONE + const x = playerInfo.rightRatio + const y = playerInfo.bottomRatio const pieceRef = useRef(null) return ( { const pieceBounds = pieceRef.current!.getBoundingClientRect() const parentBounds = courtRef.current!.getBoundingClientRect() - const { x, y } = ratioWithinBase(pieceBounds, parentBounds) - - onChange({ - type: "player", - id: player.id, - rightRatio: x, - bottomRatio: y, - team: player.team, - role: player.role, - hasBall: player.hasBall, - } as Player) + const pos = ratioWithinBase(pieceBounds, parentBounds) + onPositionValidated(pos) }}>
diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts new file mode 100644 index 0000000..1179dc7 --- /dev/null +++ b/front/editor/ActionsDomains.ts @@ -0,0 +1,214 @@ +import {BallState, Player, PlayerPhantom} from "../model/tactic/Player" +import {middlePos, ratioWithinBase} from "../geo/Pos" +import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" +import {overlaps} from "../geo/Box" +import {Action, ActionKind} from "../model/tactic/Action" +import {removeBall, updateComponent} from "./TacticContentDomains" +import {getOrigin} from "./PlayerDomains" + +export function refreshAllActions( + actions: Action[], + components: TacticComponent[], +) { + return actions.map((action) => ({ + ...action, + type: getActionKindFrom(action.fromId, action.toId, components), + })) +} + +export function getActionKindFrom( + originId: ComponentId, + targetId: ComponentId | null, + components: TacticComponent[], +): ActionKind { + const origin = components.find((p) => p.id == originId)! + const target = components.find(p => p.id == targetId) + + let ballState = BallState.NONE + + if (origin.type == "player" || origin.type == "phantom") { + ballState = origin.ballState + } + + let hasTarget = target ? (target.type != 'phantom' || target.originPlayerId != origin.id) : false + + return getActionKind(hasTarget, ballState) +} + +export function getActionKind(hasTarget: boolean, ballState: BallState): ActionKind { + switch (ballState) { + case BallState.HOLDS: + return hasTarget ? ActionKind.SHOOT : ActionKind.DRIBBLE + case BallState.SHOOTED: + return ActionKind.MOVE + case BallState.NONE: + return hasTarget ? ActionKind.SCREEN : ActionKind.MOVE + } +} + +export function placeArrow( + origin: Player | PlayerPhantom, + courtBounds: DOMRect, + arrowHead: DOMRect, + content: TacticContent, +): { createdAction: Action, newContent: TacticContent } { + const originRef = document.getElementById(origin.id)! + const start = ratioWithinBase( + middlePos(originRef.getBoundingClientRect()), + courtBounds, + ) + + /** + * Creates a new phantom component. + * Be aware that this function will reassign the `content` parameter. + * @param receivesBall + */ + function createPhantom(receivesBall: boolean): ComponentId { + const {x, y} = ratioWithinBase(arrowHead, courtBounds) + + let itemIndex: number + let originPlayer: Player + + if (origin.type == "phantom") { + // if we create a phantom from another phantom, + // simply add it to the phantom's path + const originPlr = getOrigin(origin, content.components)! + itemIndex = originPlr.path!.items.length + originPlayer = originPlr + } else { + // if we create a phantom directly from a player + // create a new path and add it into + itemIndex = 0 + originPlayer = origin + } + + const path = originPlayer.path + + const phantomId = "phantom-" + itemIndex + "-" + originPlayer.id + + content = updateComponent( + { + ...originPlayer, + path: { + items: path ? [...path.items, phantomId] : [phantomId], + }, + }, + content, + ) + + const ballState = receivesBall + ? BallState.HOLDS + : origin.ballState == BallState.HOLDS + ? BallState.HOLDS + : BallState.NONE + + const phantom: PlayerPhantom = { + type: "phantom", + id: phantomId, + rightRatio: x, + bottomRatio: y, + originPlayerId: originPlayer.id, + ballState + } + content = { + ...content, + components: [...content.components, phantom], + } + return phantom.id + } + + for (const component of content.components) { + if (component.id == origin.id) { + continue + } + + const componentBounds = document + .getElementById(component.id)! + .getBoundingClientRect() + + if (overlaps(componentBounds, arrowHead)) { + const targetPos = document + .getElementById(component.id)! + .getBoundingClientRect() + + const end = ratioWithinBase(middlePos(targetPos), courtBounds) + + let toId = component.id + + if (component.type == "ball") { + toId = createPhantom(true) + content = removeBall(content) + } + + const action: Action = { + fromId: originRef.id, + toId, + type: getActionKind(true, origin.ballState), + moveFrom: start, + segments: [{next: end}], + } + + return { + newContent: { + ...content, + actions: [...content.actions, action], + }, + createdAction: action + } + } + } + + const phantomId = createPhantom(origin.ballState == BallState.HOLDS) + + const action: Action = { + fromId: originRef.id, + toId: phantomId, + type: getActionKind(false, origin.ballState), + moveFrom: ratioWithinBase( + middlePos(originRef.getBoundingClientRect()), + courtBounds, + ), + segments: [ + {next: ratioWithinBase(middlePos(arrowHead), courtBounds)}, + ], + } + return { + newContent: { + ...content, + actions: [...content.actions, action], + }, + createdAction: action + } +} + +export function repositionActionsRelatedTo( + compId: ComponentId, + courtBounds: DOMRect, + actions: Action[], +): Action[] { + const posRect = document.getElementById(compId)?.getBoundingClientRect() + const newPos = posRect != undefined + ? ratioWithinBase(middlePos(posRect), courtBounds) + : undefined + + return actions.flatMap((action) => { + if (newPos == undefined) { + return [] + } + + if (action.fromId == compId) { + return [{...action, moveFrom: newPos}] + } + + if (action.toId == compId) { + const lastIdx = action.segments.length - 1 + const segments = action.segments.toSpliced(lastIdx, 1, { + ...action.segments[lastIdx], + next: newPos!, + }) + return [{...action, segments}] + } + + return action + }) +} diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts new file mode 100644 index 0000000..9ef1d45 --- /dev/null +++ b/front/editor/PlayerDomains.ts @@ -0,0 +1,80 @@ +import { Player, PlayerPhantom } from "../model/tactic/Player" +import { TacticComponent, TacticContent } from "../model/tactic/Tactic" +import { removeComponent, updateComponent } from "./TacticContentDomains" + +export function getOrigin( + pathItem: PlayerPhantom, + components: TacticComponent[], +): Player { + // Trust the components to contains only phantoms with valid player origin identifiers + return components.find((c) => c.id == pathItem.originPlayerId)! as Player +} + +export function removePlayerPath( + player: Player, + content: TacticContent, +): TacticContent { + if (player.path == null) { + return content + } + + for (const pathElement of player.path.items) { + content = removeComponent(pathElement, content) + } + return updateComponent( + { + ...player, + path: null, + }, + content, + ) +} + +export function removePlayer( + player: Player | PlayerPhantom, + content: TacticContent, +): TacticContent { + if (player.type == "phantom") { + const origin = getOrigin(player, content.components) + return truncatePlayerPath(origin, player, content) + } + + content = removePlayerPath(player, content) + return removeComponent(player.id, content) +} + +export function truncatePlayerPath( + player: Player, + phantom: PlayerPhantom, + content: TacticContent, +): TacticContent { + if (player.path == null) return content + + const path = player.path! + + let truncateStartIdx = -1 + + for (let j = 0; j < path.items.length; j++) { + const pathPhantomId = path.items[j] + if (truncateStartIdx != -1 || pathPhantomId == phantom.id) { + if (truncateStartIdx == -1) truncateStartIdx = j + + //remove the phantom from the tactic + content = removeComponent(pathPhantomId, content) + } + } + + return updateComponent( + { + ...player, + path: + truncateStartIdx == 0 + ? null + : { + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, + }, + content, + ) +} diff --git a/front/editor/RackedItems.ts b/front/editor/RackedItems.ts new file mode 100644 index 0000000..f2df151 --- /dev/null +++ b/front/editor/RackedItems.ts @@ -0,0 +1,11 @@ +/** + * information about a player that is into a rack + */ +import { PlayerTeam } from "../model/tactic/Player" + +export interface RackedPlayer { + team: PlayerTeam + key: string +} + +export type RackedCourtObject = { key: "ball" } diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts new file mode 100644 index 0000000..bec65bc --- /dev/null +++ b/front/editor/TacticContentDomains.ts @@ -0,0 +1,299 @@ +import {Pos, ratioWithinBase} from "../geo/Pos" +import {BallState, Player, PlayerInfo, PlayerTeam} from "../model/tactic/Player" +import {Ball, BALL_ID, BALL_TYPE, CourtObject} from "../model/tactic/CourtObjects" +import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" +import {overlaps} from "../geo/Box" +import {RackedCourtObject, RackedPlayer} from "./RackedItems" +import {refreshAllActions} from "./ActionsDomains" +import {getOrigin} from "./PlayerDomains"; + +export function placePlayerAt( + refBounds: DOMRect, + courtBounds: DOMRect, + element: RackedPlayer, +): Player { + const {x, y} = ratioWithinBase(refBounds, courtBounds) + + return { + type: "player", + id: "player-" + element.key + "-" + element.team, + team: element.team, + role: element.key, + rightRatio: x, + bottomRatio: y, + ballState: BallState.NONE, + path: null, + } +} + +export function placeObjectAt( + refBounds: DOMRect, + courtBounds: DOMRect, + rackedObject: RackedCourtObject, + content: TacticContent, +): TacticContent { + const {x, y} = ratioWithinBase(refBounds, courtBounds) + + let courtObject: CourtObject + + switch (rackedObject.key) { + case BALL_TYPE: + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + if (playerCollidedIdx != -1) { + return dropBallOnComponent(playerCollidedIdx, content) + } + + courtObject = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + } + break + + default: + throw new Error("unknown court object " + rackedObject.key) + } + + return { + ...content, + components: [...content.components, courtObject], + } +} + +export function dropBallOnComponent( + targetedComponentIdx: number, + content: TacticContent, +): TacticContent { + let components = content.components + let component = components[targetedComponentIdx] + + let origin + let isPhantom: boolean + + if (component.type == 'phantom') { + isPhantom = true + origin = getOrigin(component, components) + } else if (component.type == 'player') { + isPhantom = false + origin = component + } else { + return content + } + + components = components.toSpliced(targetedComponentIdx, 1, { + ...component, + ballState: BallState.HOLDS, + }) + if (origin.path != null) { + const phantoms = origin.path!.items + const headingPhantoms = isPhantom ? phantoms.slice(phantoms.indexOf(component.id)) : phantoms + components = components.map(c => headingPhantoms.indexOf(c.id) != -1 ? { + ...c, + hasBall: true + } : c) + } + + const ballObj = components.findIndex((p) => p.type == BALL_TYPE) + + // Maybe the ball is not present on the court as an object component + // if so, don't bother removing it from the court. + // This can occur if the user drags and drop the ball from a player that already has the ball + // to another component + if (ballObj != -1) { + components.splice(ballObj, 1) + } + return { + ...content, + actions: refreshAllActions(content.actions, components), + components, + } +} + +export function removeBall(content: TacticContent): TacticContent { + const ballObj = content.components.findIndex((o) => o.type == "ball") + + const components = content.components.map((c) => + (c.type == 'player' || c.type == 'phantom') + ? { + ...c, + hasBall: false, + } + : c, + ) + + // if the ball is already not on the court, do nothing + if (ballObj != -1) { + components.splice(ballObj, 1) + } + + return { + ...content, + actions: refreshAllActions(content.actions, components), + components, + } +} + +export function placeBallAt( + refBounds: DOMRect, + courtBounds: DOMRect, + content: TacticContent, +): { + newContent: TacticContent + removed: boolean +} { + if (!overlaps(courtBounds, refBounds)) { + return {newContent: removeBall(content), removed: true} + } + const playerCollidedIdx = getComponentCollided( + refBounds, + content.components, + BALL_ID, + ) + if (playerCollidedIdx != -1) { + return { + newContent: dropBallOnComponent(playerCollidedIdx, { + ...content, + components: content.components.map((c) => + c.type == "player" || c.type == 'phantom' + ? { + ...c, + hasBall: false, + } + : c, + ), + }), + removed: false, + } + } + + const ballIdx = content.components.findIndex((o) => o.type == "ball") + + const {x, y} = ratioWithinBase(refBounds, courtBounds) + + const components = content.components.map((c) => + c.type == "player" || c.type == "phantom" + ? { + ...c, + hasBall: false, + } + : c, + ) + + const ball: Ball = { + type: BALL_TYPE, + id: BALL_ID, + rightRatio: x, + bottomRatio: y, + } + if (ballIdx != -1) { + components.splice(ballIdx, 1, ball) + } else { + components.push(ball) + } + + return { + newContent: { + ...content, + actions: refreshAllActions(content.actions, components), + components, + }, + removed: false, + } +} + +export function moveComponent( + newPos: Pos, + component: TacticComponent, + info: PlayerInfo, + courtBounds: DOMRect, + content: TacticContent, + removed: (content: TacticContent) => TacticContent, +): TacticContent { + const playerBounds = document + .getElementById(info.id)! + .getBoundingClientRect() + + // if the piece is no longer on the court, remove it + if (!overlaps(playerBounds, courtBounds)) { + return removed(content) + } + return updateComponent( + { + ...component, + rightRatio: newPos.x, + bottomRatio: newPos.y, + }, + content, + ) +} + +export function removeComponent( + componentId: ComponentId, + content: TacticContent, +): TacticContent { + const componentIdx = content.components.findIndex( + (c) => c.id == componentId, + ) + + return { + ...content, + components: content.components.toSpliced(componentIdx, 1), + actions: content.actions.filter( + (a) => a.toId !== componentId && a.fromId !== componentId, + ), + } +} + +export function updateComponent( + component: TacticComponent, + content: TacticContent, +): TacticContent { + const componentIdx = content.components.findIndex( + (c) => c.id == component.id, + ) + return { + ...content, + components: content.components.toSpliced(componentIdx, 1, component), + } +} + +export function getComponentCollided( + bounds: DOMRect, + components: TacticComponent[], + ignore?: ComponentId, +): number | -1 { + for (let i = 0; i < components.length; i++) { + const component = components[i] + + if (component.id == ignore) continue + + const playerBounds = document + .getElementById(component.id)! + .getBoundingClientRect() + + if (overlaps(playerBounds, bounds)) { + return i + } + } + return -1 +} + +export function getRackPlayers( + team: PlayerTeam, + components: TacticComponent[], +): RackedPlayer[] { + return ["1", "2", "3", "4", "5"] + .filter( + (role) => + components.findIndex( + (c) => + c.type == "player" && c.team == team && c.role == role, + ) == -1, + ) + .map((key) => ({team, key})) +} diff --git a/front/model/tactic/Action.ts b/front/model/tactic/Action.ts index d238398..f22dfaf 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -1,4 +1,3 @@ - import { Pos } from "../../geo/Pos" import { Segment } from "../../components/arrows/BendableArrow" import { ComponentId } from "./Tactic" @@ -14,7 +13,7 @@ export type Action = { type: ActionKind } & MovementAction export interface MovementAction { fromId: ComponentId - toId?: ComponentId + toId: ComponentId | null moveFrom: Pos segments: Segment[] } diff --git a/front/model/tactic/Ball.ts b/front/model/tactic/CourtObjects.ts similarity index 100% rename from front/model/tactic/Ball.ts rename to front/model/tactic/CourtObjects.ts diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index e558496..7df59ec 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -1,4 +1,4 @@ -import {Component} from "./Tactic"; +import { Component, ComponentId } from "./Tactic" export type PlayerId = string @@ -7,11 +7,15 @@ export enum PlayerTeam { Opponents = "opponents", } -export interface Player { +export interface Player extends PlayerInfo, Component<"player"> { readonly id: PlayerId } -export interface Player extends Component<"player"> { +/** + * All information about a player + */ +export interface PlayerInfo { + readonly id: string /** * the player's team * */ @@ -25,6 +29,43 @@ export interface Player extends Component<"player"> { /** * True if the player has a basketball */ - readonly hasBall: boolean + readonly ballState: BallState + + /** + * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) + */ + readonly bottomRatio: number + + /** + * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) + */ + readonly rightRatio: number +} + +export enum BallState { + NONE, + HOLDS, + SHOOTED } +export interface Player extends Component<"player">, PlayerInfo { + /** + * True if the player has a basketball + */ + readonly ballState: BallState + + readonly path: MovementPath | null +} + +export interface MovementPath { + readonly items: ComponentId[] +} + +/** + * A player phantom is a kind of component that represents the future state of a player + * according to the court's step information + */ +export interface PlayerPhantom extends Component<"phantom"> { + readonly originPlayerId: ComponentId + readonly ballState: BallState +} diff --git a/front/model/tactic/Tactic.ts b/front/model/tactic/Tactic.ts index 6580dbb..c641ac4 100644 --- a/front/model/tactic/Tactic.ts +++ b/front/model/tactic/Tactic.ts @@ -1,6 +1,6 @@ -import {Player} from "./Player" -import {Action} from "./Action" -import {CourtObject} from "./Ball" +import { Player, PlayerPhantom } from "./Player" +import { Action } from "./Action" +import { CourtObject } from "./CourtObjects" export interface Tactic { id: number @@ -13,7 +13,7 @@ export interface TacticContent { actions: Action[] } -export type TacticComponent = Player | CourtObject +export type TacticComponent = Player | CourtObject | PlayerPhantom export type ComponentId = string export interface Component { @@ -26,12 +26,12 @@ export interface Component { */ readonly id: ComponentId /** - * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) + * Percentage of the component's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) */ readonly bottomRatio: number /** - * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) + * Percentage of the component's position to the right (0 means left, 1 means right, 0.5 means middle) */ readonly rightRatio: number } diff --git a/front/style/player.css b/front/style/player.css index 22afe4e..b03123b 100644 --- a/front/style/player.css +++ b/front/style/player.css @@ -2,6 +2,10 @@ pointer-events: none; } +.phantom { + opacity: 50%; +} + .player-content { display: flex; flex-direction: column; diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 387ad74..5163bcc 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -8,20 +8,37 @@ import {BallPiece} from "../components/editor/BallPiece" import {Rack} from "../components/Rack" import {PlayerPiece} from "../components/editor/PlayerPiece" -import {Player, PlayerTeam} from "../model/tactic/Player" import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic" import {fetchAPI} from "../Fetcher" import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState" -import {BALL_ID, BALL_TYPE, CourtObject, Ball} from "../model/tactic/Ball" +import {BALL_TYPE} from "../model/tactic/CourtObjects" import {CourtAction} from "./editor/CourtAction" import {BasketCourt} from "../components/editor/BasketCourt" +import {overlaps} from "../geo/Box" +import { + dropBallOnComponent, + getComponentCollided, + getRackPlayers, + moveComponent, + placeBallAt, + placeObjectAt, + placePlayerAt, + removeBall, updateComponent, +} from "../editor/TacticContentDomains" +import {BallState, Player, PlayerInfo, PlayerPhantom, PlayerTeam,} from "../model/tactic/Player" +import {RackedCourtObject} from "../editor/RackedItems" +import CourtPlayer from "../components/editor/CourtPlayer" +import {getActionKind, placeArrow, repositionActionsRelatedTo,} from "../editor/ActionsDomains" +import ArrowAction from "../components/actions/ArrowAction" +import {middlePos, ratioWithinBase} from "../geo/Pos" import {Action, ActionKind} from "../model/tactic/Action" +import BallAction from "../components/actions/BallAction" +import {getOrigin, removePlayer, truncatePlayerPath,} from "../editor/PlayerDomains" +import {CourtBall} from "../components/editor/CourtBall" import {BASE} from "../Constants" -import {overlaps} from "../geo/Box" -import {ratioWithinBase} from "../geo/Pos" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -44,17 +61,7 @@ export interface EditorProps { courtType: "PLAIN" | "HALF" } -/** - * information about a player that is into a rack - */ -interface RackedPlayer { - team: PlayerTeam - key: string -} - -type RackedCourtObject = { key: "ball" } - -export default function Editor({ id, name, courtType, content }: EditorProps) { +export default function Editor({id, name, courtType, content}: EditorProps) { const isInGuestMode = id == -1 const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY) @@ -80,7 +87,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { ) return SaveStates.Guest } - return fetchAPI(`tactic/${id}/save`, { content }).then((r) => + return fetchAPI(`tactic/${id}/save`, {content}).then((r) => r.ok ? SaveStates.Ok : SaveStates.Err, ) }} @@ -89,7 +96,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name) return true //simulate that the name has been changed } - return fetchAPI(`tactic/${id}/edit/name`, { name }).then( + return fetchAPI(`tactic/${id}/edit/name`, {name}).then( (r) => r.ok, ) }} @@ -99,11 +106,11 @@ export default function Editor({ id, name, courtType, content }: EditorProps) { } function EditorView({ - tactic: { id, name, content: initialContent }, - onContentChange, - onNameChange, - courtType, -}: EditorViewProps) { + tactic: {id, name, content: initialContent}, + onContentChange, + onNameChange, + courtType, + }: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) @@ -124,235 +131,36 @@ function EditorView({ ) const [allies, setAllies] = useState( - getRackPlayers(PlayerTeam.Allies, content.components), + () => getRackPlayers(PlayerTeam.Allies, content.components), ) const [opponents, setOpponents] = useState( - getRackPlayers(PlayerTeam.Opponents, content.components), + () => getRackPlayers(PlayerTeam.Opponents, content.components), ) const [objects, setObjects] = useState( - isBallOnCourt(content) ? [] : [{ key: "ball" }], + () => isBallOnCourt(content) ? [] : [{key: "ball"}], ) - const courtDivContentRef = useRef(null) - - const isBoundsOnCourt = (bounds: DOMRect) => { - const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - - // check if refBounds overlaps courtBounds - return overlaps(courtBounds, bounds) - } - - const onRackPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { - const refBounds = ref.getBoundingClientRect() - const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - - const { x, y } = ratioWithinBase(refBounds, courtBounds) - - setContent((content) => { - return { - ...content, - components: [ - ...content.components, - { - type: "player", - id: "player-" + element.key + "-" + element.team, - team: element.team, - role: element.key, - rightRatio: x, - bottomRatio: y, - hasBall: false, - } as Player, - ], - actions: content.actions, - } - }) - } - - const onRackedObjectDetach = ( - ref: HTMLDivElement, - rackedObject: RackedCourtObject, - ) => { - const refBounds = ref.getBoundingClientRect() - const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - - const { x, y } = ratioWithinBase(refBounds, courtBounds) + const [previewAction, setPreviewAction] = useState(null) - let courtObject: CourtObject - - switch (rackedObject.key) { - case BALL_TYPE: - const ballObj = content.components.findIndex( - (o) => o.type == BALL_TYPE, - ) - const playerCollidedIdx = getComponentCollided( - refBounds, - content.components.toSpliced(ballObj, 1), - ) - if (playerCollidedIdx != -1) { - onBallDropOnComponent(playerCollidedIdx) - return - } + const courtRef = useRef(null) - courtObject = { - type: BALL_TYPE, - id: BALL_ID, - rightRatio: x, - bottomRatio: y, - } - break - - default: - throw new Error("unknown court object " + rackedObject.key) - } - - setContent((content) => { - return { - ...content, - components: [...content.components, courtObject], - } - }) - } - - const getComponentCollided = ( - bounds: DOMRect, - components: TacticComponent[], - ): number | -1 => { - for (let i = 0; i < components.length; i++) { - const component = components[i] - const playerBounds = document - .getElementById(component.id)! - .getBoundingClientRect() - if (overlaps(playerBounds, bounds)) { - return i - } - } - return -1 - } - - function updateActions(actions: Action[], components: TacticComponent[]) { - return actions.map((action) => { - const originHasBall = ( - components.find( - (p) => p.type == "player" && p.id == action.fromId, - )! as Player - ).hasBall - - let type = action.type - - if (originHasBall && type == ActionKind.MOVE) { - type = ActionKind.DRIBBLE - } else if (originHasBall && type == ActionKind.SCREEN) { - type = ActionKind.SHOOT - } else if (type == ActionKind.DRIBBLE) { - type = ActionKind.MOVE - } else if (type == ActionKind.SHOOT) { - type = ActionKind.SCREEN - } - return { - ...action, - type, - } - }) - } - - const onBallDropOnComponent = (collidedComponentIdx: number) => { - setContent((content) => { - const ballObj = content.components.findIndex( - (p) => p.type == BALL_TYPE, - ) - let component = content.components[collidedComponentIdx] - if (component.type != "player") { - return content //do nothing if the ball isn't dropped on a player. - } - const components = content.components.toSpliced( - collidedComponentIdx, - 1, - { - ...component, - hasBall: true, - }, - ) - // Maybe the ball is not present on the court as an object component - // if so, don't bother removing it from the court. - // This can occur if the user drags and drop the ball from a player that already has the ball - // to another component - if (ballObj != -1) { - components.splice(ballObj, 1) - } - return { - ...content, - actions: updateActions(content.actions, components), - components, - } - }) + const setActions = (action: SetStateAction) => { + setContent((c) => ({ + ...c, + actions: typeof action == "function" ? action(c.actions) : action, + })) } - const onBallMoved = (refBounds: DOMRect) => { - if (!isBoundsOnCourt(refBounds)) { - removeCourtBall() - return - } - const playerCollidedIdx = getComponentCollided( - refBounds, - content.components, - ) - if (playerCollidedIdx != -1) { - setContent((content) => { - return { - ...content, - components: content.components.map((c) => - c.type == "player" - ? { - ...c, - hasBall: false, - } - : c, - ), - } - }) - onBallDropOnComponent(playerCollidedIdx) - return - } - - if (content.components.findIndex((o) => o.type == "ball") != -1) { - return - } - - const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - const { x, y } = ratioWithinBase(refBounds, courtBounds) - const courtObject = { - type: BALL_TYPE, - id: BALL_ID, - rightRatio: x, - bottomRatio: y, - } as Ball - - let components = content.components.map((c) => - c.type == "player" - ? { - ...c, - hasBall: false, - } - : c, - ) - components = [...components, courtObject] - - setContent((content) => ({ - ...content, - actions: updateActions(content.actions, components), - components, + const setComponents = (action: SetStateAction) => { + setContent((c) => ({ + ...c, + components: + typeof action == "function" ? action(c.components) : action, })) } - const removePlayer = (player: Player) => { - setContent((content) => ({ - ...content, - components: replaceOrInsert(content.components, player, false), - actions: content.actions.filter( - (a) => a.toId !== player.id && a.fromId !== player.id, - ), - })) + const insertRackedPlayer = (player: Player) => { let setter switch (player.team) { case PlayerTeam.Opponents: @@ -361,8 +169,8 @@ function EditorView({ case PlayerTeam.Allies: setter = setAllies } - if (player.hasBall) { - setObjects([{ key: "ball" }]) + if (player.ballState == BallState.HOLDS) { + setObjects([{key: "ball"}]) } setter((players) => [ ...players, @@ -374,26 +182,168 @@ function EditorView({ ]) } - const removeCourtBall = () => { + const doMoveBall = (newBounds: DOMRect) => { setContent((content) => { - const ballObj = content.components.findIndex( - (o) => o.type == "ball", + const {newContent, removed} = placeBallAt( + newBounds, + courtBounds(), + content, ) - const components = content.components.map((c) => - c.type == "player" - ? ({ - ...c, - hasBall: false, - } as Player) - : c, - ) - components.splice(ballObj, 1) - return { - ...content, - components, + + if (removed) { + setObjects((objects) => [...objects, {key: "ball"}]) } + + return newContent }) - setObjects([{ key: "ball" }]) + } + + const courtBounds = () => courtRef.current!.getBoundingClientRect() + + const renderPlayer = (component: Player | PlayerPhantom) => { + let info: PlayerInfo + let canPlaceArrows: boolean + const isPhantom = component.type == "phantom" + + if (isPhantom) { + const origin = getOrigin(component, content.components) + const path = origin.path! + // phantoms can only place other arrows if they are the head of the path + canPlaceArrows = path.items.indexOf(component.id) == path.items.length - 1 + if (canPlaceArrows) { + // and if their only action is to shoot the ball + + // list the actions the phantoms does + const phantomArrows = content.actions.filter(c => c.fromId == component.id) + canPlaceArrows = phantomArrows.length == 0 || phantomArrows.findIndex(c => c.type != ActionKind.SHOOT) == -1 + } + + info = { + id: component.id, + team: origin.team, + role: origin.role, + bottomRatio: component.bottomRatio, + rightRatio: component.rightRatio, + ballState: component.ballState, + } + } else { + // a player + info = component + // can place arrows only if the + canPlaceArrows = component.path == null || content.actions.findIndex(p => p.fromId == component.id && p.type != ActionKind.SHOOT) == -1 + } + + return ( + + setActions((actions) => + repositionActionsRelatedTo(info.id, courtBounds(), actions), + ) + } + onPositionValidated={(newPos) => { + setContent((content) => + moveComponent( + newPos, + component, + info, + courtBounds(), + content, + + (content) => { + if (!isPhantom) insertRackedPlayer(component) + return removePlayer(component, content) + }, + ), + ) + }} + onRemove={() => { + setContent((c) => removePlayer(component, c)) + if (!isPhantom) insertRackedPlayer(component) + }} + courtRef={courtRef} + availableActions={(pieceRef) => [ + canPlaceArrows && ( + { + const arrowHeadPos = middlePos(headPos) + const targetIdx = getComponentCollided(headPos, content.components) + + setPreviewAction((action) => ({ + ...action!, + segments: [ + { + next: ratioWithinBase( + arrowHeadPos, + courtBounds(), + ), + }, + ], + type: getActionKind(targetIdx != -1, info.ballState), + })) + }} + onHeadPicked={(headPos) => { + (document.activeElement as HTMLElement).blur() + + setPreviewAction({ + type: getActionKind(false, info.ballState), + fromId: info.id, + toId: null, + moveFrom: ratioWithinBase( + middlePos( + pieceRef.getBoundingClientRect(), + ), + courtBounds(), + ), + segments: [ + { + next: ratioWithinBase( + middlePos(headPos), + courtBounds(), + ), + }, + ], + }) + }} + onHeadDropped={(headRect) => { + setContent((content) => { + let {createdAction, newContent} = placeArrow( + component, + courtBounds(), + headRect, + content, + ) + + let originNewBallState = component.ballState + + if (createdAction.type == ActionKind.SHOOT) { + const targetIdx = newContent.components.findIndex(c => c.id == createdAction.toId) + newContent = dropBallOnComponent(targetIdx, newContent) + originNewBallState = BallState.SHOOTED + } + + newContent = updateComponent({ + ...(newContent.components.find(c => c.id == component.id)! as Player | PlayerPhantom), + ballState: originNewBallState + }, newContent) + return newContent + }) + setPreviewAction(null) + }} + /> + ), + info.ballState != BallState.NONE && ( + + ), + ]} + /> + ) } return ( @@ -403,7 +353,7 @@ function EditorView({ Home
- +
-
+
@@ -425,10 +375,19 @@ function EditorView({ objects={allies} onChange={setAllies} canDetach={(div) => - isBoundsOnCourt(div.getBoundingClientRect()) + overlaps(courtBounds(), div.getBoundingClientRect()) } - onElementDetached={onRackPieceDetach} - render={({ team, key }) => ( + onElementDetached={(r, e) => + setComponents((components) => [ + ...components, + placePlayerAt( + r.getBoundingClientRect(), + courtBounds(), + e, + ), + ]) + } + render={({team, key}) => ( - isBoundsOnCourt(div.getBoundingClientRect()) + overlaps(courtBounds(), div.getBoundingClientRect()) + } + onElementDetached={(r, e) => + setContent((content) => + placeObjectAt( + r.getBoundingClientRect(), + courtBounds(), + e, + content, + ), + ) } - onElementDetached={onRackedObjectDetach} render={renderCourtObject} /> @@ -454,10 +422,19 @@ function EditorView({ objects={opponents} onChange={setOpponents} canDetach={(div) => - isBoundsOnCourt(div.getBoundingClientRect()) + overlaps(courtBounds(), div.getBoundingClientRect()) } - onElementDetached={onRackPieceDetach} - render={({ team, key }) => ( + onElementDetached={(r, e) => + setComponents((components) => [ + ...components, + placePlayerAt( + r.getBoundingClientRect(), + courtBounds(), + e, + ), + ]) + } + render={({team, key}) => ( } - courtRef={courtDivContentRef} - setActions={(actions) => - setContent((content) => ({ - ...content, - actions: actions(content.actions), - })) - } + courtImage={} + courtRef={courtRef} + previewAction={previewAction} + renderComponent={(component) => { + if ( + component.type == "player" || + component.type == "phantom" + ) { + return renderPlayer(component) + } + if (component.type == BALL_TYPE) { + return ( + + setActions((actions) => + repositionActionsRelatedTo( + component.id, + courtBounds(), + actions, + ), + ) + } + onRemove={() => { + setContent((content) => + removeBall(content), + ) + setObjects(objects => [...objects, {key: "ball"}]) + }} + /> + ) + } + throw new Error( + "unknown tactic component " + component, + ) + }} renderAction={(action, i) => ( { - setContent((content) => ({ - ...content, - actions: content.actions.toSpliced( - i, - 1, - ), - })) + setContent((content) => { + content = { + ...content, + actions: + content.actions.toSpliced( + i, + 1, + ), + } + + if (action.toId == null) + return content + + const target = + content.components.find( + (c) => action.toId == c.id, + )! + + if (target.type == "phantom") { + const origin = getOrigin( + target, + content.components, + ) + if (origin.id != action.fromId) { + return content + } + content = truncatePlayerPath( + origin, + target, + content, + ) + } + + return content + }) }} onActionChanges={(a) => setContent((content) => ({ @@ -507,25 +541,6 @@ function EditorView({ } /> )} - onPlayerChange={(player) => { - const playerBounds = document - .getElementById(player.id)! - .getBoundingClientRect() - if (!isBoundsOnCourt(playerBounds)) { - removePlayer(player) - return - } - setContent((content) => ({ - ...content, - components: replaceOrInsert( - content.components, - player, - true, - ), - })) - }} - onPlayerRemove={removePlayer} - onBallRemove={removeCourtBall} />
@@ -537,46 +552,30 @@ function EditorView({ function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( - (c) => (c.type == "player" && c.hasBall) || c.type == BALL_TYPE, + (c) => (c.type == "player" && c.ballState == BallState.HOLDS) || c.type == BALL_TYPE, ) != -1 ) } function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { - return + return } throw new Error("unknown racked court object " + courtObject.key) } -function Court({ courtType }: { courtType: string }) { +function Court({courtType}: { courtType: string }) { return (
{courtType == "PLAIN" ? ( - + ) : ( - + )}
) } - -function getRackPlayers( - team: PlayerTeam, - components: TacticComponent[], -): RackedPlayer[] { - return ["1", "2", "3", "4", "5"] - .filter( - (role) => - components.findIndex( - (c) => - c.type == "player" && c.team == team && c.role == role, - ) == -1, - ) - .map((key) => ({ team, key })) -} - function debounceAsync( f: (args: A) => Promise, delay = 1000, @@ -605,6 +604,7 @@ function useContentState( typeof newState === "function" ? (newState as (state: S) => S)(content) : newState + if (state !== content) { setSavingState(SaveStates.Saving) saveStateCallback(state) @@ -619,12 +619,3 @@ function useContentState( return [content, setContentSynced, savingState] } - -function replaceOrInsert
( - array: A[], - it: A, - replace: boolean, -): A[] { - const idx = array.findIndex((i) => i.id == it.id) - return array.toSpliced(idx, 1, ...(replace ? [it] : [])) -} diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index cef1e86..22a4147 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -47,7 +47,7 @@ export function CourtAction({ wavy={action.type == ActionKind.DRIBBLE} //TODO place those magic values in constants endRadius={action.toId ? 26 : 17} - startRadius={0} + startRadius={10} onDeleteRequested={onActionDeleted} style={{ head, diff --git a/sql/database.php b/sql/database.php index 69b53e7..336416c 100644 --- a/sql/database.php +++ b/sql/database.php @@ -1,7 +1,7 @@ Date: Sun, 14 Jan 2024 15:50:55 +0100 Subject: [PATCH 3/7] store actions directly inside each components, enhance bendable arrows to hook to DOM elements --- front/components/actions/BallAction.tsx | 10 +- front/components/arrows/BendableArrow.tsx | 72 ++++- front/components/editor/BasketCourt.tsx | 37 ++- front/components/editor/CourtBall.tsx | 9 +- front/components/editor/CourtPlayer.tsx | 11 +- front/editor/ActionsDomains.ts | 160 +++++----- front/editor/PlayerDomains.ts | 9 +- front/editor/TacticContentDomains.ts | 90 +++--- front/model/tactic/Action.ts | 5 +- front/model/tactic/Player.ts | 2 +- front/model/tactic/Tactic.ts | 4 +- front/style/actions/arrow_action.css | 1 + front/views/Editor.tsx | 358 ++++++++++++---------- front/views/editor/CourtAction.tsx | 8 +- sql/setup-tables.sql | 2 +- src/App/Controller/EditorController.php | 2 +- 16 files changed, 436 insertions(+), 344 deletions(-) diff --git a/front/components/actions/BallAction.tsx b/front/components/actions/BallAction.tsx index f4af373..87779df 100644 --- a/front/components/actions/BallAction.tsx +++ b/front/components/actions/BallAction.tsx @@ -1,13 +1,13 @@ -import {BallPiece} from "../editor/BallPiece" +import { BallPiece } from "../editor/BallPiece" import Draggable from "react-draggable" -import {useRef} from "react" -import {NULL_POS} from "../../geo/Pos"; +import { useRef } from "react" +import { NULL_POS } from "../../geo/Pos" export interface BallActionProps { onDrop: (el: DOMRect) => void } -export default function BallAction({onDrop}: BallActionProps) { +export default function BallAction({ onDrop }: BallActionProps) { const ref = useRef(null) return ( onDrop(ref.current!.getBoundingClientRect())} position={NULL_POS}>
- +
) diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index e46fb7b..46598f2 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -29,7 +29,7 @@ import Draggable from "react-draggable" export interface BendableArrowProps { area: RefObject - startPos: Pos + startPos: Pos | string segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void forceStraight: boolean @@ -55,7 +55,7 @@ const ArrowStyleDefaults: ArrowStyle = { } export interface Segment { - next: Pos + next: Pos | string controlPoint?: Pos } @@ -162,8 +162,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 +204,7 @@ export default function BendableArrow({ { const currentSegment = segments[i] @@ -252,19 +252,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, @@ -313,11 +313,11 @@ export default function BendableArrow({ 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 = @@ -382,6 +382,22 @@ export default function BendableArrow({ // 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]) + // Adds a selection handler // Also force an update when the window is resized useEffect(() => { @@ -418,10 +434,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) @@ -529,6 +551,24 @@ export default function BendableArrow({ ) } +function getPosWithinBase(target: Pos | string, area: DOMRect): Pos { + if (typeof target != "string") { + return posWithinBase(target, area) + } + + const targetPos = document.getElementById(target)!.getBoundingClientRect() + return relativeTo(middlePos(targetPos), area) +} + +function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos { + if (typeof target != "string") { + return target + } + + const targetPos = document.getElementById(target)!.getBoundingClientRect() + return ratioWithinBase(middlePos(targetPos), area) +} + interface ControlPointProps { className: string posRatio: Pos @@ -546,9 +586,9 @@ enum PointSegmentSearchResult { } interface FullSegment { - start: Pos + start: Pos | string controlPoint: Pos | null - end: Pos + end: Pos | string } /** diff --git a/front/components/editor/BasketCourt.tsx b/front/components/editor/BasketCourt.tsx index f684e1b..5815f7a 100644 --- a/front/components/editor/BasketCourt.tsx +++ b/front/components/editor/BasketCourt.tsx @@ -1,36 +1,41 @@ -import {ReactElement, ReactNode, RefObject, useLayoutEffect, useState,} from "react" -import {Action} from "../../model/tactic/Action" - -import {CourtAction} from "../../views/editor/CourtAction" -import {TacticComponent} from "../../model/tactic/Tactic" +import { + ReactElement, + ReactNode, + RefObject, + useEffect, + useLayoutEffect, + useState, +} from "react" +import { Action } from "../../model/tactic/Action" + +import { CourtAction } from "../../views/editor/CourtAction" +import { ComponentId, TacticComponent } from "../../model/tactic/Tactic" export interface BasketCourtProps { components: TacticComponent[] - actions: Action[] - previewAction: Action | null + previewAction: ActionPreview | null renderComponent: (comp: TacticComponent) => ReactNode - renderAction: (action: Action, idx: number) => ReactNode + renderActions: (comp: TacticComponent) => ReactNode[] courtImage: ReactElement courtRef: RefObject } +export interface ActionPreview extends Action { + origin: ComponentId +} + export function BasketCourt({ components, - actions, previewAction, renderComponent, - renderAction, + renderActions, courtImage, courtRef, }: BasketCourtProps) { - const [internActions, setInternActions] = useState([]) - - useLayoutEffect(() => setInternActions(actions), [actions]) - return (
renderAction(action, idx))} + {components.flatMap(renderActions)} {previewAction && ( {}} onActionChanges={() => {}} diff --git a/front/components/editor/CourtBall.tsx b/front/components/editor/CourtBall.tsx index 53ae408..b167126 100644 --- a/front/components/editor/CourtBall.tsx +++ b/front/components/editor/CourtBall.tsx @@ -6,17 +6,11 @@ import { Ball } from "../../model/tactic/CourtObjects" export interface CourtBallProps { onPosValidated: (rect: DOMRect) => void - onMoves: () => void onRemove: () => void ball: Ball } -export function CourtBall({ - onPosValidated, - ball, - onRemove, - onMoves, -}: CourtBallProps) { +export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) { const pieceRef = useRef(null) const x = ball.rightRatio @@ -27,7 +21,6 @@ export function CourtBall({ onStop={() => onPosValidated(pieceRef.current!.getBoundingClientRect()) } - onDrag={onMoves} position={NULL_POS} nodeRef={pieceRef}>
void onPositionValidated: (newPos: Pos) => void onRemove: () => void courtRef: RefObject @@ -23,7 +22,6 @@ export default function CourtPlayer({ playerInfo, className, - onMoves, onPositionValidated, onRemove, courtRef, @@ -38,7 +36,6 @@ export default function CourtPlayer({ { diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts index 1179dc7..54b6198 100644 --- a/front/editor/ActionsDomains.ts +++ b/front/editor/ActionsDomains.ts @@ -1,20 +1,24 @@ -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), - })) -} +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, @@ -22,7 +26,7 @@ export function getActionKindFrom( components: TacticComponent[], ): ActionKind { const origin = components.find((p) => p.id == originId)! - const target = components.find(p => p.id == targetId) + const target = components.find((p) => p.id == targetId) let ballState = BallState.NONE @@ -30,12 +34,17 @@ export function getActionKindFrom( ballState = origin.ballState } - let hasTarget = target ? (target.type != 'phantom' || target.originPlayerId != origin.id) : false + let hasTarget = target + ? target.type != "phantom" || target.originPlayerId != origin.id + : false return getActionKind(hasTarget, ballState) } -export function getActionKind(hasTarget: boolean, ballState: BallState): ActionKind { +export function getActionKind( + hasTarget: boolean, + ballState: BallState, +): ActionKind { switch (ballState) { case BallState.HOLDS: return hasTarget ? ActionKind.SHOOT : ActionKind.DRIBBLE @@ -51,20 +60,14 @@ export function placeArrow( courtBounds: DOMRect, arrowHead: DOMRect, content: TacticContent, -): { createdAction: Action, newContent: TacticContent } { - const originRef = document.getElementById(origin.id)! - const start = ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ) - +): { createdAction: Action; newContent: TacticContent } { /** * 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) + const { x, y } = ratioWithinBase(arrowHead, courtBounds) let itemIndex: number let originPlayer: Player @@ -99,16 +102,17 @@ export function placeArrow( const ballState = receivesBall ? BallState.HOLDS : origin.ballState == BallState.HOLDS - ? BallState.HOLDS - : BallState.NONE + ? BallState.HOLDS + : BallState.NONE const phantom: PlayerPhantom = { + actions: [], type: "phantom", id: phantomId, rightRatio: x, bottomRatio: y, originPlayerId: originPlayer.id, - ballState + ballState, } content = { ...content, @@ -127,12 +131,6 @@ export function placeArrow( .getBoundingClientRect() if (overlaps(componentBounds, arrowHead)) { - const targetPos = document - .getElementById(component.id)! - .getBoundingClientRect() - - const end = ratioWithinBase(middlePos(targetPos), courtBounds) - let toId = component.id if (component.type == "ball") { @@ -141,19 +139,20 @@ export function placeArrow( } const action: Action = { - fromId: originRef.id, - toId, + target: toId, type: getActionKind(true, origin.ballState), - moveFrom: start, - segments: [{next: end}], + segments: [{ next: component.id }], } return { - newContent: { - ...content, - actions: [...content.actions, action], - }, - createdAction: action + newContent: updateComponent( + { + ...origin, + actions: [...origin.actions, action], + }, + content, + ), + createdAction: action, } } } @@ -161,54 +160,37 @@ export function placeArrow( const phantomId = createPhantom(origin.ballState == BallState.HOLDS) const action: Action = { - fromId: originRef.id, - toId: phantomId, + target: phantomId, type: getActionKind(false, origin.ballState), - moveFrom: ratioWithinBase( - middlePos(originRef.getBoundingClientRect()), - courtBounds, - ), - segments: [ - {next: ratioWithinBase(middlePos(arrowHead), courtBounds)}, - ], + segments: [{ next: phantomId }], } return { - newContent: { - ...content, - actions: [...content.actions, action], - }, - createdAction: action + newContent: updateComponent( + { + ...content.components.find((c) => c.id == origin.id)!, + actions: [...origin.actions, action], + }, + content, + ), + createdAction: action, } } -export function repositionActionsRelatedTo( - compId: ComponentId, - courtBounds: DOMRect, - actions: Action[], -): Action[] { - const posRect = document.getElementById(compId)?.getBoundingClientRect() - const newPos = posRect != undefined - ? ratioWithinBase(middlePos(posRect), courtBounds) - : undefined - - return actions.flatMap((action) => { - if (newPos == undefined) { - return [] - } - - if (action.fromId == compId) { - return [{...action, moveFrom: newPos}] - } - - if (action.toId == compId) { - const lastIdx = action.segments.length - 1 - const segments = action.segments.toSpliced(lastIdx, 1, { - ...action.segments[lastIdx], - next: newPos!, - }) - return [{...action, segments}] - } +export function removeAllActionsTargeting( + componentId: ComponentId, + content: TacticContent, +): TacticContent { + let 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 action - }) + return { + ...content, + components, + } } diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts index 9ef1d45..b7c69df 100644 --- a/front/editor/PlayerDomains.ts +++ b/front/editor/PlayerDomains.ts @@ -1,6 +1,7 @@ import { Player, PlayerPhantom } from "../model/tactic/Player" import { TacticComponent, TacticContent } from "../model/tactic/Tactic" import { removeComponent, updateComponent } from "./TacticContentDomains" +import { removeAllActionsTargeting } from "./ActionsDomains" export function getOrigin( pathItem: PlayerPhantom, @@ -34,6 +35,8 @@ export function removePlayer( player: Player | PlayerPhantom, content: TacticContent, ): TacticContent { + content = removeAllActionsTargeting(player.id, content) + if (player.type == "phantom") { const origin = getOrigin(player, content.components) return truncatePlayerPath(origin, player, content) @@ -54,10 +57,10 @@ export function truncatePlayerPath( let truncateStartIdx = -1 - for (let j = 0; j < path.items.length; j++) { - const pathPhantomId = path.items[j] + for (let i = 0; i < path.items.length; i++) { + const pathPhantomId = path.items[i] if (truncateStartIdx != -1 || pathPhantomId == phantom.id) { - if (truncateStartIdx == -1) truncateStartIdx = j + if (truncateStartIdx == -1) truncateStartIdx = i //remove the phantom from the tactic content = removeComponent(pathPhantomId, content) diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts index bec65bc..d0a24ba 100644 --- a/front/editor/TacticContentDomains.ts +++ b/front/editor/TacticContentDomains.ts @@ -1,18 +1,31 @@ -import {Pos, ratioWithinBase} from "../geo/Pos" -import {BallState, Player, PlayerInfo, PlayerTeam} from "../model/tactic/Player" -import {Ball, BALL_ID, BALL_TYPE, CourtObject} from "../model/tactic/CourtObjects" -import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic" -import {overlaps} from "../geo/Box" -import {RackedCourtObject, RackedPlayer} from "./RackedItems" -import {refreshAllActions} from "./ActionsDomains" -import {getOrigin} from "./PlayerDomains"; +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" 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", @@ -23,6 +36,7 @@ export function placePlayerAt( bottomRatio: y, ballState: BallState.NONE, path: null, + actions: [], } } @@ -32,7 +46,7 @@ export function placeObjectAt( rackedObject: RackedCourtObject, content: TacticContent, ): TacticContent { - const {x, y} = ratioWithinBase(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject @@ -52,6 +66,7 @@ export function placeObjectAt( id: BALL_ID, rightRatio: x, bottomRatio: y, + actions: [], } break @@ -75,10 +90,10 @@ export function dropBallOnComponent( let origin let isPhantom: boolean - if (component.type == 'phantom') { + if (component.type == "phantom") { isPhantom = true origin = getOrigin(component, components) - } else if (component.type == 'player') { + } else if (component.type == "player") { isPhantom = false origin = component } else { @@ -91,11 +106,17 @@ export function dropBallOnComponent( }) 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 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) @@ -109,7 +130,6 @@ export function dropBallOnComponent( } return { ...content, - actions: refreshAllActions(content.actions, components), components, } } @@ -118,11 +138,11 @@ 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.type == "player" || c.type == "phantom" ? { - ...c, - hasBall: false, - } + ...c, + hasBall: false, + } : c, ) @@ -133,7 +153,6 @@ export function removeBall(content: TacticContent): TacticContent { return { ...content, - actions: refreshAllActions(content.actions, components), components, } } @@ -147,7 +166,7 @@ export function placeBallAt( removed: boolean } { if (!overlaps(courtBounds, refBounds)) { - return {newContent: removeBall(content), removed: true} + return { newContent: removeBall(content), removed: true } } const playerCollidedIdx = getComponentCollided( refBounds, @@ -159,11 +178,11 @@ export function placeBallAt( newContent: dropBallOnComponent(playerCollidedIdx, { ...content, components: content.components.map((c) => - c.type == "player" || c.type == 'phantom' + c.type == "player" || c.type == "phantom" ? { - ...c, - hasBall: false, - } + ...c, + hasBall: false, + } : c, ), }), @@ -173,14 +192,14 @@ export function placeBallAt( const ballIdx = content.components.findIndex((o) => o.type == "ball") - const {x, y} = ratioWithinBase(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) const components = content.components.map((c) => c.type == "player" || c.type == "phantom" ? { - ...c, - hasBall: false, - } + ...c, + hasBall: false, + } : c, ) @@ -189,6 +208,7 @@ export function placeBallAt( id: BALL_ID, rightRatio: x, bottomRatio: y, + actions: [], } if (ballIdx != -1) { components.splice(ballIdx, 1, ball) @@ -199,7 +219,6 @@ export function placeBallAt( return { newContent: { ...content, - actions: refreshAllActions(content.actions, components), components, }, removed: false, @@ -243,9 +262,6 @@ export function removeComponent( return { ...content, components: content.components.toSpliced(componentIdx, 1), - actions: content.actions.filter( - (a) => a.toId !== componentId && a.fromId !== componentId, - ), } } @@ -295,5 +311,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 f22dfaf..be5b155 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -12,8 +12,7 @@ export enum ActionKind { export type Action = { type: ActionKind } & MovementAction export interface MovementAction { - fromId: ComponentId - toId: ComponentId | null - moveFrom: Pos + // fromId: ComponentId + target: ComponentId | Pos segments: Segment[] } diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index 7df59ec..41738d3 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -45,7 +45,7 @@ export interface PlayerInfo { export enum BallState { NONE, HOLDS, - SHOOTED + SHOOTED, } export interface Player extends Component<"player">, PlayerInfo { diff --git a/front/model/tactic/Tactic.ts b/front/model/tactic/Tactic.ts index c641ac4..dfe1190 100644 --- a/front/model/tactic/Tactic.ts +++ b/front/model/tactic/Tactic.ts @@ -10,7 +10,7 @@ export interface Tactic { export interface TacticContent { components: TacticComponent[] - actions: Action[] + //actions: Action[] } export type TacticComponent = Player | CourtObject | PlayerPhantom @@ -34,4 +34,6 @@ export interface Component { * 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/views/Editor.tsx b/front/views/Editor.tsx index 5163bcc..7a2321b 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,23 +1,34 @@ -import {CSSProperties, Dispatch, SetStateAction, useCallback, useMemo, useRef, useState,} from "react" +import { + CSSProperties, + Dispatch, + SetStateAction, + useCallback, + useMemo, + useRef, + useState, +} from "react" import "../style/editor.css" import TitleInput from "../components/TitleInput" import PlainCourt from "../assets/court/full_court.svg?react" import HalfCourt from "../assets/court/half_court.svg?react" -import {BallPiece} from "../components/editor/BallPiece" +import { 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 {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, @@ -26,19 +37,30 @@ import { placeBallAt, placeObjectAt, placePlayerAt, - removeBall, updateComponent, + 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 } from "../editor/RackedItems" import CourtPlayer from "../components/editor/CourtPlayer" -import {getActionKind, placeArrow, repositionActionsRelatedTo,} from "../editor/ActionsDomains" +import { getActionKind, placeArrow } from "../editor/ActionsDomains" import ArrowAction from "../components/actions/ArrowAction" -import {middlePos, ratioWithinBase} from "../geo/Pos" -import {Action, ActionKind} from "../model/tactic/Action" +import { middlePos, ratioWithinBase } from "../geo/Pos" +import { Action, ActionKind } from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" -import {getOrigin, removePlayer, truncatePlayerPath,} from "../editor/PlayerDomains" -import {CourtBall} from "../components/editor/CourtBall" -import {BASE} from "../Constants" +import { + getOrigin, + removePlayer, + truncatePlayerPath, +} from "../editor/PlayerDomains" +import { CourtBall } from "../components/editor/CourtBall" +import { BASE } from "../Constants" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -61,7 +83,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) @@ -87,7 +109,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, ) }} @@ -96,7 +118,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, ) }} @@ -106,11 +128,11 @@ export default function Editor({id, name, courtType, content}: EditorProps) { } function EditorView({ - tactic: {id, name, content: initialContent}, - onContentChange, - onNameChange, - courtType, - }: EditorViewProps) { + tactic: { id, name, content: initialContent }, + onContentChange, + onNameChange, + courtType, +}: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) @@ -130,27 +152,24 @@ function EditorView({ ), ) - const [allies, setAllies] = useState( - () => getRackPlayers(PlayerTeam.Allies, content.components), + const [allies, setAllies] = useState(() => + getRackPlayers(PlayerTeam.Allies, content.components), ) - const [opponents, setOpponents] = useState( - () => getRackPlayers(PlayerTeam.Opponents, content.components), + const [opponents, setOpponents] = useState(() => + getRackPlayers(PlayerTeam.Opponents, content.components), ) - const [objects, setObjects] = useState( - () => isBallOnCourt(content) ? [] : [{key: "ball"}], + const [objects, setObjects] = useState(() => + isBallOnCourt(content) ? [] : [{ key: "ball" }], ) - const [previewAction, setPreviewAction] = useState(null) + const [previewAction, setPreviewAction] = useState( + null, + ) const courtRef = useRef(null) - const setActions = (action: SetStateAction) => { - setContent((c) => ({ - ...c, - actions: typeof action == "function" ? action(c.actions) : action, - })) - } + const actionsReRenderHooks = [] const setComponents = (action: SetStateAction) => { setContent((c) => ({ @@ -170,7 +189,7 @@ function EditorView({ setter = setAllies } if (player.ballState == BallState.HOLDS) { - setObjects([{key: "ball"}]) + setObjects([{ key: "ball" }]) } setter((players) => [ ...players, @@ -184,14 +203,14 @@ function EditorView({ const doMoveBall = (newBounds: DOMRect) => { setContent((content) => { - const {newContent, removed} = placeBallAt( + const { newContent, removed } = placeBallAt( newBounds, courtBounds(), content, ) if (removed) { - setObjects((objects) => [...objects, {key: "ball"}]) + setObjects((objects) => [...objects, { key: "ball" }]) } return newContent @@ -209,13 +228,18 @@ function EditorView({ const origin = getOrigin(component, content.components) const path = origin.path! // phantoms can only place other arrows if they are the head of the path - canPlaceArrows = path.items.indexOf(component.id) == path.items.length - 1 + canPlaceArrows = + path.items.indexOf(component.id) == path.items.length - 1 if (canPlaceArrows) { // and if their only action is to shoot the ball // list the actions the phantoms does - const phantomArrows = content.actions.filter(c => c.fromId == component.id) - canPlaceArrows = phantomArrows.length == 0 || phantomArrows.findIndex(c => c.type != ActionKind.SHOOT) == -1 + const phantomActions = component.actions + canPlaceArrows = + phantomActions.length == 0 || + phantomActions.findIndex( + (c) => c.type != ActionKind.SHOOT, + ) == -1 } info = { @@ -230,7 +254,11 @@ function EditorView({ // a player info = component // can place arrows only if the - canPlaceArrows = component.path == null || content.actions.findIndex(p => p.fromId == component.id && p.type != ActionKind.SHOOT) == -1 + canPlaceArrows = + component.path == null || + component.actions.findIndex( + (p) => p.type != ActionKind.SHOOT, + ) == -1 } return ( @@ -238,11 +266,6 @@ function EditorView({ key={component.id} className={isPhantom ? "phantom" : "player"} playerInfo={info} - onMoves={() => - setActions((actions) => - repositionActionsRelatedTo(info.id, courtBounds(), actions), - ) - } onPositionValidated={(newPos) => { setContent((content) => moveComponent( @@ -264,13 +287,16 @@ function EditorView({ if (!isPhantom) insertRackedPlayer(component) }} courtRef={courtRef} - availableActions={(pieceRef) => [ + availableActions={() => [ canPlaceArrows && ( { const arrowHeadPos = middlePos(headPos) - const targetIdx = getComponentCollided(headPos, content.components) + const targetIdx = getComponentCollided( + headPos, + content.components, + ) setPreviewAction((action) => ({ ...action!, @@ -282,20 +308,20 @@ function EditorView({ ), }, ], - type: getActionKind(targetIdx != -1, info.ballState), + type: getActionKind( + targetIdx != -1, + info.ballState, + ), })) }} onHeadPicked={(headPos) => { - (document.activeElement as HTMLElement).blur() + ;(document.activeElement as HTMLElement).blur() setPreviewAction({ + origin: component.id, type: getActionKind(false, info.ballState), - fromId: info.id, - toId: null, - moveFrom: ratioWithinBase( - middlePos( - pieceRef.getBoundingClientRect(), - ), + target: ratioWithinBase( + headPos, courtBounds(), ), segments: [ @@ -310,25 +336,41 @@ function EditorView({ }} onHeadDropped={(headRect) => { setContent((content) => { - let {createdAction, newContent} = placeArrow( - component, - courtBounds(), - headRect, - content, - ) + let { createdAction, newContent } = + placeArrow( + component, + courtBounds(), + headRect, + content, + ) let originNewBallState = component.ballState - if (createdAction.type == ActionKind.SHOOT) { - const targetIdx = newContent.components.findIndex(c => c.id == createdAction.toId) - newContent = dropBallOnComponent(targetIdx, newContent) + 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) + newContent = updateComponent( + { + ...(newContent.components.find( + (c) => c.id == component.id, + )! as Player | PlayerPhantom), + ballState: originNewBallState, + }, + newContent, + ) return newContent }) setPreviewAction(null) @@ -336,16 +378,54 @@ function EditorView({ /> ), info.ballState != BallState.NONE && ( - + ), ]} /> ) } + const doDeleteAction = ( + action: Action, + idx: number, + component: TacticComponent, + ) => { + setContent((content) => { + content = updateComponent( + { + ...component, + actions: component.actions.toSpliced(idx, 1), + }, + content, + ) + + 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 + }) + } + return (
@@ -353,7 +433,7 @@ function EditorView({ Home
- +
-
+
@@ -387,7 +467,7 @@ function EditorView({ ), ]) } - render={({team, key}) => ( + render={({ team, key }) => ( ( + render={({ team, key }) => ( } + courtImage={} courtRef={courtRef} previewAction={previewAction} renderComponent={(component) => { @@ -465,20 +544,14 @@ function EditorView({ key="ball" ball={component} onPosValidated={doMoveBall} - onMoves={() => - setActions((actions) => - repositionActionsRelatedTo( - component.id, - courtBounds(), - actions, - ), - ) - } onRemove={() => { setContent((content) => removeBall(content), ) - setObjects(objects => [...objects, {key: "ball"}]) + setObjects((objects) => [ + ...objects, + { key: "ball" }, + ]) }} /> ) @@ -487,60 +560,35 @@ function EditorView({ "unknown tactic component " + component, ) }} - renderAction={(action, i) => ( - { - setContent((content) => { - content = { - ...content, - actions: - content.actions.toSpliced( - i, - 1, - ), - } - - if (action.toId == null) - return content - - const target = - content.components.find( - (c) => action.toId == c.id, - )! - - if (target.type == "phantom") { - const origin = getOrigin( - target, - content.components, - ) - if (origin.id != action.fromId) { - return content - } - content = truncatePlayerPath( - origin, - target, + 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, - ) - } - - return content - }) - }} - onActionChanges={(a) => - setContent((content) => ({ - ...content, - actions: content.actions.toSpliced( - i, - 1, - a, - ), - })) - } - /> - )} + ), + ) + } + /> + )) + } />
@@ -552,25 +600,27 @@ function EditorView({ function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( - (c) => (c.type == "player" && c.ballState == BallState.HOLDS) || c.type == BALL_TYPE, + (c) => + (c.type == "player" && c.ballState == BallState.HOLDS) || + c.type == BALL_TYPE, ) != -1 ) } function renderCourtObject(courtObject: RackedCourtObject) { if (courtObject.key == "ball") { - return + return } throw new Error("unknown racked court object " + courtObject.key) } -function Court({courtType}: { courtType: string }) { +function Court({ courtType }: { courtType: string }) { return (
{courtType == "PLAIN" ? ( - + ) : ( - + )}
) diff --git a/front/views/editor/CourtAction.tsx b/front/views/editor/CourtAction.tsx index 22a4147..e4f5fa9 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -2,8 +2,11 @@ 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" export interface CourtActionProps { + origin: ComponentId action: Action onActionChanges: (a: Action) => void onActionDeleted: () => void @@ -11,6 +14,7 @@ export interface CourtActionProps { } export function CourtAction({ + origin, action, onActionChanges, onActionDeleted, @@ -39,14 +43,14 @@ export function CourtAction({ { onActionChanges({ ...action, segments: edges }) }} wavy={action.type == ActionKind.DRIBBLE} //TODO place those magic values in constants - endRadius={action.toId ? 26 : 17} + endRadius={action.target ? 26 : 17} startRadius={10} onDeleteRequested={onActionDeleted} style={{ diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 0763818..2971de9 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -26,7 +26,7 @@ CREATE TABLE Tactic name varchar NOT NULL, creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, owner integer NOT NULL, - content varchar DEFAULT '{"components": [], "actions": []}' NOT NULL, + content varchar DEFAULT '{"components": []}' NOT NULL, court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL, FOREIGN KEY (owner) REFERENCES Account ); diff --git a/src/App/Controller/EditorController.php b/src/App/Controller/EditorController.php index b6ebd6e..ec21324 100644 --- a/src/App/Controller/EditorController.php +++ b/src/App/Controller/EditorController.php @@ -42,7 +42,7 @@ class EditorController { return ViewHttpResponse::react("views/Editor.tsx", [ "id" => -1, //-1 id means that the editor will not support saves "name" => TacticModel::TACTIC_DEFAULT_NAME, - "content" => '{"components": [], "actions": []}', + "content" => '{"components": []}', "courtType" => $courtType->name(), ]); } From e97821a4faac3031f88c7c3d42288dd47645d965 Mon Sep 17 00:00:00 2001 From: maxime Date: Sun, 14 Jan 2024 18:23:22 +0100 Subject: [PATCH 4/7] stabilize phantoms, spread changes between players and phantoms if an action changes --- .eslintrc.js | 2 + front/components/TitleInput.tsx | 6 +- front/components/actions/ArrowAction.tsx | 8 +- front/components/arrows/BendableArrow.tsx | 142 +++-- front/components/editor/BasketCourt.tsx | 3 + front/components/editor/CourtPlayer.tsx | 34 +- front/editor/ActionsDomains.ts | 328 ++++++++-- front/editor/PlayerDomains.ts | 74 ++- front/editor/TacticContentDomains.ts | 154 ++--- front/model/tactic/Action.ts | 5 +- front/model/tactic/Player.ts | 6 +- front/views/Editor.tsx | 717 +++++++++++----------- front/views/editor/CourtAction.tsx | 19 +- 13 files changed, 846 insertions(+), 652 deletions(-) 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 }} /> ) From 15c75ee269571fd01abc83b5beb4407a8c54e8d0 Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 25 Jan 2024 17:49:10 +0100 Subject: [PATCH 5/7] fixes and format --- front/components/actions/ArrowAction.tsx | 8 +- front/components/arrows/BendableArrow.tsx | 114 ++-- front/components/editor/BasketCourt.tsx | 1 - front/components/editor/CourtPlayer.tsx | 34 +- front/editor/ActionsDomains.ts | 280 ++++++---- front/editor/PlayerDomains.ts | 48 +- front/editor/TacticContentDomains.ts | 45 +- front/model/tactic/Action.ts | 2 +- front/views/Editor.tsx | 649 ++++++++++++---------- front/views/editor/CourtAction.tsx | 15 +- 10 files changed, 702 insertions(+), 494 deletions(-) diff --git a/front/components/actions/ArrowAction.tsx b/front/components/actions/ArrowAction.tsx index 86e1a49..8fbae5f 100644 --- a/front/components/actions/ArrowAction.tsx +++ b/front/components/actions/ArrowAction.tsx @@ -44,15 +44,13 @@ export default function ArrowAction({ ) } -export function ScreenHead({color}: {color: string}) { +export function ScreenHead({ color }: { color: string }) { return ( -
+
) } -export function MoveToHead({color}: {color: string}) { +export function MoveToHead({ color }: { color: string }) { return ( diff --git a/front/components/arrows/BendableArrow.tsx b/front/components/arrows/BendableArrow.tsx index 5a3ac2d..7a4760b 100644 --- a/front/components/arrows/BendableArrow.tsx +++ b/front/components/arrows/BendableArrow.tsx @@ -47,14 +47,14 @@ export interface BendableArrowProps { export interface ArrowStyle { width?: number dashArray?: string - color: string, + color: string head?: () => ReactElement tail?: () => ReactElement } const ArrowStyleDefaults: ArrowStyle = { width: 3, - color: "black" + color: "black", } export interface Segment { @@ -99,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) @@ -162,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) @@ -248,8 +248,6 @@ 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 @@ -268,8 +266,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, @@ -307,15 +305,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}) => { - const svgPosRelativeToBase = {x: left, y: top} + ).map(({ start, controlPoint, end }) => { + const svgPosRelativeToBase = { x: left, y: top } const nextRelative = relativeTo( getPosWithinBase(end, parentBase), @@ -328,9 +326,9 @@ export default function BendableArrow({ const controlPointRelative = controlPoint && !forceStraight ? relativeTo( - posWithinBase(controlPoint, parentBase), - svgPosRelativeToBase, - ) + posWithinBase(controlPoint, parentBase), + svgPosRelativeToBase, + ) : middle(startRelative, nextRelative) return { @@ -341,7 +339,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 @@ -375,14 +373,22 @@ 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) - }, [area, internalSegments, startPos, forceStraight, startRadius, endRadius, wavy]) + }, [ + 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) } @@ -421,7 +427,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, @@ -448,13 +454,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( @@ -502,7 +508,7 @@ export default function BendableArrow({ return (
+ style={{ position: "absolute", top: 0, left: 0 }}> {style?.head?.call(style)}
{style?.tail?.call(style)}
@@ -611,7 +617,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, @@ -633,7 +639,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) @@ -751,14 +757,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) @@ -774,7 +780,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 }}>
) => { - if (e.key == "Delete") onRemove() - }, [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 index cbb21c2..8bb4200 100644 --- a/front/editor/ActionsDomains.ts +++ b/front/editor/ActionsDomains.ts @@ -1,11 +1,21 @@ -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"; +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, @@ -14,9 +24,7 @@ export function getActionKind( switch (ballState) { case BallState.HOLDS_ORIGIN: case BallState.HOLDS_BY_PASS: - return target - ? ActionKind.SHOOT - : ActionKind.DRIBBLE + return target ? ActionKind.SHOOT : ActionKind.DRIBBLE case BallState.PASSED_ORIGIN: case BallState.PASSED: case BallState.NONE: @@ -26,23 +34,38 @@ export function getActionKind( } } -export function getActionKindBetween(origin: Player | PlayerPhantom, target: TacticComponent | null, state: BallState): ActionKind { +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; + 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 { +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) { + if ( + origin.actions.find((a) => moves(a.type)) && + origin.ballState != BallState.HOLDS_BY_PASS + ) { return false } //Action is valid if the target is null @@ -56,23 +79,26 @@ export function isActionValid(origin: TacticComponent, target: TacticComponent | } // 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))) { + 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)) { + 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 (areInSamePath(origin, target)) return false if (alreadyHasAnAnteriorActionWith(origin, target, components)) { return false @@ -82,21 +108,25 @@ export function isActionValid(origin: TacticComponent, target: TacticComponent | return true } -function hasBoundWith(origin: TacticComponent, target: TacticComponent, components: TacticComponent[]): boolean { +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 + if (visited.indexOf(itemId) !== -1) continue visited.push(itemId) - const item = components.find(c => c.id === itemId)! + const item = components.find((c) => c.id === itemId)! - const itemBounds = item.actions.flatMap(a => typeof a.target == "string" ? [a.target] : []) + const itemBounds = item.actions.flatMap((a) => + typeof a.target == "string" ? [a.target] : [], + ) if (itemBounds.indexOf(target.id) !== -1) { return true } @@ -107,30 +137,58 @@ function hasBoundWith(origin: TacticComponent, target: TacticComponent, componen return false } -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 ?? [])] +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 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; + 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; + return false } export function createAction( @@ -143,8 +201,8 @@ export function createAction( * Creates a new phantom component. * Be aware that this function will reassign the `content` parameter. */ - function createPhantom(originState: BallState): ComponentId { - const {x, y} = ratioWithinBase(arrowHead, courtBounds) + function createPhantom(forceHasBall: boolean): ComponentId { + const { x, y } = ratioWithinBase(arrowHead, courtBounds) let itemIndex: number let originPlayer: Player @@ -177,17 +235,19 @@ export function createAction( ) 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 - } + 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", @@ -225,7 +285,7 @@ export function createAction( const action: Action = { target: toId, type: getActionKind(component, origin.ballState), - segments: [{next: toId}], + segments: [{ next: toId }], } return { @@ -241,12 +301,12 @@ export function createAction( } } - const phantomId = createPhantom(origin.ballState) + const phantomId = createPhantom(false) const action: Action = { target: phantomId, type: getActionKind(null, origin.ballState), - segments: [{next: phantomId}], + segments: [{ next: phantomId }], } return { newContent: updateComponent( @@ -279,29 +339,39 @@ export function removeAllActionsTargeting( } } - -export function removeAction(origin: TacticComponent, action: Action, actionIdx: number, content: TacticContent): TacticContent { +export function removeAction( + origin: TacticComponent, + action: Action, + actionIdx: number, + content: TacticContent, +): TacticContent { origin = { ...origin, actions: origin.actions.toSpliced(actionIdx, 1), } - content = updateComponent( - origin, - content, - ) + content = updateComponent(origin, content) if (action.target == null) return content - const target = content.components.find( - (c) => action.target == c.id, - )! + 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 ( + action.type == ActionKind.SHOOT && + (origin.type === "player" || origin.type === "phantom") + ) { if (origin.ballState === BallState.PASSED) - content = changePlayerBallState(origin, BallState.HOLDS_BY_PASS, content) + content = changePlayerBallState( + origin, + BallState.HOLDS_BY_PASS, + content, + ) else if (origin.ballState === BallState.PASSED_ORIGIN) - content = changePlayerBallState(origin, BallState.HOLDS_ORIGIN, content) + content = changePlayerBallState( + origin, + BallState.HOLDS_ORIGIN, + content, + ) if (target.type === "player" || target.type === "phantom") content = changePlayerBallState(target, BallState.NONE, content) @@ -315,16 +385,11 @@ export function removeAction(origin: TacticComponent, action: Action, actionIdx: path = getOrigin(origin, content.components).path } - if ( - path != null && - path.items.find((c) => c == target.id) - ) { + if (path != null && path.items.find((c) => c == target.id)) { content = removePlayer(target, content) } } - - return content } @@ -335,14 +400,18 @@ export function removeAction(origin: TacticComponent, action: Action, actionIdx: * @param newState * @param content */ -export function spreadNewStateFromOriginStateChange(origin: Player | PlayerPhantom, newState: BallState, content: TacticContent): TacticContent { +export function spreadNewStateFromOriginStateChange( + origin: Player | PlayerPhantom, + newState: BallState, + content: TacticContent, +): TacticContent { if (origin.ballState === newState) { return content } origin = { ...origin, - ballState: newState + ballState: newState, } content = updateComponent(origin, content) @@ -350,47 +419,72 @@ export function spreadNewStateFromOriginStateChange(origin: Player | PlayerPhant for (let i = 0; i < origin.actions.length; i++) { const action = origin.actions[i] if (typeof action.target !== "string") { - continue; + continue } - const actionTarget = content.components.find(c => action.target === c.id)! as Player | PlayerPhantom; + 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) { + 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) { + } 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 + 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) + const type = + action.type == ActionKind.SHOOT + ? ActionKind.SHOOT + : getActionKindBetween(origin, actionTarget, newState) origin = { ...origin, actions: origin.actions.toSpliced(i, 1, { ...action, - type - }) + type, + }), } content = updateComponent(origin, content) } - content = spreadNewStateFromOriginStateChange(actionTarget, targetState, 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 08f70b8..39419a2 100644 --- a/front/editor/PlayerDomains.ts +++ b/front/editor/PlayerDomains.ts @@ -1,8 +1,11 @@ -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"; +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, @@ -34,12 +37,19 @@ export function areInSamePath( * @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 { +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 + return ( + originPath.items!.indexOf(origin.id) === + originPath.items!.indexOf(other.id) - 1 + ) } export function removePlayerPath( @@ -81,8 +91,14 @@ export function removePlayer( 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) + const actionTarget = content.components.find( + (c) => c.id === action.target, + )! as Player | PlayerPhantom + return spreadNewStateFromOriginStateChange( + actionTarget, + BallState.NONE, + content, + ) } return content @@ -114,14 +130,18 @@ 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 { +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 d252a10..1c6bfda 100644 --- a/front/editor/TacticContentDomains.ts +++ b/front/editor/TacticContentDomains.ts @@ -1,17 +1,31 @@ -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" +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", @@ -32,7 +46,7 @@ export function placeObjectAt( rackedObject: RackedCourtObject, content: TacticContent, ): TacticContent { - const {x, y} = ratioWithinBase(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject @@ -69,13 +83,16 @@ export function placeObjectAt( export function dropBallOnComponent( targetedComponentIdx: number, content: TacticContent, - setAsOrigin: boolean + setAsOrigin: boolean, ): TacticContent { const component = content.components[targetedComponentIdx] - if ((component.type == 'player' || component.type == 'phantom')) { + 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 + ? component.ballState === BallState.PASSED || + component.ballState === BallState.PASSED_ORIGIN + ? BallState.PASSED_ORIGIN + : BallState.HOLDS_ORIGIN : BallState.HOLDS_BY_PASS content = changePlayerBallState(component, newState, content) @@ -117,7 +134,7 @@ export function placeBallAt( const ballIdx = content.components.findIndex((o) => o.type == "ball") - const {x, y} = ratioWithinBase(refBounds, courtBounds) + const { x, y } = ratioWithinBase(refBounds, courtBounds) const ball: Ball = { type: BALL_TYPE, @@ -227,5 +244,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 e590696..c97cdd4 100644 --- a/front/model/tactic/Action.ts +++ b/front/model/tactic/Action.ts @@ -18,4 +18,4 @@ export interface MovementAction { export function moves(kind: ActionKind): boolean { return kind != ActionKind.SHOOT -} \ No newline at end of file +} diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 90f278e..de7c749 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -14,20 +14,23 @@ 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, @@ -39,17 +42,32 @@ import { removeBall, updateComponent, } from "../editor/TacticContentDomains" -import {BallState, Player, PlayerInfo, PlayerPhantom, PlayerTeam,} from "../model/tactic/Player" -import {RackedCourtObject, RackedPlayer} 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 {createAction, getActionKind, isActionValid, removeAction} from "../editor/ActionsDomains" +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 { 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" +import { + changePlayerBallState, + getOrigin, + removePlayer, +} from "../editor/PlayerDomains" +import { CourtBall } from "../components/editor/CourtBall" +import { BASE } from "../Constants" const ERROR_STYLE: CSSProperties = { borderColor: "red", @@ -72,7 +90,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) @@ -98,7 +116,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, ) }} @@ -107,7 +125,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, ) }} @@ -117,12 +135,11 @@ export default function Editor({id, name, courtType, content}: EditorProps) { } function EditorView({ - tactic: {id, name, content: initialContent}, - onContentChange, - onNameChange, - courtType, - }: EditorViewProps) { - + tactic: { id, name, content: initialContent }, + onContentChange, + onNameChange, + courtType, +}: EditorViewProps) { const isInGuestMode = id == -1 const [titleStyle, setTitleStyle] = useState({}) @@ -150,7 +167,7 @@ function EditorView({ ) const [objects, setObjects] = useState(() => - isBallOnCourt(content) ? [] : [{key: "ball"}], + isBallOnCourt(content) ? [] : [{ key: "ball" }], ) const [previewAction, setPreviewAction] = useState( @@ -167,11 +184,14 @@ function EditorView({ })) } - const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) useEffect(() => { - setObjects(isBallOnCourt(content) ? [] : [{key: "ball"}]) - }, [setObjects, content]); + setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }]) + }, [setObjects, content]) const insertRackedPlayer = (player: Player) => { let setter @@ -183,7 +203,7 @@ function EditorView({ setter = setAllies } if (player.ballState == BallState.HOLDS_BY_PASS) { - setObjects([{key: "ball"}]) + setObjects([{ key: "ball" }]) } setter((players) => [ ...players, @@ -195,195 +215,222 @@ function EditorView({ ]) } - const doRemovePlayer = useCallback((component: Player | PlayerPhantom) => { - setContent((c) => removePlayer(component, c)) - if (component.type == "player") insertRackedPlayer(component) - }, [setContent]) + 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) => { + if (from) { + content = changePlayerBallState( + from, + BallState.NONE, + content, + ) + } - const doMoveBall = useCallback((newBounds: DOMRect, from?: Player | PlayerPhantom) => { - setContent((content) => { - if (from) { - content = changePlayerBallState(from, BallState.NONE, content) - } + content = placeBallAt(newBounds, courtBounds(), content) - content = placeBallAt( - newBounds, - courtBounds(), - content, + return content + }) + }, + [courtBounds, setContent], + ) + + const validatePlayerPosition = useCallback( + (player: Player | PlayerPhantom, 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], + ) - return content - }) - }, [courtBounds, setContent]) - - const validatePlayerPosition = useCallback((player: Player | PlayerPhantom, 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: Player | PlayerPhantom) => { - 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 + const renderAvailablePlayerActions = useCallback( + (info: PlayerInfo, player: Player | PlayerPhantom) => { + let canPlaceArrows: boolean + + if (player.type == "player") { canPlaceArrows = - phantomActions.length == 0 || - phantomActions.findIndex( - (c) => c.type != ActionKind.SHOOT, + 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 + } } - } + 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, + role: origin.role, + bottomRatio: component.bottomRatio, + rightRatio: component.rightRatio, + ballState: component.ballState, + } + } else { + info = component + } - return [ - canPlaceArrows && ( - + validatePlayerPosition(component, info, newPos) + } + onRemove={() => doRemovePlayer(component)} courtRef={courtRef} - setContent={setContent} + availableActions={() => + renderAvailablePlayerActions(info, component) + } /> - ), - (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, - role: origin.role, - bottomRatio: component.bottomRatio, - rightRatio: component.rightRatio, - ballState: component.ballState, - } - } else { - info = component - } + ) + }, + [ + content.components, + doRemovePlayer, + renderAvailablePlayerActions, + validatePlayerPosition, + ], + ) - 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( + 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"}, - ]) - }} - /> + }, + content, + ), ) - } - throw new Error( - "unknown tactic component " + component, - ) - }, [renderPlayer, doMoveBall, setContent]) + }, + [setContent], + ) - const renderActions = useCallback((component: TacticComponent) => - component.actions.map((action, i) => { - return ( - { - doDeleteAction(action, i, component) - }} - onActionChanges={(action) => - doUpdateAction(component, action, i) - } - /> - ) - }), [doDeleteAction, doUpdateAction]) + 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 (
@@ -392,34 +439,48 @@ function EditorView({ Home
- +
{ - onNameChange(new_name).then((success) => { - setTitleStyle(success ? {} : ERROR_STYLE) - }) - }, [onNameChange])} + onValidated={useCallback( + (new_name) => { + onNameChange(new_name).then((success) => { + setTitleStyle(success ? {} : ERROR_STYLE) + }) + }, + [onNameChange], + )} />
-
+
- + - overlaps(courtBounds(), div.getBoundingClientRect()) - , [courtBounds])} - onElementDetached={useCallback((r, e: RackedCourtObject) => + canDetach={useCallback( + (div) => + overlaps( + courtBounds(), + div.getBoundingClientRect(), + ), + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedCourtObject) => setContent((content) => placeObjectAt( r.getBoundingClientRect(), @@ -427,19 +488,25 @@ function EditorView({ e, content, ), - ) - , [courtBounds, setContent])} + ), + [courtBounds, setContent], + )} render={renderCourtObject} /> - +
} + courtImage={} courtRef={courtRef} previewAction={previewAction} renderComponent={renderComponent} @@ -456,23 +523,35 @@ interface PlayerRackProps { id: string objects: RackedPlayer[] setObjects: (state: RackedPlayer[]) => void - setComponents: (f: (components: TacticComponent[]) => TacticComponent[]) => void + setComponents: ( + f: (components: TacticComponent[]) => TacticComponent[], + ) => void courtRef: RefObject } -function PlayerRack({id, objects, setObjects, courtRef, setComponents}: PlayerRackProps) { - - const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) +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) => + canDetach={useCallback( + (div) => overlaps(courtBounds(), div.getBoundingClientRect()), + [courtBounds], + )} + onElementDetached={useCallback( + (r, e: RackedPlayer) => setComponents((components) => [ ...components, placePlayerAt( @@ -480,16 +559,20 @@ function PlayerRack({id, objects, setObjects, courtRef, setComponents}: PlayerRa courtBounds(), e, ), - ]) - , [courtBounds, setComponents])} - render={useCallback(({team, key}: { team: PlayerTeam, key: string }) => ( - - ), [])} + ]), + [courtBounds, setComponents], + )} + render={useCallback( + ({ team, key }: { team: PlayerTeam; key: string }) => ( + + ), + [], + )} /> ) } @@ -506,17 +589,19 @@ interface CourtPlayerArrowActionProps { } function CourtPlayerArrowAction({ - playerInfo, - player, - isInvalid, - - content, - setContent, - setPreviewAction, - courtRef - }: CourtPlayerArrowActionProps) { - - const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef]) + playerInfo, + player, + isInvalid, + + content, + setContent, + setPreviewAction, + courtRef, +}: CourtPlayerArrowActionProps) { + const courtBounds = useCallback( + () => courtRef.current!.getBoundingClientRect(), + [courtRef], + ) return ( { - (document.activeElement as HTMLElement).blur() + ;(document.activeElement as HTMLElement).blur() setPreviewAction({ origin: playerInfo.id, type: getActionKind(null, playerInfo.ballState), - target: ratioWithinBase( - headPos, - courtBounds(), - ), + target: ratioWithinBase(headPos, courtBounds()), segments: [ { next: ratioWithinBase( @@ -564,7 +642,7 @@ function CourtPlayerArrowAction({ ), }, ], - isInvalid: false + isInvalid: false, }) }} onHeadDropped={(headRect) => { @@ -574,27 +652,21 @@ function CourtPlayerArrowAction({ } setContent((content) => { - let {createdAction, newContent} = - createAction( - player, - courtBounds(), - headRect, - content, - ) + let { createdAction, newContent } = createAction( + player, + courtBounds(), + headRect, + content, + ) - if ( - createdAction.type == ActionKind.SHOOT - ) { - const targetIdx = - newContent.components.findIndex( - (c) => - c.id == - createdAction.target, - ) + if (createdAction.type == ActionKind.SHOOT) { + const targetIdx = newContent.components.findIndex( + (c) => c.id == createdAction.target, + ) newContent = dropBallOnComponent( targetIdx, newContent, - false + false, ) newContent = updateComponent( { @@ -607,7 +679,6 @@ function CourtPlayerArrowAction({ ) } - return newContent }) setPreviewAction(null) @@ -620,26 +691,28 @@ function isBallOnCourt(content: TacticContent) { return ( content.components.findIndex( (c) => - (c.type == "player" && (c.ballState === BallState.HOLDS_ORIGIN || c.ballState === BallState.HOLDS_BY_PASS)) || - c.type == BALL_TYPE, + ((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) { 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 986854d..c26c0d9 100644 --- a/front/views/editor/CourtAction.tsx +++ b/front/views/editor/CourtAction.tsx @@ -1,8 +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 { RefObject } from "react" +import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" +import { ComponentId } from "../../model/tactic/Tactic" export interface CourtActionProps { origin: ComponentId @@ -19,9 +19,8 @@ export function CourtAction({ onActionChanges, onActionDeleted, courtRef, - isInvalid + isInvalid, }: CourtActionProps) { - const color = isInvalid ? "red" : "black" let head @@ -32,7 +31,7 @@ export function CourtAction({ head = () => break case ActionKind.SCREEN: - head = () => + head = () => break } @@ -60,7 +59,7 @@ export function CourtAction({ style={{ head, dashArray, - color + color, }} /> ) From 9a6103e78a5988c6f070a4da988ab8b60411c015 Mon Sep 17 00:00:00 2001 From: maxime Date: Sat, 27 Jan 2024 21:51:05 +0100 Subject: [PATCH 6/7] fix desynchronization when pass arrows are removed --- front/editor/ActionsDomains.ts | 63 ++++++++++++++++++---------- front/editor/TacticContentDomains.ts | 12 +++--- front/views/Editor.tsx | 10 +++-- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts index 8bb4200..da988ce 100644 --- a/front/editor/ActionsDomains.ts +++ b/front/editor/ActionsDomains.ts @@ -20,17 +20,26 @@ import { BALL_TYPE } from "../model/tactic/CourtObjects" export function getActionKind( target: TacticComponent | null, ballState: BallState, -): ActionKind { +): { 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 ? ActionKind.SHOOT : ActionKind.DRIBBLE + return target + ? { kind: ActionKind.SHOOT, nextState: BallState.PASSED } + : { kind: ActionKind.DRIBBLE, nextState: ballState } case BallState.PASSED_ORIGIN: case BallState.PASSED: case BallState.NONE: - return target && target.type != BALL_TYPE - ? ActionKind.SCREEN - : ActionKind.MOVE + return { + kind: + target && target.type != BALL_TYPE + ? ActionKind.SCREEN + : ActionKind.MOVE, + nextState: ballState, + } } } @@ -38,7 +47,7 @@ export function getActionKindBetween( origin: Player | PlayerPhantom, target: TacticComponent | null, state: BallState, -): ActionKind { +): { kind: ActionKind; nextState: BallState } { //remove the target if the target is a phantom that is within the origin's path if ( target != null && @@ -63,9 +72,11 @@ export function isActionValid( // 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 + origin.ballState != BallState.HOLDS_BY_PASS && + origin.ballState != BallState.HOLDS_ORIGIN && + origin.actions.find((a) => moves(a.type)) ) { + console.log("a") return false } //Action is valid if the target is null @@ -84,6 +95,7 @@ export function isActionValid( (hasBoundWith(target, origin, components) || hasBoundWith(origin, target, components)) ) { + console.log("b") return false } @@ -92,6 +104,7 @@ export function isActionValid( origin.actions.find((a) => a.target === target?.id) || target?.actions.find((a) => a.target === origin.id) ) { + console.log("c") return false } @@ -101,6 +114,7 @@ export function isActionValid( if (areInSamePath(origin, target)) return false if (alreadyHasAnAnteriorActionWith(origin, target, components)) { + console.log("e") return false } } @@ -165,6 +179,7 @@ function alreadyHasAnAnteriorActionWith( phantom.actions.find( (a) => typeof a.target === "string" && + moves(a.type) && originOriginPath.indexOf(a.target) !== -1, ) ) { @@ -181,6 +196,7 @@ function alreadyHasAnAnteriorActionWith( phantom.actions.find( (a) => typeof a.target === "string" && + moves(a.type) && targetOriginPath.indexOf(a.target) > targetIdx, ) ) { @@ -284,7 +300,7 @@ export function createAction( const action: Action = { target: toId, - type: getActionKind(component, origin.ballState), + type: getActionKind(component, origin.ballState).kind, segments: [{ next: toId }], } @@ -305,7 +321,7 @@ export function createAction( const action: Action = { target: phantomId, - type: getActionKind(null, origin.ballState), + type: getActionKind(null, origin.ballState).kind, segments: [{ next: phantomId }], } return { @@ -360,21 +376,22 @@ export function removeAction( action.type == ActionKind.SHOOT && (origin.type === "player" || origin.type === "phantom") ) { - if (origin.ballState === BallState.PASSED) + 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) + } 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") { @@ -385,7 +402,7 @@ export function removeAction( path = getOrigin(origin, content.components).path } - if (path != null && path.items.find((c) => c == target.id)) { + if (path != null && path.items.find((c) => c === target.id)) { content = removePlayer(target, content) } } @@ -447,7 +464,7 @@ export function spreadNewStateFromOriginStateChange( ) { /// 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 + targetState = BallState.NONE // Then remove the ball for the target as well } else if ( (newState === BallState.HOLDS_BY_PASS || newState === BallState.HOLDS_ORIGIN) && @@ -464,16 +481,18 @@ export function spreadNewStateFromOriginStateChange( 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) + const { kind, nextState } = getActionKindBetween( + origin, + actionTarget, + newState, + ) origin = { ...origin, + ballState: nextState, actions: origin.actions.toSpliced(i, 1, { ...action, - type, + type: kind, }), } content = updateComponent(origin, content) diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts index 1c6bfda..4a18439 100644 --- a/front/editor/TacticContentDomains.ts +++ b/front/editor/TacticContentDomains.ts @@ -88,12 +88,12 @@ export function dropBallOnComponent( const component = content.components[targetedComponentIdx] 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 + 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) } diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index de7c749..e5ce71b 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -621,7 +621,7 @@ function CourtPlayerArrowAction({ next: ratioWithinBase(arrowHeadPos, courtBounds()), }, ], - type: getActionKind(target, playerInfo.ballState), + type: getActionKind(target, playerInfo.ballState).kind, isInvalid: !overlaps(headPos, courtBounds()) || !isActionValid(player, target, content.components), @@ -632,7 +632,7 @@ function CourtPlayerArrowAction({ setPreviewAction({ origin: playerInfo.id, - type: getActionKind(null, playerInfo.ballState), + type: getActionKind(null, playerInfo.ballState).kind, target: ratioWithinBase(headPos, courtBounds()), segments: [ { @@ -668,12 +668,16 @@ function CourtPlayerArrowAction({ 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 Player | PlayerPhantom), - ballState: BallState.PASSED, + ballState, }, newContent, ) From 1b435d4469082fb1aa687700fc9b39aa43375d17 Mon Sep 17 00:00:00 2001 From: maxime Date: Sun, 4 Feb 2024 20:19:33 +0100 Subject: [PATCH 7/7] apply suggestions --- front/editor/ActionsDomains.ts | 27 ++++++++++++++------------- front/editor/PlayerDomains.ts | 22 ++++++++++++---------- front/editor/TacticContentDomains.ts | 13 ++++--------- front/model/tactic/Player.ts | 2 ++ front/views/Editor.tsx | 16 ++++++++-------- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/front/editor/ActionsDomains.ts b/front/editor/ActionsDomains.ts index da988ce..ad0c8bd 100644 --- a/front/editor/ActionsDomains.ts +++ b/front/editor/ActionsDomains.ts @@ -1,4 +1,9 @@ -import { BallState, Player, PlayerPhantom } from "../model/tactic/Player" +import { + BallState, + Player, + PlayerPhantom, + PlayerLike, +} from "../model/tactic/Player" import { ratioWithinBase } from "../geo/Pos" import { ComponentId, @@ -44,7 +49,7 @@ export function getActionKind( } export function getActionKindBetween( - origin: Player | PlayerPhantom, + origin: PlayerLike, target: TacticComponent | null, state: BallState, ): { kind: ActionKind; nextState: BallState } { @@ -76,7 +81,6 @@ export function isActionValid( origin.ballState != BallState.HOLDS_ORIGIN && origin.actions.find((a) => moves(a.type)) ) { - console.log("a") return false } //Action is valid if the target is null @@ -95,7 +99,6 @@ export function isActionValid( (hasBoundWith(target, origin, components) || hasBoundWith(origin, target, components)) ) { - console.log("b") return false } @@ -104,7 +107,6 @@ export function isActionValid( origin.actions.find((a) => a.target === target?.id) || target?.actions.find((a) => a.target === origin.id) ) { - console.log("c") return false } @@ -114,7 +116,6 @@ export function isActionValid( if (areInSamePath(origin, target)) return false if (alreadyHasAnAnteriorActionWith(origin, target, components)) { - console.log("e") return false } } @@ -152,8 +153,8 @@ function hasBoundWith( } function alreadyHasAnAnteriorActionWith( - origin: Player | PlayerPhantom, - target: Player | PlayerPhantom, + origin: PlayerLike, + target: PlayerLike, components: TacticComponent[], ): boolean { const targetOrigin = @@ -174,7 +175,7 @@ function alreadyHasAnAnteriorActionWith( for (let i = targetIdx; i < targetOriginPath.length; i++) { const phantom = components.find( (c) => c.id === targetOriginPath[i], - )! as Player | PlayerPhantom + )! as PlayerLike if ( phantom.actions.find( (a) => @@ -191,7 +192,7 @@ function alreadyHasAnAnteriorActionWith( for (let i = 0; i <= originIdx; i++) { const phantom = components.find( (c) => c.id === originOriginPath[i], - )! as Player | PlayerPhantom + )! as PlayerLike if ( phantom.actions.find( (a) => @@ -208,7 +209,7 @@ function alreadyHasAnAnteriorActionWith( } export function createAction( - origin: Player | PlayerPhantom, + origin: PlayerLike, courtBounds: DOMRect, arrowHead: DOMRect, content: TacticContent, @@ -418,7 +419,7 @@ export function removeAction( * @param content */ export function spreadNewStateFromOriginStateChange( - origin: Player | PlayerPhantom, + origin: PlayerLike, newState: BallState, content: TacticContent, ): TacticContent { @@ -441,7 +442,7 @@ export function spreadNewStateFromOriginStateChange( const actionTarget = content.components.find( (c) => action.target === c.id, - )! as Player | PlayerPhantom + )! as PlayerLike let targetState: BallState = actionTarget.ballState let deleteAction = false diff --git a/front/editor/PlayerDomains.ts b/front/editor/PlayerDomains.ts index 39419a2..b20ca9d 100644 --- a/front/editor/PlayerDomains.ts +++ b/front/editor/PlayerDomains.ts @@ -1,4 +1,9 @@ -import { BallState, Player, PlayerPhantom } from "../model/tactic/Player" +import { + BallState, + Player, + PlayerLike, + PlayerPhantom, +} from "../model/tactic/Player" import { TacticComponent, TacticContent } from "../model/tactic/Tactic" import { removeComponent, updateComponent } from "./TacticContentDomains" import { @@ -15,10 +20,7 @@ export function getOrigin( return components.find((c) => c.id == pathItem.originPlayerId)! as Player } -export function areInSamePath( - a: Player | PlayerPhantom, - b: Player | PlayerPhantom, -) { +export function areInSamePath(a: PlayerLike, b: PlayerLike) { if (a.type === "phantom" && b.type === "phantom") { return a.originPlayerId === b.originPlayerId } @@ -38,8 +40,8 @@ export function areInSamePath( * @returns true if the `other` player is the phantom next-to the origin's path. */ export function isNextInPath( - origin: Player | PlayerPhantom, - other: Player | PlayerPhantom, + origin: PlayerLike, + other: PlayerLike, components: TacticComponent[], ): boolean { if (origin.type === "player") { @@ -74,7 +76,7 @@ export function removePlayerPath( } export function removePlayer( - player: Player | PlayerPhantom, + player: PlayerLike, content: TacticContent, ): TacticContent { content = removeAllActionsTargeting(player.id, content) @@ -93,7 +95,7 @@ export function removePlayer( } const actionTarget = content.components.find( (c) => c.id === action.target, - )! as Player | PlayerPhantom + )! as PlayerLike return spreadNewStateFromOriginStateChange( actionTarget, BallState.NONE, @@ -139,7 +141,7 @@ export function truncatePlayerPath( } export function changePlayerBallState( - player: Player | PlayerPhantom, + player: PlayerLike, newState: BallState, content: TacticContent, ): TacticContent { diff --git a/front/editor/TacticContentDomains.ts b/front/editor/TacticContentDomains.ts index 4a18439..5839bee 100644 --- a/front/editor/TacticContentDomains.ts +++ b/front/editor/TacticContentDomains.ts @@ -188,13 +188,9 @@ export function removeComponent( componentId: ComponentId, content: TacticContent, ): TacticContent { - const componentIdx = content.components.findIndex( - (c) => c.id == componentId, - ) - return { ...content, - components: content.components.toSpliced(componentIdx, 1), + components: content.components.filter((c) => c.id !== componentId), } } @@ -202,12 +198,11 @@ export function updateComponent( component: TacticComponent, content: TacticContent, ): TacticContent { - const componentIdx = content.components.findIndex( - (c) => c.id == component.id, - ) return { ...content, - components: content.components.toSpliced(componentIdx, 1, component), + components: content.components.map((c) => + c.id === component.id ? component : c, + ), } } diff --git a/front/model/tactic/Player.ts b/front/model/tactic/Player.ts index ad95b2c..a257103 100644 --- a/front/model/tactic/Player.ts +++ b/front/model/tactic/Player.ts @@ -2,6 +2,8 @@ import { Component, ComponentId } from "./Tactic" export type PlayerId = string +export type PlayerLike = Player | PlayerPhantom + export enum PlayerTeam { Allies = "allies", Opponents = "opponents", diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index e5ce71b..d65d8a6 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -46,7 +46,7 @@ import { BallState, Player, PlayerInfo, - PlayerPhantom, + PlayerLike, PlayerTeam, } from "../model/tactic/Player" import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems" @@ -216,7 +216,7 @@ function EditorView({ } const doRemovePlayer = useCallback( - (component: Player | PlayerPhantom) => { + (component: PlayerLike) => { setContent((c) => removePlayer(component, c)) if (component.type == "player") insertRackedPlayer(component) }, @@ -224,7 +224,7 @@ function EditorView({ ) const doMoveBall = useCallback( - (newBounds: DOMRect, from?: Player | PlayerPhantom) => { + (newBounds: DOMRect, from?: PlayerLike) => { setContent((content) => { if (from) { content = changePlayerBallState( @@ -243,7 +243,7 @@ function EditorView({ ) const validatePlayerPosition = useCallback( - (player: Player | PlayerPhantom, info: PlayerInfo, newPos: Pos) => { + (player: PlayerLike, info: PlayerInfo, newPos: Pos) => { setContent((content) => moveComponent( newPos, @@ -263,7 +263,7 @@ function EditorView({ ) const renderAvailablePlayerActions = useCallback( - (info: PlayerInfo, player: Player | PlayerPhantom) => { + (info: PlayerInfo, player: PlayerLike) => { let canPlaceArrows: boolean if (player.type == "player") { @@ -317,7 +317,7 @@ function EditorView({ ) const renderPlayer = useCallback( - (component: Player | PlayerPhantom) => { + (component: PlayerLike) => { let info: PlayerInfo const isPhantom = component.type == "phantom" if (isPhantom) { @@ -579,7 +579,7 @@ function PlayerRack({ interface CourtPlayerArrowActionProps { playerInfo: PlayerInfo - player: Player | PlayerPhantom + player: PlayerLike isInvalid: boolean content: TacticContent @@ -676,7 +676,7 @@ function CourtPlayerArrowAction({ { ...(newContent.components.find( (c) => c.id == player.id, - )! as Player | PlayerPhantom), + )! as PlayerLike), ballState, }, newContent,