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..4f4c216 100644 --- a/src/components/editor/BasketCourt.tsx +++ b/src/components/editor/BasketCourt.tsx @@ -37,8 +37,8 @@ export function BasketCourt({ style={{ position: "relative" }}> {courtImage} - {components.map(renderComponent)} - {components.flatMap(renderActions)} + {courtRef.current && components.map(renderComponent)} + {courtRef.current && components.flatMap(renderActions)} {previewAction && ( (null) - const x = ball.rightRatio - const y = ball.bottomRatio + const { x, y } = ball.pos return ( 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 * */ @@ -28,8 +32,7 @@ export default function CourtPlayer({ 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,11 @@ 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 === targetOriginPath[i], - )! as PlayerLike + const component = getComponent(targetOriginPath[i], components) if ( - phantom.actions.find( + component.actions.find( (a) => typeof a.target === "string" && moves(a.type) && @@ -218,7 +218,10 @@ export function createAction( * Creates a new phantom component. * Be aware that this function will reassign the `content` parameter. */ - function createPhantom(forceHasBall: boolean): ComponentId { + function createPhantom( + forceHasBall: boolean, + attachedTo?: ComponentId, + ): ComponentId { const { x, y } = ratioWithinBase(arrowHead, courtBounds) let itemIndex: number @@ -269,8 +272,16 @@ export function createAction( const phantom: PlayerPhantom = { type: "phantom", id: phantomId, - rightRatio: x, - bottomRatio: y, + pos: attachedTo + ? { + type: "follows", + attach: attachedTo, + } + : { + type: "fixed", + x, + y, + }, originPlayerId: originPlayer.id, ballState: phantomState, actions: [], @@ -299,10 +310,24 @@ export function createAction( content = removeBall(content) } - const action: Action = { - target: toId, - type: getActionKind(component, origin.ballState).kind, - segments: [{ next: toId }], + const actionKind = getActionKind(component, origin.ballState).kind + + let action: Action + + if (actionKind === ActionKind.SCREEN) { + createPhantom(false, toId) + + action = { + target: toId, + type: actionKind, + segments: [{ next: toId }], + } + } else { + action = { + target: toId, + type: actionKind, + segments: [{ next: toId }], + } } return { @@ -318,11 +343,18 @@ export function createAction( } } + const actionKind = getActionKind(null, origin.ballState).kind + + if (actionKind === ActionKind.SCREEN) + throw new Error( + "Attempted to create a screen action with nothing targeted", + ) + const phantomId = createPhantom(false) const action: Action = { target: phantomId, - type: getActionKind(null, origin.ballState).kind, + type: actionKind, segments: [{ next: phantomId }], } return { @@ -358,10 +390,10 @@ export function removeAllActionsTargeting( export function removeAction( origin: TacticComponent, - action: Action, actionIdx: number, content: TacticContent, ): TacticContent { + const action = origin.actions[actionIdx] origin = { ...origin, actions: origin.actions.toSpliced(actionIdx, 1), @@ -408,6 +440,15 @@ export function removeAction( } } + // if the action type is a screen over a player, remove the phantom bound to the target + if ( + action.type === ActionKind.SCREEN && + (origin.type === "phantom" || origin.type === "player") + ) { + const screenPhantom = getPlayerNextTo(origin, 1, content.components)! + content = removePlayer(screenPhantom, content) + } + return content } @@ -440,9 +481,10 @@ export function spreadNewStateFromOriginStateChange( continue } - const actionTarget = content.components.find( - (c) => action.target === c.id, - )! as PlayerLike + const actionTarget: PlayerLike = getComponent( + action.target, + content.components, + ) let targetState: BallState = actionTarget.ballState let deleteAction = false @@ -472,13 +514,23 @@ export function spreadNewStateFromOriginStateChange( action.type === ActionKind.SCREEN ) { targetState = BallState.HOLDS_BY_PASS + const screenPhantom = getPlayerNextTo( + origin, + 1, + content.components, + )! + if ( + screenPhantom.type === "phantom" && + screenPhantom.pos.type === "follows" + ) { + content = removePlayer(screenPhantom, content) + origin = getComponent(origin.id, content.components) + } } if (deleteAction) { - content = removeAction(origin, action, i, content) - origin = content.components.find((c) => c.id === origin.id)! as - | Player - | PlayerPhantom + content = removeAction(origin, i, content) + origin = getComponent(origin.id, content.components) i-- // step back } else { // do not change the action type if it is a shoot action diff --git a/src/editor/PlayerDomains.ts b/src/editor/PlayerDomains.ts index b20ca9d..0473d72 100644 --- a/src/editor/PlayerDomains.ts +++ b/src/editor/PlayerDomains.ts @@ -4,13 +4,27 @@ import { PlayerLike, PlayerPhantom, } from "../model/tactic/Player" -import { TacticComponent, TacticContent } from "../model/tactic/Tactic" +import { + ComponentId, + TacticComponent, + TacticContent, +} from "../model/tactic/Tactic" import { removeComponent, updateComponent } from "./TacticContentDomains" import { removeAllActionsTargeting, spreadNewStateFromOriginStateChange, } from "./ActionsDomains" import { ActionKind } from "../model/tactic/Action" +import { + add, + minus, + norm, + Pos, + posWithinBase, + ratioWithinBase, + relativeTo, +} from "../geo/Pos.ts" +import { PLAYER_RADIUS_PIXELS } from "../components/editor/CourtPlayer.tsx" export function getOrigin( pathItem: PlayerPhantom, @@ -20,6 +34,107 @@ export function getOrigin( return components.find((c) => c.id == pathItem.originPlayerId)! as Player } +export function getPlayerNextTo( + player: PlayerLike, + n: number, + components: TacticComponent[], +): PlayerLike | undefined { + const playerOrigin = + player.type === "player" ? player : getOrigin(player, components) + const pathItems = playerOrigin.path!.items + + // add one as there is a shifting because a Player is never at the head of its own path + const idx = pathItems.indexOf(player.id) + 1 // is 0 if the player is the origin + + const targetIdx = idx + n + + // remove the screen phantom + return targetIdx == 0 + ? playerOrigin + : getComponent(pathItems[targetIdx - 1], components) +} + +//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 + let pivotPoint = lastSegment.controlPoint + + if (!pivotPoint) { + if (lastSegmentStart) { + pivotPoint = + typeof lastSegmentStart === "string" + ? document + .getElementById(lastSegmentStart)! + .getBoundingClientRect() + : lastSegmentStart + } else { + pivotPoint = + 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 @@ -54,7 +169,7 @@ export function isNextInPath( ) } -export function removePlayerPath( +export function clearPlayerPath( player: Player, content: TacticContent, ): TacticContent { @@ -75,18 +190,60 @@ export function removePlayerPath( ) } +function removeAllPhantomsAttached( + to: ComponentId, + content: TacticContent, +): TacticContent { + let i = 0 + while (i < content.components.length) { + const component = content.components[i] + if (component.type === "phantom") { + if ( + component.pos.type === "follows" && + component.pos.attach === to + ) { + content = removePlayer(component, content) + continue + } + } + i++ + } + return content +} + export function removePlayer( player: PlayerLike, content: TacticContent, ): TacticContent { content = removeAllActionsTargeting(player.id, content) + content = removeAllPhantomsAttached(player.id, content) + + if (player.type === "phantom") { + const pos = player.pos + // if the phantom was attached to another player, remove the action that symbolizes the attachment + if (pos.type === "follows") { + const playerBefore = getPlayerNextTo( + player, + -1, + content.components, + )! + const actions = playerBefore.actions.filter( + (a) => a.target === pos.attach, + ) + content = updateComponent( + { + ...playerBefore, + actions, + }, + content, + ) + } - if (player.type == "phantom") { const origin = getOrigin(player, content.components) return truncatePlayerPath(origin, player, content) } - content = removePlayerPath(player, content) + content = clearPlayerPath(player, content) content = removeComponent(player.id, content) for (const action of player.actions) { diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 5839bee..1f3a9cc 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -3,6 +3,7 @@ import { BallState, Player, PlayerInfo, + PlayerLike, PlayerTeam, } from "../model/tactic/Player" import { @@ -18,22 +19,22 @@ import { } from "../model/tactic/Tactic" import { overlaps } from "../geo/Box" import { RackedCourtObject, RackedPlayer } from "./RackedItems" -import { changePlayerBallState } from "./PlayerDomains" +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 +47,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 +65,7 @@ export function placeObjectAt( courtObject = { type: BALL_TYPE, id: BALL_ID, - rightRatio: x, - bottomRatio: y, + pos, actions: [], } break @@ -134,13 +134,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 +173,61 @@ 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( 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..2dee897 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,10 +10,6 @@ export enum PlayerTeam { Opponents = "opponents", } -export interface Player extends PlayerInfo, Component<"player"> { - readonly id: PlayerId -} - /** * All information about a player */ @@ -33,15 +30,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 +41,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 +54,34 @@ 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..0ad312c 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..83c4dac 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -68,6 +68,7 @@ import { Action, ActionKind } from "../model/tactic/Action" import BallAction from "../components/actions/BallAction" import { changePlayerBallState, + computePhantomPositioning, getOrigin, removePlayer, } from "../editor/PlayerDomains" @@ -329,7 +330,7 @@ function EditorView({ content, (content) => { - if (player.type == "player") insertRackedPlayer(player) + if (player.type === "player") insertRackedPlayer(player) return removePlayer(player, content) }, ), @@ -402,8 +403,11 @@ 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 { @@ -435,8 +439,8 @@ function EditorView({ ) const doDeleteAction = useCallback( - (action: Action, idx: number, origin: TacticComponent) => { - setContent((content) => removeAction(origin, action, idx, content)) + (_: Action, idx: number, origin: TacticComponent) => { + setContent((content) => removeAction(origin, idx, content)) }, [setContent], ) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 2a6bb64..faba0d7 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -57,10 +57,10 @@ export default function HomePage() { } function Home({ - lastTactics, - allTactics, - teams, - }: { + lastTactics, + allTactics, + teams, +}: { lastTactics: Tactic[] allTactics: Tactic[] teams: Team[] @@ -77,10 +77,10 @@ function Home({ } function Body({ - lastTactics, - allTactics, - teams, - }: { + lastTactics, + allTactics, + teams, +}: { lastTactics: Tactic[] allTactics: Tactic[] teams: Team[] @@ -100,10 +100,10 @@ function Body({ } function SideMenu({ - width, - lastTactics, - teams, - }: { + width, + lastTactics, + teams, +}: { width: number lastTactics: Tactic[] teams: Team[] @@ -123,9 +123,9 @@ function SideMenu({ } function PersonalSpace({ - width, - allTactics, - }: { + width, + allTactics, +}: { width: number allTactics: Tactic[] }) { @@ -198,17 +198,15 @@ function TableData({ allTactics }: { allTactics: Tactic[] }) { function BodyPersonalSpace({ allTactics }: { allTactics: Tactic[] }) { return (
- { - allTactics.length == 0 - ?

Aucune tactique créée !

- : - - + {allTactics.length == 0 ? ( +

Aucune tactique créée !

+ ) : ( +
+ - -
- } - + + + )}
) } 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%; -}