diff --git a/src/App.tsx b/src/App.tsx index c277daa..29ab83b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,7 @@ const Editor = lazy(() => import("./pages/Editor.tsx")) export default function App() { function suspense(node: ReactNode) { return ( - Loading, please wait...suspense(

}> + Loading, please wait...

}> {node}
) diff --git a/src/components/arrows/BendableArrow.tsx b/src/components/arrows/BendableArrow.tsx index 7a4760b..18aeea3 100644 --- a/src/components/arrows/BendableArrow.tsx +++ b/src/components/arrows/BendableArrow.tsx @@ -613,7 +613,7 @@ function wavyBezier( wavesPer100px: number, amplitude: number, ): string { - function getVerticalAmplification(t: number): Pos { + function getVerticalDerivativeProjectionAmplification(t: number): Pos { const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t) const velocityLength = norm(velocity) //rotate the velocity by 90 deg @@ -641,7 +641,7 @@ function wavyBezier( for (let t = step; t <= 1; ) { const pos = cubicBeziers(start, cp1, cp2, end, t) - const amplification = getVerticalAmplification(t) + const amplification = getVerticalDerivativeProjectionAmplification(t) let nextPos if (phase == 1 || phase == 3) { diff --git a/src/components/editor/BasketCourt.tsx b/src/components/editor/BasketCourt.tsx index 2213525..4cab8cf 100644 --- a/src/components/editor/BasketCourt.tsx +++ b/src/components/editor/BasketCourt.tsx @@ -1,8 +1,8 @@ -import { ReactElement, ReactNode, RefObject } from "react" -import { Action } from "../../model/tactic/Action" +import {ReactElement, ReactNode, RefObject, useLayoutEffect, useState} from "react" +import {Action} from "../../model/tactic/Action" -import { CourtAction } from "./CourtAction.tsx" -import { ComponentId, TacticComponent } from "../../model/tactic/Tactic" +import {CourtAction} from "./CourtAction.tsx" +import {ComponentId, TacticComponent} from "../../model/tactic/Tactic" export interface BasketCourtProps { components: TacticComponent[] @@ -21,24 +21,33 @@ export interface ActionPreview extends Action { } export function BasketCourt({ - components, - previewAction, + components, + previewAction, - renderComponent, - renderActions, + renderComponent, + renderActions, + + courtImage, + courtRef, + }: BasketCourtProps) { + + const [forceEmptyComponents, setForceEmptyComponents] = useState(true) + + useLayoutEffect(() => { + setForceEmptyComponents(false) + }, [setForceEmptyComponents]); + + const usedComponents = forceEmptyComponents ? [] : components - courtImage, - courtRef, -}: BasketCourtProps) { return (
+ style={{position: "relative"}}> {courtImage} - {components.map(renderComponent)} - {components.flatMap(renderActions)} + {usedComponents.map(renderComponent)} + {usedComponents.flatMap(renderActions)} {previewAction && ( {}} - onActionChanges={() => {}} + onActionDeleted={() => { + }} + onActionChanges={() => { + }} /> )}
diff --git a/src/components/editor/CourtBall.tsx b/src/components/editor/CourtBall.tsx index e1ac542..e368598 100644 --- a/src/components/editor/CourtBall.tsx +++ b/src/components/editor/CourtBall.tsx @@ -1,8 +1,8 @@ -import { useRef } from "react" +import {useRef} from "react" import Draggable from "react-draggable" -import { BallPiece } from "./BallPiece" -import { NULL_POS } from "../../geo/Pos" -import { Ball } from "../../model/tactic/CourtObjects" +import {BallPiece} from "./BallPiece" +import {NULL_POS} from "../../geo/Pos" +import {Ball} from "../../model/tactic/CourtObjects" export interface CourtBallProps { onPosValidated: (rect: DOMRect) => void @@ -10,11 +10,10 @@ export interface CourtBallProps { ball: Ball } -export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) { +export function CourtBall({onPosValidated, ball, onRemove}: CourtBallProps) { const pieceRef = useRef(null) - const x = ball.rightRatio - const y = ball.bottomRatio + const {x, y} = ball.pos return ( - + ) diff --git a/src/components/editor/CourtPlayer.tsx b/src/components/editor/CourtPlayer.tsx index 58fbc48..2f8e5cb 100644 --- a/src/components/editor/CourtPlayer.tsx +++ b/src/components/editor/CourtPlayer.tsx @@ -1,9 +1,9 @@ -import React, { ReactNode, RefObject, useCallback, 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 @@ -15,21 +15,24 @@ export interface CourtPlayerProps { availableActions: (ro: HTMLElement) => ReactNode[] } +const MOVE_AREA_SENSIBILITY = 0.001 + +export const PLAYER_RADIUS_PIXELS = 20 + /** * 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 + const {x, y} = playerInfo.pos const pieceRef = useRef(null) return ( @@ -44,7 +47,8 @@ export default function CourtPlayer({ const pos = ratioWithinBase(pieceBounds, parentBounds) - if (pos.x !== x || pos.y != y) onPositionValidated(pos) + + if (Math.abs(pos.x - x) >= MOVE_AREA_SENSIBILITY || Math.abs(pos.y - y) >= MOVE_AREA_SENSIBILITY) onPositionValidated(pos) }, [courtRef, onPositionValidated, x, y])}>
c.id == pathItem.originPlayerId)! as Player } +//FIXME this function can be a bottleneck if the phantom's position is +// following another phantom and / or the origin of the phantom is another +export function computePhantomPositioning(phantom: PlayerPhantom, + content: TacticContent, + area: DOMRect): Pos { + const positioning = phantom.pos + + // If the position is already known and fixed, return the pos + if (positioning.type === "fixed") + return positioning + + // If the position is to determine (positioning.type = "follows"), determine the phantom's pos + // by calculating it from the referent position, and the action that targets the referent. + + const components = content.components + + // Get the referent from the components + const referent: PlayerLike = getComponent(positioning.attach, components) + const referentPos = referent.type === "player" + ? referent.pos + : computePhantomPositioning(referent, content, area) + + // Get the origin + const origin = getOrigin(phantom, components) + const originPathItems = origin.path!.items + const phantomIdx = originPathItems.indexOf(phantom.id) + + const playerBeforePhantom: PlayerLike = phantomIdx == 0 ? origin : getComponent(originPathItems[phantomIdx - 1], components) + const action = playerBeforePhantom.actions.find(a => a.target === positioning.attach)! + + const segments = action.segments + const lastSegment = segments[segments.length - 1] + const lastSegmentStart = segments[segments.length - 2]?.next + const pivotPoint = lastSegment.controlPoint ?? (lastSegmentStart + ? typeof lastSegmentStart === "string" + ? document.getElementById(lastSegmentStart)!.getBoundingClientRect() + : lastSegmentStart + : playerBeforePhantom.type === "phantom" + ? computePhantomPositioning(playerBeforePhantom, content, area) + : playerBeforePhantom.pos) + + + + const segment = posWithinBase(relativeTo(referentPos, pivotPoint), area) + const segmentLength = norm(segment) + const phantomDistanceFromReferent = PLAYER_RADIUS_PIXELS //TODO Place this in constants + const segmentProjection = minus(area, { + x: (segment.x / segmentLength) * phantomDistanceFromReferent, + y: (segment.y / segmentLength) * phantomDistanceFromReferent + }) + const segmentProjectionRatio: Pos = ratioWithinBase(segmentProjection, area) + + return add(referentPos, segmentProjectionRatio) +} + +export function getComponent(id: string, components: TacticComponent[]): T { + return components.find(c => c.id === id)! as T +} + export function areInSamePath(a: PlayerLike, b: PlayerLike) { if (a.type === "phantom" && b.type === "phantom") { return a.originPlayerId === b.originPlayerId @@ -132,9 +185,9 @@ export function truncatePlayerPath( truncateStartIdx == 0 ? null : { - ...path, - items: path.items.toSpliced(truncateStartIdx), - }, + ...path, + items: path.items.toSpliced(truncateStartIdx), + }, }, content, ) diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 5839bee..a8d02d3 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -1,39 +1,25 @@ -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, PlayerLike, 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, getComponent, getOrigin} from "./PlayerDomains" +import {ActionKind} from "../model/tactic/Action.ts"; export function placePlayerAt( refBounds: DOMRect, courtBounds: DOMRect, element: RackedPlayer, ): Player { - const { x, y } = ratioWithinBase(refBounds, courtBounds) + const pos = ratioWithinBase(refBounds, courtBounds) return { type: "player", id: "player-" + element.key + "-" + element.team, team: element.team, role: element.key, - rightRatio: x, - bottomRatio: y, + pos, ballState: BallState.NONE, path: null, actions: [], @@ -46,7 +32,7 @@ export function placeObjectAt( rackedObject: RackedCourtObject, content: TacticContent, ): TacticContent { - const { x, y } = ratioWithinBase(refBounds, courtBounds) + const pos = ratioWithinBase(refBounds, courtBounds) let courtObject: CourtObject @@ -64,8 +50,7 @@ export function placeObjectAt( courtObject = { type: BALL_TYPE, id: BALL_ID, - rightRatio: x, - bottomRatio: y, + pos, actions: [], } break @@ -134,13 +119,12 @@ export function placeBallAt( const ballIdx = content.components.findIndex((o) => o.type == "ball") - const { x, y } = ratioWithinBase(refBounds, courtBounds) + const pos = ratioWithinBase(refBounds, courtBounds) const ball: Ball = { type: BALL_TYPE, id: BALL_ID, - rightRatio: x, - bottomRatio: y, + pos, actions: [], } @@ -174,14 +158,44 @@ export function moveComponent( if (!overlaps(playerBounds, courtBounds)) { return removed(content) } - return updateComponent( - { + + const isPhantom = component.type === "phantom" + + if (isPhantom && component.pos.type === "follows") { + const referent = component.pos.attach + const origin = getOrigin(component, content.components) + const originPathItems = origin.path!.items + const phantomIdx = originPathItems.indexOf(component.id) + + const playerBeforePhantom: PlayerLike = phantomIdx == 0 ? origin : getComponent(originPathItems[phantomIdx - 1], content.components) + // detach the action from the screen target and transform it to a regular move action to the phantom. + content = updateComponent({ + ...playerBeforePhantom, + actions: playerBeforePhantom.actions.map(a => a.target === referent ? { + ...a, + segments: a.segments.toSpliced(a.segments.length - 2, 1, { + ...a.segments[a.segments.length - 1], + next: component.id, + }), + target: component.id, + type: ActionKind.MOVE + } : a), + }, content) + } + + content = updateComponent( + { ...component, - rightRatio: newPos.x, - bottomRatio: newPos.y, + pos: isPhantom + ? { + type: "fixed", + ...newPos + } + : newPos }, content, ) + return content } export function removeComponent( @@ -239,5 +253,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/src/model/tactic/Action.ts b/src/model/tactic/Action.ts index c97cdd4..b2dca4f 100644 --- a/src/model/tactic/Action.ts +++ b/src/model/tactic/Action.ts @@ -9,9 +9,10 @@ export enum ActionKind { SHOOT = "SHOOT", } -export type Action = { type: ActionKind } & MovementAction +export type Action = MovementAction export interface MovementAction { + type: ActionKind target: ComponentId | Pos segments: Segment[] } diff --git a/src/model/tactic/CourtObjects.ts b/src/model/tactic/CourtObjects.ts index 96cde26..5f72199 100644 --- a/src/model/tactic/CourtObjects.ts +++ b/src/model/tactic/CourtObjects.ts @@ -1,4 +1,5 @@ import { Component } from "./Tactic" +import { Pos } from "../../geo/Pos.ts" export const BALL_ID = "ball" export const BALL_TYPE = "ball" @@ -6,4 +7,4 @@ export const BALL_TYPE = "ball" //place here all different kinds of objects export type CourtObject = Ball -export type Ball = Component +export type Ball = Component diff --git a/src/model/tactic/Player.ts b/src/model/tactic/Player.ts index a257103..ab4d116 100644 --- a/src/model/tactic/Player.ts +++ b/src/model/tactic/Player.ts @@ -1,4 +1,5 @@ import { Component, ComponentId } from "./Tactic" +import { Pos } from "../../geo/Pos.ts" export type PlayerId = string @@ -9,7 +10,7 @@ export enum PlayerTeam { Opponents = "opponents", } -export interface Player extends PlayerInfo, Component<"player"> { +export interface Player extends PlayerInfo, Component<"player", Pos> { readonly id: PlayerId } @@ -33,15 +34,7 @@ export interface PlayerInfo { */ 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 + readonly pos: Pos, } export enum BallState { @@ -52,7 +45,7 @@ export enum BallState { PASSED_ORIGIN, } -export interface Player extends Component<"player">, PlayerInfo { +export interface Player extends Component<"player", Pos>, PlayerInfo { /** * True if the player has a basketball */ @@ -65,11 +58,32 @@ export interface MovementPath { readonly items: ComponentId[] } +/** + * The position of the phantom is known and fixed + */ +export type FixedPhantomPositioning = ({ type: "fixed" } & Pos) +/** + * The position of the phantom is constrained to a given component. + * The actual position of the phantom is to determine given its environment. + */ +export type FollowsPhantomPositioning = { type: "follows", attach: ComponentId } + +/** + * Defines the different kind of positioning a phantom can have + */ +export type PhantomPositioning = FixedPhantomPositioning | FollowsPhantomPositioning + + /** * 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"> { +export interface PlayerPhantom extends Component<"phantom", PhantomPositioning> { readonly originPlayerId: ComponentId readonly ballState: BallState + + /** + * Defines a component this phantom will be attached to. + */ + readonly attachedTo?: ComponentId } diff --git a/src/model/tactic/Tactic.ts b/src/model/tactic/Tactic.ts index acce6f0..b22473f 100644 --- a/src/model/tactic/Tactic.ts +++ b/src/model/tactic/Tactic.ts @@ -18,7 +18,7 @@ export interface TacticContent { export type TacticComponent = Player | CourtObject | PlayerPhantom export type ComponentId = string -export interface Component { +export interface Component { /** * The component's type */ @@ -27,15 +27,8 @@ export interface Component { * The component's identifier */ readonly id: ComponentId - /** - * Percentage of the component's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) - */ - readonly bottomRatio: number - /** - * Percentage of the component's position to the right (0 means left, 1 means right, 0.5 means middle) - */ - readonly rightRatio: number + readonly pos: Positioning, readonly actions: Action[] } diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 7f2b797..f8d6d97 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -67,7 +67,7 @@ import { middlePos, Pos, ratioWithinBase } from "../geo/Pos" import { Action, ActionKind } from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" import { - changePlayerBallState, + changePlayerBallState, computePhantomPositioning, getOrigin, removePlayer, } from "../editor/PlayerDomains" @@ -329,7 +329,7 @@ function EditorView({ content, (content) => { - if (player.type == "player") insertRackedPlayer(player) + if (player.type === "player") insertRackedPlayer(player) return removePlayer(player, content) }, ), @@ -402,8 +402,7 @@ function EditorView({ id: component.id, team: origin.team, role: origin.role, - bottomRatio: component.bottomRatio, - rightRatio: component.rightRatio, + pos: computePhantomPositioning(component, content, courtBounds()), ballState: component.ballState, } } else { diff --git a/src/style/steps_tree.css b/src/style/steps_tree.css deleted file mode 100644 index eadeaf6..0000000 --- a/src/style/steps_tree.css +++ /dev/null @@ -1,87 +0,0 @@ -.step-piece { - position: relative; - font-family: monospace; - pointer-events: all; - - background-color: var(--editor-tree-step-piece); - color: var(--selected-team-secondarycolor); - - border-radius: 100px; - - width: 20px; - height: 20px; - - display: flex; - - align-items: center; - justify-content: center; - - user-select: none; - cursor: pointer; - - border: 2px solid var(--editor-tree-background); -} - -.step-piece-selected { - border: 2px solid var(--selection-color-light); -} - -.step-piece-selected, -.step-piece:focus, -.step-piece:hover { - background-color: var(--editor-tree-step-piece-hovered); -} - -.step-piece-actions { - display: none; - position: absolute; - column-gap: 5px; - top: -140%; -} - -.step-piece-selected .step-piece-actions { - display: flex; -} - -.add-icon, -.remove-icon { - background-color: white; - border-radius: 100%; -} - -.add-icon { - fill: var(--add-icon-fill); -} - -.remove-icon { - fill: var(--remove-icon-fill); -} - -.step-children { - margin-top: 10vh; - display: flex; - flex-direction: row; - width: 100%; - height: 100%; -} - -.step-group { - position: relative; - - display: flex; - flex-direction: column; - align-items: center; - - width: 100%; - height: 100%; -} - -.steps-tree { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding-top: 10%; - - height: 100%; -}