add phantoms for screen actions

maxime 1 year ago committed by maxime.batista
parent 6df90bb3aa
commit b9bd18c331

@ -17,7 +17,7 @@ const Editor = lazy(() => import("./pages/Editor.tsx"))
export default function App() { export default function App() {
function suspense(node: ReactNode) { function suspense(node: ReactNode) {
return ( return (
<Suspense fallback={<p>Loading, please wait...suspense(</p>}> <Suspense fallback={<p>Loading, please wait...</p>}>
{node} {node}
</Suspense> </Suspense>
) )

@ -613,7 +613,7 @@ function wavyBezier(
wavesPer100px: number, wavesPer100px: number,
amplitude: number, amplitude: number,
): string { ): string {
function getVerticalAmplification(t: number): Pos { function getVerticalDerivativeProjectionAmplification(t: number): Pos {
const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t) const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t)
const velocityLength = norm(velocity) const velocityLength = norm(velocity)
//rotate the velocity by 90 deg //rotate the velocity by 90 deg
@ -641,7 +641,7 @@ function wavyBezier(
for (let t = step; t <= 1; ) { for (let t = step; t <= 1; ) {
const pos = cubicBeziers(start, cp1, cp2, end, t) const pos = cubicBeziers(start, cp1, cp2, end, t)
const amplification = getVerticalAmplification(t) const amplification = getVerticalDerivativeProjectionAmplification(t)
let nextPos let nextPos
if (phase == 1 || phase == 3) { if (phase == 1 || phase == 3) {

@ -1,8 +1,8 @@
import { ReactElement, ReactNode, RefObject } from "react" import {ReactElement, ReactNode, RefObject, useLayoutEffect, useState} from "react"
import { Action } from "../../model/tactic/Action" import {Action} from "../../model/tactic/Action"
import { CourtAction } from "./CourtAction.tsx" import {CourtAction} from "./CourtAction.tsx"
import { ComponentId, TacticComponent } from "../../model/tactic/Tactic" import {ComponentId, TacticComponent} from "../../model/tactic/Tactic"
export interface BasketCourtProps { export interface BasketCourtProps {
components: TacticComponent[] components: TacticComponent[]
@ -21,24 +21,33 @@ export interface ActionPreview extends Action {
} }
export function BasketCourt({ export function BasketCourt({
components, components,
previewAction, previewAction,
renderComponent, renderComponent,
renderActions, renderActions,
courtImage,
courtRef,
}: BasketCourtProps) {
const [forceEmptyComponents, setForceEmptyComponents] = useState(true)
useLayoutEffect(() => {
setForceEmptyComponents(false)
}, [setForceEmptyComponents]);
const usedComponents = forceEmptyComponents ? [] : components
courtImage,
courtRef,
}: BasketCourtProps) {
return ( return (
<div <div
className="court-container" className="court-container"
ref={courtRef} ref={courtRef}
style={{ position: "relative" }}> style={{position: "relative"}}>
{courtImage} {courtImage}
{components.map(renderComponent)} {usedComponents.map(renderComponent)}
{components.flatMap(renderActions)} {usedComponents.flatMap(renderActions)}
{previewAction && ( {previewAction && (
<CourtAction <CourtAction
@ -47,8 +56,10 @@ export function BasketCourt({
origin={previewAction.origin} origin={previewAction.origin}
isInvalid={previewAction.isInvalid} isInvalid={previewAction.isInvalid}
//do nothing on interacted, not really possible as it's a preview arrow //do nothing on interacted, not really possible as it's a preview arrow
onActionDeleted={() => {}} onActionDeleted={() => {
onActionChanges={() => {}} }}
onActionChanges={() => {
}}
/> />
)} )}
</div> </div>

@ -1,8 +1,8 @@
import { useRef } from "react" import {useRef} from "react"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { BallPiece } from "./BallPiece" import {BallPiece} from "./BallPiece"
import { NULL_POS } from "../../geo/Pos" import {NULL_POS} from "../../geo/Pos"
import { Ball } from "../../model/tactic/CourtObjects" import {Ball} from "../../model/tactic/CourtObjects"
export interface CourtBallProps { export interface CourtBallProps {
onPosValidated: (rect: DOMRect) => void onPosValidated: (rect: DOMRect) => void
@ -10,11 +10,10 @@ export interface CourtBallProps {
ball: Ball ball: Ball
} }
export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) { export function CourtBall({onPosValidated, ball, onRemove}: CourtBallProps) {
const pieceRef = useRef<HTMLDivElement>(null) const pieceRef = useRef<HTMLDivElement>(null)
const x = ball.rightRatio const {x, y} = ball.pos
const y = ball.bottomRatio
return ( return (
<Draggable <Draggable
@ -35,7 +34,7 @@ export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) {
left: `${x * 100}%`, left: `${x * 100}%`,
top: `${y * 100}%`, top: `${y * 100}%`,
}}> }}>
<BallPiece /> <BallPiece/>
</div> </div>
</Draggable> </Draggable>
) )

@ -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 "../../style/player.css"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece" import {PlayerPiece} from "./PlayerPiece"
import { BallState, PlayerInfo } from "../../model/tactic/Player" import {BallState, PlayerInfo} from "../../model/tactic/Player"
import { NULL_POS, Pos, ratioWithinBase } from "../../geo/Pos" import {NULL_POS, Pos, ratioWithinBase} from "../../geo/Pos"
export interface CourtPlayerProps { export interface CourtPlayerProps {
playerInfo: PlayerInfo playerInfo: PlayerInfo
@ -15,21 +15,24 @@ export interface CourtPlayerProps {
availableActions: (ro: HTMLElement) => ReactNode[] 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 * A player that is placed on the court, which can be selected, and moved in the associated bounds
* */ * */
export default function CourtPlayer({ export default function CourtPlayer({
playerInfo, playerInfo,
className, className,
onPositionValidated, onPositionValidated,
onRemove, onRemove,
courtRef, courtRef,
availableActions, availableActions,
}: CourtPlayerProps) { }: CourtPlayerProps) {
const usesBall = playerInfo.ballState != BallState.NONE const usesBall = playerInfo.ballState != BallState.NONE
const x = playerInfo.rightRatio const {x, y} = playerInfo.pos
const y = playerInfo.bottomRatio
const pieceRef = useRef<HTMLDivElement>(null) const pieceRef = useRef<HTMLDivElement>(null)
return ( return (
@ -44,7 +47,8 @@ export default function CourtPlayer({
const pos = ratioWithinBase(pieceBounds, parentBounds) 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])}> }, [courtRef, onPositionValidated, x, y])}>
<div <div
id={playerInfo.id} id={playerInfo.id}

@ -1,26 +1,18 @@
import { import {BallState, Player, PlayerLike, PlayerPhantom} from "../model/tactic/Player"
BallState, import {ratioWithinBase} from "../geo/Pos"
Player, import {ComponentId, TacticComponent, TacticContent} from "../model/tactic/Tactic"
PlayerPhantom, import {overlaps} from "../geo/Box"
PlayerLike, import {Action, ActionKind, moves} from "../model/tactic/Action"
} from "../model/tactic/Player" import {removeBall, updateComponent} from "./TacticContentDomains"
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 { import {
areInSamePath, areInSamePath,
changePlayerBallState, changePlayerBallState,
getComponent,
getOrigin, getOrigin,
isNextInPath, isNextInPath,
removePlayer, removePlayer
} from "./PlayerDomains" } from "./PlayerDomains"
import { BALL_TYPE } from "../model/tactic/CourtObjects" import {BALL_TYPE} from "../model/tactic/CourtObjects"
export function getActionKind( export function getActionKind(
target: TacticComponent | null, target: TacticComponent | null,
@ -29,12 +21,12 @@ export function getActionKind(
switch (ballState) { switch (ballState) {
case BallState.HOLDS_ORIGIN: case BallState.HOLDS_ORIGIN:
return target return target
? { kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN } ? {kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN}
: { kind: ActionKind.DRIBBLE, nextState: ballState } : {kind: ActionKind.DRIBBLE, nextState: ballState}
case BallState.HOLDS_BY_PASS: case BallState.HOLDS_BY_PASS:
return target return target
? { kind: ActionKind.SHOOT, nextState: BallState.PASSED } ? {kind: ActionKind.SHOOT, nextState: BallState.PASSED}
: { kind: ActionKind.DRIBBLE, nextState: ballState } : {kind: ActionKind.DRIBBLE, nextState: ballState}
case BallState.PASSED_ORIGIN: case BallState.PASSED_ORIGIN:
case BallState.PASSED: case BallState.PASSED:
case BallState.NONE: case BallState.NONE:
@ -218,8 +210,8 @@ export function createAction(
* Creates a new phantom component. * Creates a new phantom component.
* Be aware that this function will reassign the `content` parameter. * 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) const {x, y} = ratioWithinBase(arrowHead, courtBounds)
let itemIndex: number let itemIndex: number
let originPlayer: Player let originPlayer: Player
@ -269,8 +261,16 @@ export function createAction(
const phantom: PlayerPhantom = { const phantom: PlayerPhantom = {
type: "phantom", type: "phantom",
id: phantomId, id: phantomId,
rightRatio: x, pos: attachedTo
bottomRatio: y, ? {
type: "follows",
attach: attachedTo
}
: {
type: "fixed",
x,
y
},
originPlayerId: originPlayer.id, originPlayerId: originPlayer.id,
ballState: phantomState, ballState: phantomState,
actions: [], actions: [],
@ -299,12 +299,27 @@ export function createAction(
content = removeBall(content) content = removeBall(content)
} }
const action: Action = { const actionKind = getActionKind(component, origin.ballState).kind
target: toId,
type: getActionKind(component, origin.ballState).kind, let action: Action
segments: [{ next: toId }],
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 { return {
newContent: updateComponent( newContent: updateComponent(
{ {
@ -318,12 +333,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 phantomId = createPhantom(false)
const action: Action = { const action: Action = {
target: phantomId, target: phantomId,
type: getActionKind(null, origin.ballState).kind, type: actionKind,
segments: [{ next: phantomId }], segments: [{next: phantomId}],
} }
return { return {
newContent: updateComponent( newContent: updateComponent(
@ -408,6 +429,16 @@ 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 playerOrigin = origin.type === "phantom" ? getOrigin(origin, content.components) : origin
const pathItems = playerOrigin.path?.items!
const originIdx = pathItems.indexOf(origin.id)
// remove the screen phantom
const phantomId = pathItems.at(originIdx + 1)!
content = removePlayer(getComponent(phantomId, content.components), content)
}
return content return content
} }
@ -482,7 +513,7 @@ export function spreadNewStateFromOriginStateChange(
i-- // step back i-- // step back
} else { } else {
// do not change the action type if it is a shoot action // do not change the action type if it is a shoot action
const { kind, nextState } = getActionKindBetween( const {kind, nextState} = getActionKindBetween(
origin, origin,
actionTarget, actionTarget,
newState, newState,

@ -1,16 +1,10 @@
import { import {BallState, Player, PlayerLike, PlayerPhantom,} from "../model/tactic/Player"
BallState, import {TacticComponent, TacticContent} from "../model/tactic/Tactic"
Player, import {removeComponent, updateComponent} from "./TacticContentDomains"
PlayerLike, import {removeAllActionsTargeting, spreadNewStateFromOriginStateChange,} from "./ActionsDomains"
PlayerPhantom, import {ActionKind} from "../model/tactic/Action"
} from "../model/tactic/Player" import {add, minus, norm, Pos, posWithinBase, ratioWithinBase, relativeTo} from "../geo/Pos.ts"
import { TacticComponent, TacticContent } from "../model/tactic/Tactic" import {PLAYER_RADIUS_PIXELS} from "../components/editor/CourtPlayer.tsx";
import { removeComponent, updateComponent } from "./TacticContentDomains"
import {
removeAllActionsTargeting,
spreadNewStateFromOriginStateChange,
} from "./ActionsDomains"
import { ActionKind } from "../model/tactic/Action"
export function getOrigin( export function getOrigin(
pathItem: PlayerPhantom, pathItem: PlayerPhantom,
@ -20,6 +14,65 @@ export function getOrigin(
return components.find((c) => c.id == pathItem.originPlayerId)! as Player return components.find((c) => 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<T extends TacticComponent>(id: string, components: TacticComponent[]): T {
return components.find(c => c.id === id)! as T
}
export function areInSamePath(a: PlayerLike, b: PlayerLike) { export function areInSamePath(a: PlayerLike, b: PlayerLike) {
if (a.type === "phantom" && b.type === "phantom") { if (a.type === "phantom" && b.type === "phantom") {
return a.originPlayerId === b.originPlayerId return a.originPlayerId === b.originPlayerId
@ -132,9 +185,9 @@ export function truncatePlayerPath(
truncateStartIdx == 0 truncateStartIdx == 0
? null ? null
: { : {
...path, ...path,
items: path.items.toSpliced(truncateStartIdx), items: path.items.toSpliced(truncateStartIdx),
}, },
}, },
content, content,
) )

@ -1,243 +0,0 @@
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)
return {
type: "player",
id: "player-" + element.key + "-" + element.team,
team: element.team,
role: element.key,
rightRatio: x,
bottomRatio: y,
ballState: BallState.NONE,
path: null,
actions: [],
}
}
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, true)
}
courtObject = {
type: BALL_TYPE,
id: BALL_ID,
rightRatio: x,
bottomRatio: y,
actions: [],
}
break
default:
throw new Error("unknown court object " + rackedObject.key)
}
return {
...content,
components: [...content.components, courtObject],
}
}
export function dropBallOnComponent(
targetedComponentIdx: number,
content: TacticContent,
setAsOrigin: boolean,
): TacticContent {
const component = content.components[targetedComponentIdx]
if (component.type === "player" || component.type === "phantom") {
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)
}
return removeBall(content)
}
export function removeBall(content: TacticContent): TacticContent {
const ballObjIdx = content.components.findIndex((o) => o.type == "ball")
if (ballObjIdx == -1) {
return content
}
return {
...content,
components: content.components.toSpliced(ballObjIdx, 1),
}
}
export function placeBallAt(
refBounds: DOMRect,
courtBounds: DOMRect,
content: TacticContent,
): TacticContent {
if (!overlaps(courtBounds, refBounds)) {
return removeBall(content)
}
const playerCollidedIdx = getComponentCollided(
refBounds,
content.components,
BALL_ID,
)
if (playerCollidedIdx != -1) {
return dropBallOnComponent(playerCollidedIdx, content, true)
}
const ballIdx = content.components.findIndex((o) => o.type == "ball")
const { x, y } = ratioWithinBase(refBounds, courtBounds)
const ball: Ball = {
type: BALL_TYPE,
id: BALL_ID,
rightRatio: x,
bottomRatio: y,
actions: [],
}
let components = content.components
if (ballIdx != -1) {
components = components.toSpliced(ballIdx, 1, ball)
} else {
components = components.concat(ball)
}
return {
...content,
components,
}
}
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 {
return {
...content,
components: content.components.filter((c) => c.id !== componentId),
}
}
export function updateComponent(
component: TacticComponent,
content: TacticContent,
): TacticContent {
return {
...content,
components: content.components.map((c) =>
c.id === component.id ? component : c,
),
}
}
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 }))
}

@ -1,39 +1,25 @@
import { Pos, ratioWithinBase } from "../geo/Pos" import {Pos, ratioWithinBase} from "../geo/Pos"
import { import {BallState, Player, PlayerInfo, PlayerLike, PlayerTeam,} from "../model/tactic/Player"
BallState, import {Ball, BALL_ID, BALL_TYPE, CourtObject,} from "../model/tactic/CourtObjects"
Player, import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic"
PlayerInfo, import {overlaps} from "../geo/Box"
PlayerTeam, import {RackedCourtObject, RackedPlayer} from "./RackedItems"
} from "../model/tactic/Player" import {changePlayerBallState, getComponent, getOrigin} from "./PlayerDomains"
import { import {ActionKind} from "../model/tactic/Action.ts";
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( export function placePlayerAt(
refBounds: DOMRect, refBounds: DOMRect,
courtBounds: DOMRect, courtBounds: DOMRect,
element: RackedPlayer, element: RackedPlayer,
): Player { ): Player {
const { x, y } = ratioWithinBase(refBounds, courtBounds) const pos = ratioWithinBase(refBounds, courtBounds)
return { return {
type: "player", type: "player",
id: "player-" + element.key + "-" + element.team, id: "player-" + element.key + "-" + element.team,
team: element.team, team: element.team,
role: element.key, role: element.key,
rightRatio: x, pos,
bottomRatio: y,
ballState: BallState.NONE, ballState: BallState.NONE,
path: null, path: null,
actions: [], actions: [],
@ -46,7 +32,7 @@ export function placeObjectAt(
rackedObject: RackedCourtObject, rackedObject: RackedCourtObject,
content: TacticContent, content: TacticContent,
): TacticContent { ): TacticContent {
const { x, y } = ratioWithinBase(refBounds, courtBounds) const pos = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject let courtObject: CourtObject
@ -64,8 +50,7 @@ export function placeObjectAt(
courtObject = { courtObject = {
type: BALL_TYPE, type: BALL_TYPE,
id: BALL_ID, id: BALL_ID,
rightRatio: x, pos,
bottomRatio: y,
actions: [], actions: [],
} }
break break
@ -134,13 +119,12 @@ export function placeBallAt(
const ballIdx = content.components.findIndex((o) => o.type == "ball") const ballIdx = content.components.findIndex((o) => o.type == "ball")
const { x, y } = ratioWithinBase(refBounds, courtBounds) const pos = ratioWithinBase(refBounds, courtBounds)
const ball: Ball = { const ball: Ball = {
type: BALL_TYPE, type: BALL_TYPE,
id: BALL_ID, id: BALL_ID,
rightRatio: x, pos,
bottomRatio: y,
actions: [], actions: [],
} }
@ -174,14 +158,44 @@ export function moveComponent(
if (!overlaps(playerBounds, courtBounds)) { if (!overlaps(playerBounds, courtBounds)) {
return removed(content) 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(
<TacticComponent>{
...component, ...component,
rightRatio: newPos.x, pos: isPhantom
bottomRatio: newPos.y, ? {
type: "fixed",
...newPos
}
: newPos
}, },
content, content,
) )
return content
} }
export function removeComponent( export function removeComponent(
@ -239,5 +253,5 @@ export function getRackPlayers(
c.type == "player" && c.team == team && c.role == role, c.type == "player" && c.team == team && c.role == role,
) == -1, ) == -1,
) )
.map((key) => ({ team, key })) .map((key) => ({team, key}))
} }

@ -9,9 +9,10 @@ export enum ActionKind {
SHOOT = "SHOOT", SHOOT = "SHOOT",
} }
export type Action = { type: ActionKind } & MovementAction export type Action = MovementAction
export interface MovementAction { export interface MovementAction {
type: ActionKind
target: ComponentId | Pos target: ComponentId | Pos
segments: Segment[] segments: Segment[]
} }

@ -1,4 +1,5 @@
import { Component } from "./Tactic" import { Component } from "./Tactic"
import { Pos } from "../../geo/Pos.ts"
export const BALL_ID = "ball" export const BALL_ID = "ball"
export const BALL_TYPE = "ball" export const BALL_TYPE = "ball"
@ -6,4 +7,4 @@ export const BALL_TYPE = "ball"
//place here all different kinds of objects //place here all different kinds of objects
export type CourtObject = Ball export type CourtObject = Ball
export type Ball = Component<typeof BALL_TYPE> export type Ball = Component<typeof BALL_TYPE, Pos>

@ -1,4 +1,5 @@
import { Component, ComponentId } from "./Tactic" import { Component, ComponentId } from "./Tactic"
import { Pos } from "../../geo/Pos.ts"
export type PlayerId = string export type PlayerId = string
@ -9,7 +10,7 @@ export enum PlayerTeam {
Opponents = "opponents", Opponents = "opponents",
} }
export interface Player extends PlayerInfo, Component<"player"> { export interface Player extends PlayerInfo, Component<"player", Pos> {
readonly id: PlayerId readonly id: PlayerId
} }
@ -33,15 +34,7 @@ export interface PlayerInfo {
*/ */
readonly ballState: BallState readonly ballState: BallState
/** readonly pos: Pos,
* 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 { export enum BallState {
@ -52,7 +45,7 @@ export enum BallState {
PASSED_ORIGIN, PASSED_ORIGIN,
} }
export interface Player extends Component<"player">, PlayerInfo { export interface Player extends Component<"player", Pos>, PlayerInfo {
/** /**
* True if the player has a basketball * True if the player has a basketball
*/ */
@ -65,11 +58,32 @@ export interface MovementPath {
readonly items: ComponentId[] 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 * A player phantom is a kind of component that represents the future state of a player
* according to the court's step information * according to the court's step information
*/ */
export interface PlayerPhantom extends Component<"phantom"> { export interface PlayerPhantom extends Component<"phantom", PhantomPositioning> {
readonly originPlayerId: ComponentId readonly originPlayerId: ComponentId
readonly ballState: BallState readonly ballState: BallState
/**
* Defines a component this phantom will be attached to.
*/
readonly attachedTo?: ComponentId
} }

@ -18,7 +18,7 @@ export interface TacticContent {
export type TacticComponent = Player | CourtObject | PlayerPhantom export type TacticComponent = Player | CourtObject | PlayerPhantom
export type ComponentId = string export type ComponentId = string
export interface Component<T> { export interface Component<T, Positioning> {
/** /**
* The component's type * The component's type
*/ */
@ -27,15 +27,8 @@ export interface Component<T> {
* The component's identifier * The component's identifier
*/ */
readonly id: ComponentId 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
/** readonly pos: Positioning,
* 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[] readonly actions: Action[]
} }

@ -1,39 +0,0 @@
import { Player, PlayerPhantom } from "./Player"
import { Action } from "./Action"
import { CourtObject } from "./CourtObjects"
export interface Tactic {
id: number
name: string
content: TacticContent
}
export interface TacticContent {
components: TacticComponent[]
//actions: Action[]
}
export type TacticComponent = Player | CourtObject | PlayerPhantom
export type ComponentId = string
export interface Component<T> {
/**
* The component's type
*/
readonly type: T
/**
* 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 actions: Action[]
}

@ -67,7 +67,7 @@ import { middlePos, Pos, ratioWithinBase } from "../geo/Pos"
import { Action, ActionKind } from "../model/tactic/Action" import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction" import BallAction from "../components/actions/BallAction"
import { import {
changePlayerBallState, changePlayerBallState, computePhantomPositioning,
getOrigin, getOrigin,
removePlayer, removePlayer,
} from "../editor/PlayerDomains" } from "../editor/PlayerDomains"
@ -329,7 +329,7 @@ function EditorView({
content, content,
(content) => { (content) => {
if (player.type == "player") insertRackedPlayer(player) if (player.type === "player") insertRackedPlayer(player)
return removePlayer(player, content) return removePlayer(player, content)
}, },
), ),
@ -402,8 +402,7 @@ function EditorView({
id: component.id, id: component.id,
team: origin.team, team: origin.team,
role: origin.role, role: origin.role,
bottomRatio: component.bottomRatio, pos: computePhantomPositioning(component, content, courtBounds()),
rightRatio: component.rightRatio,
ballState: component.ballState, ballState: component.ballState,
} }
} else { } else {

@ -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%;
}
Loading…
Cancel
Save