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%;
-}