add phantoms for move and dribble

maxime.batista 1 year ago committed by maxime
parent 05a22c6f7a
commit 615ee53c98

@ -1,17 +1,21 @@
import { BallPiece } from "../editor/BallPiece"
import {BallPiece} from "../editor/BallPiece"
import Draggable from "react-draggable"
import { useRef } from "react"
import {useRef} from "react"
import {NULL_POS} from "../../geo/Pos";
export interface BallActionProps {
onDrop: (el: HTMLElement) => void
onDrop: (el: DOMRect) => void
}
export default function BallAction({ onDrop }: BallActionProps) {
export default function BallAction({onDrop}: BallActionProps) {
const ref = useRef<HTMLDivElement>(null)
return (
<Draggable onStop={() => onDrop(ref.current!)} nodeRef={ref}>
<Draggable
nodeRef={ref}
onStop={() => onDrop(ref.current!.getBoundingClientRect())}
position={NULL_POS}>
<div ref={ref}>
<BallPiece />
<BallPiece/>
</div>
</Draggable>
)

@ -147,7 +147,7 @@ export default function BendableArrow({
// If the (original) segments changes, overwrite the current ones.
useLayoutEffect(() => {
setInternalSegments(computeInternalSegments(segments))
}, [startPos, segments, computeInternalSegments])
}, [computeInternalSegments])
const [isSelected, setIsSelected] = useState(false)

@ -1,8 +1,8 @@
import "../../style/ball.css"
import BallSvg from "../../assets/icon/ball.svg?react"
import {BALL_ID} from "../../model/tactic/Ball";
import { BALL_ID } from "../../model/tactic/CourtObjects"
export function BallPiece() {
return <BallSvg id={BALL_ID} className={"ball"}/>
return <BallSvg id={BALL_ID} className={"ball"} />
}

@ -1,37 +1,16 @@
import { CourtBall } from "./CourtBall"
import {ReactElement, ReactNode, RefObject, useLayoutEffect, useState,} from "react"
import {Action} from "../../model/tactic/Action"
import {
ReactElement,
RefObject,
useCallback,
useLayoutEffect,
useState,
} from "react"
import CourtPlayer from "./CourtPlayer"
import { Player } from "../../model/tactic/Player"
import { Action, ActionKind } from "../../model/tactic/Action"
import ArrowAction from "../actions/ArrowAction"
import { middlePos, ratioWithinBase } from "../../geo/Pos"
import BallAction from "../actions/BallAction"
import {BALL_ID} from "../../model/tactic/Ball"
import { contains, overlaps } from "../../geo/Box"
import { CourtAction } from "../../views/editor/CourtAction"
import { TacticComponent } from "../../model/tactic/Tactic"
import {CourtAction} from "../../views/editor/CourtAction"
import {TacticComponent} from "../../model/tactic/Tactic"
export interface BasketCourtProps {
components: TacticComponent[]
actions: Action[]
previewAction: Action | null
renderAction: (a: Action, key: number) => ReactElement
setActions: (f: (a: Action[]) => Action[]) => void
onPlayerRemove: (p: Player) => void
onPlayerChange: (p: Player) => void
onBallRemove: () => void
onBallMoved: (ball: DOMRect) => void
renderComponent: (comp: TacticComponent) => ReactNode
renderAction: (action: Action, idx: number) => ReactNode
courtImage: ReactElement
courtRef: RefObject<HTMLDivElement>
@ -40,104 +19,14 @@ export interface BasketCourtProps {
export function BasketCourt({
components,
actions,
renderAction,
setActions,
onPlayerRemove,
onPlayerChange,
previewAction,
onBallMoved,
onBallRemove,
renderComponent,
renderAction,
courtImage,
courtRef,
}: BasketCourtProps) {
function placeArrow(origin: Player, arrowHead: DOMRect) {
const originRef = document.getElementById(origin.id)!
const courtBounds = courtRef.current!.getBoundingClientRect()
const start = ratioWithinBase(
middlePos(originRef.getBoundingClientRect()),
courtBounds,
)
for (const component of components) {
if (component.id == origin.id) {
continue
}
const playerBounds = document
.getElementById(component.id)!
.getBoundingClientRect()
if (overlaps(playerBounds, arrowHead)) {
const targetPos = document
.getElementById(component.id)!
.getBoundingClientRect()
const end = ratioWithinBase(middlePos(targetPos), courtBounds)
const action: Action = {
fromId: originRef.id,
toId: component.id,
type:
component.type == "player"
? origin.hasBall
? ActionKind.SHOOT
: ActionKind.SCREEN
: ActionKind.MOVE,
moveFrom: start,
segments: [{ next: end }],
}
setActions((actions) => [...actions, action])
return
}
}
const action: Action = {
fromId: originRef.id,
type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE,
moveFrom: ratioWithinBase(
middlePos(originRef.getBoundingClientRect()),
courtBounds,
),
segments: [
{ next: ratioWithinBase(middlePos(arrowHead), courtBounds) },
],
}
setActions((actions) => [...actions, action])
}
const [previewAction, setPreviewAction] = useState<Action | null>(null)
const updateActionsRelatedTo = useCallback((comp: TacticComponent) => {
const newPos = ratioWithinBase(
middlePos(
document.getElementById(comp.id)!.getBoundingClientRect(),
),
courtRef.current!.getBoundingClientRect(),
)
setActions((actions) =>
actions.map((a) => {
if (a.fromId == comp.id) {
return { ...a, moveFrom: newPos }
}
if (a.toId == comp.id) {
const segments = a.segments.toSpliced(
a.segments.length - 1,
1,
{
...a.segments[a.segments.length - 1],
next: newPos,
},
)
return { ...a, segments }
}
return a
}),
)
}, [])
const [internActions, setInternActions] = useState<Action[]>([])
useLayoutEffect(() => setInternActions(actions), [actions])
@ -149,122 +38,7 @@ export function BasketCourt({
style={{ position: "relative" }}>
{courtImage}
{components.map((component) => {
if (component.type == "player") {
const player = component
return (
<CourtPlayer
key={player.id}
player={player}
onDrag={() => updateActionsRelatedTo(player)}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
courtRef={courtRef}
availableActions={(pieceRef) => [
<ArrowAction
key={1}
onHeadMoved={(headPos) => {
const baseBounds =
courtRef.current!.getBoundingClientRect()
const arrowHeadPos = middlePos(headPos)
const target = components.find(
(c) =>
c.id != player.id &&
contains(
document
.getElementById(c.id)!
.getBoundingClientRect(),
arrowHeadPos,
),
)
const type =
target?.type == "player"
? player.hasBall
? target
? ActionKind.SHOOT
: ActionKind.DRIBBLE
: target
? ActionKind.SCREEN
: ActionKind.MOVE
: ActionKind.MOVE
setPreviewAction((action) => ({
...action!,
segments: [
{
next: ratioWithinBase(
arrowHeadPos,
baseBounds,
),
},
],
type,
}))
}}
onHeadPicked={(headPos) => {
;(
document.activeElement as HTMLElement
).blur()
const baseBounds =
courtRef.current!.getBoundingClientRect()
setPreviewAction({
type: player.hasBall
? ActionKind.DRIBBLE
: ActionKind.MOVE,
fromId: player.id,
toId: undefined,
moveFrom: ratioWithinBase(
middlePos(
pieceRef.getBoundingClientRect(),
),
baseBounds,
),
segments: [
{
next: ratioWithinBase(
middlePos(headPos),
baseBounds,
),
},
],
})
}}
onHeadDropped={(headRect) => {
placeArrow(player, headRect)
setPreviewAction(null)
}}
/>,
player.hasBall && (
<BallAction
key={2}
onDrop={(ref) =>
onBallMoved(
ref.getBoundingClientRect(),
)
}
/>
),
]}
/>
)
}
if (component.type == BALL_ID) {
return (
<CourtBall
onPosValidated={onBallMoved}
onMoves={() => updateActionsRelatedTo(component)}
ball={component}
onRemove={onBallRemove}
key="ball"
/>
)
}
throw new Error("unknown tactic component " + component)
})}
{components.map(renderComponent)}
{internActions.map((action, idx) => renderAction(action, idx))}
@ -272,7 +46,7 @@ export function BasketCourt({
<CourtAction
courtRef={courtRef}
action={previewAction}
//do nothing on change, not really possible as it's a preview arrow
//do nothing on interacted, not really possible as it's a preview arrow
onActionDeleted={() => {}}
onActionChanges={() => {}}
/>

@ -1,7 +1,8 @@
import React, { useRef } from "react"
import Draggable from "react-draggable"
import { BallPiece } from "./BallPiece"
import { Ball } from "../../model/tactic/Ball"
import { NULL_POS } from "../../geo/Pos"
import { Ball } from "../../model/tactic/CourtObjects"
export interface CourtBallProps {
onPosValidated: (rect: DOMRect) => void
@ -27,6 +28,7 @@ export function CourtBall({
onPosValidated(pieceRef.current!.getBoundingClientRect())
}
onDrag={onMoves}
position={NULL_POS}
nodeRef={pieceRef}>
<div
className={"ball-div"}

@ -1,14 +1,16 @@
import { ReactNode, RefObject, useRef } from "react"
import {ReactNode, RefObject, useRef} from "react"
import "../../style/player.css"
import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../model/tactic/Player"
import { NULL_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 PlayerProps {
player: Player
onDrag: () => void
onChange: (p: Player) => void
export interface CourtPlayerProps {
playerInfo: PlayerInfo
className?: string
onMoves: () => void
onPositionValidated: (newPos: Pos) => void
onRemove: () => void
courtRef: RefObject<HTMLElement>
availableActions: (ro: HTMLElement) => ReactNode[]
@ -18,45 +20,38 @@ export interface PlayerProps {
* A player that is placed on the court, which can be selected, and moved in the associated bounds
* */
export default function CourtPlayer({
player,
onDrag,
onChange,
playerInfo,
className,
onMoves,
onPositionValidated,
onRemove,
courtRef,
availableActions,
}: PlayerProps) {
const hasBall = player.hasBall
const x = player.rightRatio
const y = player.bottomRatio
}: CourtPlayerProps) {
const usesBall = playerInfo.ballState != BallState.NONE
const x = playerInfo.rightRatio
const y = playerInfo.bottomRatio
const pieceRef = useRef<HTMLDivElement>(null)
return (
<Draggable
handle=".player-piece"
nodeRef={pieceRef}
onDrag={onDrag}
onDrag={onMoves}
//The piece is positioned using top/bottom style attributes instead
position={NULL_POS}
onStop={() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = courtRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(pieceBounds, parentBounds)
onChange({
type: "player",
id: player.id,
rightRatio: x,
bottomRatio: y,
team: player.team,
role: player.role,
hasBall: player.hasBall,
} as Player)
const pos = ratioWithinBase(pieceBounds, parentBounds)
onPositionValidated(pos)
}}>
<div
id={player.id}
id={playerInfo.id}
ref={pieceRef}
className="player"
className={"player " + (className ?? "")}
style={{
position: "absolute",
left: `${x * 100}%`,
@ -72,9 +67,9 @@ export default function CourtPlayer({
{availableActions(pieceRef.current!)}
</div>
<PlayerPiece
team={player.team}
text={player.role}
hasBall={hasBall}
team={playerInfo.team}
text={playerInfo.role}
hasBall={usesBall}
/>
</div>
</div>

@ -0,0 +1,214 @@
import {BallState, Player, PlayerPhantom} from "../model/tactic/Player"
import {middlePos, ratioWithinBase} from "../geo/Pos"
import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic"
import {overlaps} from "../geo/Box"
import {Action, ActionKind} from "../model/tactic/Action"
import {removeBall, updateComponent} from "./TacticContentDomains"
import {getOrigin} from "./PlayerDomains"
export function refreshAllActions(
actions: Action[],
components: TacticComponent[],
) {
return actions.map((action) => ({
...action,
type: getActionKindFrom(action.fromId, action.toId, components),
}))
}
export function getActionKindFrom(
originId: ComponentId,
targetId: ComponentId | null,
components: TacticComponent[],
): ActionKind {
const origin = components.find((p) => p.id == originId)!
const target = components.find(p => p.id == targetId)
let ballState = BallState.NONE
if (origin.type == "player" || origin.type == "phantom") {
ballState = origin.ballState
}
let hasTarget = target ? (target.type != 'phantom' || target.originPlayerId != origin.id) : false
return getActionKind(hasTarget, ballState)
}
export function getActionKind(hasTarget: boolean, ballState: BallState): ActionKind {
switch (ballState) {
case BallState.HOLDS:
return hasTarget ? ActionKind.SHOOT : ActionKind.DRIBBLE
case BallState.SHOOTED:
return ActionKind.MOVE
case BallState.NONE:
return hasTarget ? ActionKind.SCREEN : ActionKind.MOVE
}
}
export function placeArrow(
origin: Player | PlayerPhantom,
courtBounds: DOMRect,
arrowHead: DOMRect,
content: TacticContent,
): { createdAction: Action, newContent: TacticContent } {
const originRef = document.getElementById(origin.id)!
const start = ratioWithinBase(
middlePos(originRef.getBoundingClientRect()),
courtBounds,
)
/**
* Creates a new phantom component.
* Be aware that this function will reassign the `content` parameter.
* @param receivesBall
*/
function createPhantom(receivesBall: boolean): ComponentId {
const {x, y} = ratioWithinBase(arrowHead, courtBounds)
let itemIndex: number
let originPlayer: Player
if (origin.type == "phantom") {
// if we create a phantom from another phantom,
// simply add it to the phantom's path
const originPlr = getOrigin(origin, content.components)!
itemIndex = originPlr.path!.items.length
originPlayer = originPlr
} else {
// if we create a phantom directly from a player
// create a new path and add it into
itemIndex = 0
originPlayer = origin
}
const path = originPlayer.path
const phantomId = "phantom-" + itemIndex + "-" + originPlayer.id
content = updateComponent(
{
...originPlayer,
path: {
items: path ? [...path.items, phantomId] : [phantomId],
},
},
content,
)
const ballState = receivesBall
? BallState.HOLDS
: origin.ballState == BallState.HOLDS
? BallState.HOLDS
: BallState.NONE
const phantom: PlayerPhantom = {
type: "phantom",
id: phantomId,
rightRatio: x,
bottomRatio: y,
originPlayerId: originPlayer.id,
ballState
}
content = {
...content,
components: [...content.components, phantom],
}
return phantom.id
}
for (const component of content.components) {
if (component.id == origin.id) {
continue
}
const componentBounds = document
.getElementById(component.id)!
.getBoundingClientRect()
if (overlaps(componentBounds, arrowHead)) {
const targetPos = document
.getElementById(component.id)!
.getBoundingClientRect()
const end = ratioWithinBase(middlePos(targetPos), courtBounds)
let toId = component.id
if (component.type == "ball") {
toId = createPhantom(true)
content = removeBall(content)
}
const action: Action = {
fromId: originRef.id,
toId,
type: getActionKind(true, origin.ballState),
moveFrom: start,
segments: [{next: end}],
}
return {
newContent: {
...content,
actions: [...content.actions, action],
},
createdAction: action
}
}
}
const phantomId = createPhantom(origin.ballState == BallState.HOLDS)
const action: Action = {
fromId: originRef.id,
toId: phantomId,
type: getActionKind(false, origin.ballState),
moveFrom: ratioWithinBase(
middlePos(originRef.getBoundingClientRect()),
courtBounds,
),
segments: [
{next: ratioWithinBase(middlePos(arrowHead), courtBounds)},
],
}
return {
newContent: {
...content,
actions: [...content.actions, action],
},
createdAction: action
}
}
export function repositionActionsRelatedTo(
compId: ComponentId,
courtBounds: DOMRect,
actions: Action[],
): Action[] {
const posRect = document.getElementById(compId)?.getBoundingClientRect()
const newPos = posRect != undefined
? ratioWithinBase(middlePos(posRect), courtBounds)
: undefined
return actions.flatMap((action) => {
if (newPos == undefined) {
return []
}
if (action.fromId == compId) {
return [{...action, moveFrom: newPos}]
}
if (action.toId == compId) {
const lastIdx = action.segments.length - 1
const segments = action.segments.toSpliced(lastIdx, 1, {
...action.segments[lastIdx],
next: newPos!,
})
return [{...action, segments}]
}
return action
})
}

@ -0,0 +1,80 @@
import { Player, PlayerPhantom } from "../model/tactic/Player"
import { TacticComponent, TacticContent } from "../model/tactic/Tactic"
import { removeComponent, updateComponent } from "./TacticContentDomains"
export function getOrigin(
pathItem: PlayerPhantom,
components: TacticComponent[],
): Player {
// Trust the components to contains only phantoms with valid player origin identifiers
return components.find((c) => c.id == pathItem.originPlayerId)! as Player
}
export function removePlayerPath(
player: Player,
content: TacticContent,
): TacticContent {
if (player.path == null) {
return content
}
for (const pathElement of player.path.items) {
content = removeComponent(pathElement, content)
}
return updateComponent(
{
...player,
path: null,
},
content,
)
}
export function removePlayer(
player: Player | PlayerPhantom,
content: TacticContent,
): TacticContent {
if (player.type == "phantom") {
const origin = getOrigin(player, content.components)
return truncatePlayerPath(origin, player, content)
}
content = removePlayerPath(player, content)
return removeComponent(player.id, content)
}
export function truncatePlayerPath(
player: Player,
phantom: PlayerPhantom,
content: TacticContent,
): TacticContent {
if (player.path == null) return content
const path = player.path!
let truncateStartIdx = -1
for (let j = 0; j < path.items.length; j++) {
const pathPhantomId = path.items[j]
if (truncateStartIdx != -1 || pathPhantomId == phantom.id) {
if (truncateStartIdx == -1) truncateStartIdx = j
//remove the phantom from the tactic
content = removeComponent(pathPhantomId, content)
}
}
return updateComponent(
{
...player,
path:
truncateStartIdx == 0
? null
: {
...path,
items: path.items.toSpliced(truncateStartIdx),
},
},
content,
)
}

@ -0,0 +1,11 @@
/**
* information about a player that is into a rack
*/
import { PlayerTeam } from "../model/tactic/Player"
export interface RackedPlayer {
team: PlayerTeam
key: string
}
export type RackedCourtObject = { key: "ball" }

@ -0,0 +1,299 @@
import {Pos, ratioWithinBase} from "../geo/Pos"
import {BallState, Player, PlayerInfo, PlayerTeam} from "../model/tactic/Player"
import {Ball, BALL_ID, BALL_TYPE, CourtObject} from "../model/tactic/CourtObjects"
import {ComponentId, TacticComponent, TacticContent,} from "../model/tactic/Tactic"
import {overlaps} from "../geo/Box"
import {RackedCourtObject, RackedPlayer} from "./RackedItems"
import {refreshAllActions} from "./ActionsDomains"
import {getOrigin} from "./PlayerDomains";
export function placePlayerAt(
refBounds: DOMRect,
courtBounds: DOMRect,
element: RackedPlayer,
): Player {
const {x, y} = ratioWithinBase(refBounds, courtBounds)
return {
type: "player",
id: "player-" + element.key + "-" + element.team,
team: element.team,
role: element.key,
rightRatio: x,
bottomRatio: y,
ballState: BallState.NONE,
path: null,
}
}
export function placeObjectAt(
refBounds: DOMRect,
courtBounds: DOMRect,
rackedObject: RackedCourtObject,
content: TacticContent,
): TacticContent {
const {x, y} = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject
switch (rackedObject.key) {
case BALL_TYPE:
const playerCollidedIdx = getComponentCollided(
refBounds,
content.components,
BALL_ID,
)
if (playerCollidedIdx != -1) {
return dropBallOnComponent(playerCollidedIdx, content)
}
courtObject = {
type: BALL_TYPE,
id: BALL_ID,
rightRatio: x,
bottomRatio: y,
}
break
default:
throw new Error("unknown court object " + rackedObject.key)
}
return {
...content,
components: [...content.components, courtObject],
}
}
export function dropBallOnComponent(
targetedComponentIdx: number,
content: TacticContent,
): TacticContent {
let components = content.components
let component = components[targetedComponentIdx]
let origin
let isPhantom: boolean
if (component.type == 'phantom') {
isPhantom = true
origin = getOrigin(component, components)
} else if (component.type == 'player') {
isPhantom = false
origin = component
} else {
return content
}
components = components.toSpliced(targetedComponentIdx, 1, {
...component,
ballState: BallState.HOLDS,
})
if (origin.path != null) {
const phantoms = origin.path!.items
const headingPhantoms = isPhantom ? phantoms.slice(phantoms.indexOf(component.id)) : phantoms
components = components.map(c => headingPhantoms.indexOf(c.id) != -1 ? {
...c,
hasBall: true
} : c)
}
const ballObj = components.findIndex((p) => p.type == BALL_TYPE)
// Maybe the ball is not present on the court as an object component
// if so, don't bother removing it from the court.
// This can occur if the user drags and drop the ball from a player that already has the ball
// to another component
if (ballObj != -1) {
components.splice(ballObj, 1)
}
return {
...content,
actions: refreshAllActions(content.actions, components),
components,
}
}
export function removeBall(content: TacticContent): TacticContent {
const ballObj = content.components.findIndex((o) => o.type == "ball")
const components = content.components.map((c) =>
(c.type == 'player' || c.type == 'phantom')
? {
...c,
hasBall: false,
}
: c,
)
// if the ball is already not on the court, do nothing
if (ballObj != -1) {
components.splice(ballObj, 1)
}
return {
...content,
actions: refreshAllActions(content.actions, components),
components,
}
}
export function placeBallAt(
refBounds: DOMRect,
courtBounds: DOMRect,
content: TacticContent,
): {
newContent: TacticContent
removed: boolean
} {
if (!overlaps(courtBounds, refBounds)) {
return {newContent: removeBall(content), removed: true}
}
const playerCollidedIdx = getComponentCollided(
refBounds,
content.components,
BALL_ID,
)
if (playerCollidedIdx != -1) {
return {
newContent: dropBallOnComponent(playerCollidedIdx, {
...content,
components: content.components.map((c) =>
c.type == "player" || c.type == 'phantom'
? {
...c,
hasBall: false,
}
: c,
),
}),
removed: false,
}
}
const ballIdx = content.components.findIndex((o) => o.type == "ball")
const {x, y} = ratioWithinBase(refBounds, courtBounds)
const components = content.components.map((c) =>
c.type == "player" || c.type == "phantom"
? {
...c,
hasBall: false,
}
: c,
)
const ball: Ball = {
type: BALL_TYPE,
id: BALL_ID,
rightRatio: x,
bottomRatio: y,
}
if (ballIdx != -1) {
components.splice(ballIdx, 1, ball)
} else {
components.push(ball)
}
return {
newContent: {
...content,
actions: refreshAllActions(content.actions, components),
components,
},
removed: false,
}
}
export function moveComponent(
newPos: Pos,
component: TacticComponent,
info: PlayerInfo,
courtBounds: DOMRect,
content: TacticContent,
removed: (content: TacticContent) => TacticContent,
): TacticContent {
const playerBounds = document
.getElementById(info.id)!
.getBoundingClientRect()
// if the piece is no longer on the court, remove it
if (!overlaps(playerBounds, courtBounds)) {
return removed(content)
}
return updateComponent(
{
...component,
rightRatio: newPos.x,
bottomRatio: newPos.y,
},
content,
)
}
export function removeComponent(
componentId: ComponentId,
content: TacticContent,
): TacticContent {
const componentIdx = content.components.findIndex(
(c) => c.id == componentId,
)
return {
...content,
components: content.components.toSpliced(componentIdx, 1),
actions: content.actions.filter(
(a) => a.toId !== componentId && a.fromId !== componentId,
),
}
}
export function updateComponent(
component: TacticComponent,
content: TacticContent,
): TacticContent {
const componentIdx = content.components.findIndex(
(c) => c.id == component.id,
)
return {
...content,
components: content.components.toSpliced(componentIdx, 1, component),
}
}
export function getComponentCollided(
bounds: DOMRect,
components: TacticComponent[],
ignore?: ComponentId,
): number | -1 {
for (let i = 0; i < components.length; i++) {
const component = components[i]
if (component.id == ignore) continue
const playerBounds = document
.getElementById(component.id)!
.getBoundingClientRect()
if (overlaps(playerBounds, bounds)) {
return i
}
}
return -1
}
export function getRackPlayers(
team: PlayerTeam,
components: TacticComponent[],
): RackedPlayer[] {
return ["1", "2", "3", "4", "5"]
.filter(
(role) =>
components.findIndex(
(c) =>
c.type == "player" && c.team == team && c.role == role,
) == -1,
)
.map((key) => ({team, key}))
}

@ -1,4 +1,3 @@
import { Pos } from "../../geo/Pos"
import { Segment } from "../../components/arrows/BendableArrow"
import { ComponentId } from "./Tactic"
@ -14,7 +13,7 @@ export type Action = { type: ActionKind } & MovementAction
export interface MovementAction {
fromId: ComponentId
toId?: ComponentId
toId: ComponentId | null
moveFrom: Pos
segments: Segment[]
}

@ -1,4 +1,4 @@
import {Component} from "./Tactic";
import { Component, ComponentId } from "./Tactic"
export type PlayerId = string
@ -7,11 +7,15 @@ export enum PlayerTeam {
Opponents = "opponents",
}
export interface Player {
export interface Player extends PlayerInfo, Component<"player"> {
readonly id: PlayerId
}
export interface Player extends Component<"player"> {
/**
* All information about a player
*/
export interface PlayerInfo {
readonly id: string
/**
* the player's team
* */
@ -25,6 +29,43 @@ export interface Player extends Component<"player"> {
/**
* True if the player has a basketball
*/
readonly hasBall: boolean
readonly ballState: BallState
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
readonly bottomRatio: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
readonly rightRatio: number
}
export enum BallState {
NONE,
HOLDS,
SHOOTED
}
export interface Player extends Component<"player">, PlayerInfo {
/**
* True if the player has a basketball
*/
readonly ballState: BallState
readonly path: MovementPath | null
}
export interface MovementPath {
readonly items: ComponentId[]
}
/**
* A player phantom is a kind of component that represents the future state of a player
* according to the court's step information
*/
export interface PlayerPhantom extends Component<"phantom"> {
readonly originPlayerId: ComponentId
readonly ballState: BallState
}

@ -1,6 +1,6 @@
import {Player} from "./Player"
import {Action} from "./Action"
import {CourtObject} from "./Ball"
import { Player, PlayerPhantom } from "./Player"
import { Action } from "./Action"
import { CourtObject } from "./CourtObjects"
export interface Tactic {
id: number
@ -13,7 +13,7 @@ export interface TacticContent {
actions: Action[]
}
export type TacticComponent = Player | CourtObject
export type TacticComponent = Player | CourtObject | PlayerPhantom
export type ComponentId = string
export interface Component<T> {
@ -26,12 +26,12 @@ export interface Component<T> {
*/
readonly id: ComponentId
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
* Percentage of the component's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
readonly bottomRatio: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
* Percentage of the component's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
readonly rightRatio: number
}

@ -2,6 +2,10 @@
pointer-events: none;
}
.phantom {
opacity: 50%;
}
.player-content {
display: flex;
flex-direction: column;

@ -8,20 +8,37 @@ import {BallPiece} from "../components/editor/BallPiece"
import {Rack} from "../components/Rack"
import {PlayerPiece} from "../components/editor/PlayerPiece"
import {Player, PlayerTeam} from "../model/tactic/Player"
import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic"
import {fetchAPI} from "../Fetcher"
import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState"
import {BALL_ID, BALL_TYPE, CourtObject, Ball} from "../model/tactic/Ball"
import {BALL_TYPE} from "../model/tactic/CourtObjects"
import {CourtAction} from "./editor/CourtAction"
import {BasketCourt} from "../components/editor/BasketCourt"
import {overlaps} from "../geo/Box"
import {
dropBallOnComponent,
getComponentCollided,
getRackPlayers,
moveComponent,
placeBallAt,
placeObjectAt,
placePlayerAt,
removeBall, updateComponent,
} from "../editor/TacticContentDomains"
import {BallState, Player, PlayerInfo, PlayerPhantom, PlayerTeam,} from "../model/tactic/Player"
import {RackedCourtObject} from "../editor/RackedItems"
import CourtPlayer from "../components/editor/CourtPlayer"
import {getActionKind, placeArrow, repositionActionsRelatedTo,} from "../editor/ActionsDomains"
import ArrowAction from "../components/actions/ArrowAction"
import {middlePos, ratioWithinBase} from "../geo/Pos"
import {Action, ActionKind} from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction"
import {getOrigin, removePlayer, truncatePlayerPath,} from "../editor/PlayerDomains"
import {CourtBall} from "../components/editor/CourtBall"
import {BASE} from "../Constants"
import {overlaps} from "../geo/Box"
import {ratioWithinBase} from "../geo/Pos"
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
@ -44,17 +61,7 @@ export interface EditorProps {
courtType: "PLAIN" | "HALF"
}
/**
* information about a player that is into a rack
*/
interface RackedPlayer {
team: PlayerTeam
key: string
}
type RackedCourtObject = { key: "ball" }
export default function Editor({ id, name, courtType, content }: EditorProps) {
export default function Editor({id, name, courtType, content}: EditorProps) {
const isInGuestMode = id == -1
const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY)
@ -80,7 +87,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) {
)
return SaveStates.Guest
}
return fetchAPI(`tactic/${id}/save`, { content }).then((r) =>
return fetchAPI(`tactic/${id}/save`, {content}).then((r) =>
r.ok ? SaveStates.Ok : SaveStates.Err,
)
}}
@ -89,7 +96,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
return true //simulate that the name has been changed
}
return fetchAPI(`tactic/${id}/edit/name`, { name }).then(
return fetchAPI(`tactic/${id}/edit/name`, {name}).then(
(r) => r.ok,
)
}}
@ -99,11 +106,11 @@ export default function Editor({ id, name, courtType, content }: EditorProps) {
}
function EditorView({
tactic: { id, name, content: initialContent },
tactic: {id, name, content: initialContent},
onContentChange,
onNameChange,
courtType,
}: EditorViewProps) {
}: EditorViewProps) {
const isInGuestMode = id == -1
const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
@ -124,235 +131,36 @@ function EditorView({
)
const [allies, setAllies] = useState(
getRackPlayers(PlayerTeam.Allies, content.components),
() => getRackPlayers(PlayerTeam.Allies, content.components),
)
const [opponents, setOpponents] = useState(
getRackPlayers(PlayerTeam.Opponents, content.components),
() => getRackPlayers(PlayerTeam.Opponents, content.components),
)
const [objects, setObjects] = useState<RackedCourtObject[]>(
isBallOnCourt(content) ? [] : [{ key: "ball" }],
)
const courtDivContentRef = useRef<HTMLDivElement>(null)
const isBoundsOnCourt = (bounds: DOMRect) => {
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
// check if refBounds overlaps courtBounds
return overlaps(courtBounds, bounds)
}
const onRackPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(refBounds, courtBounds)
setContent((content) => {
return {
...content,
components: [
...content.components,
{
type: "player",
id: "player-" + element.key + "-" + element.team,
team: element.team,
role: element.key,
rightRatio: x,
bottomRatio: y,
hasBall: false,
} as Player,
],
actions: content.actions,
}
})
}
const onRackedObjectDetach = (
ref: HTMLDivElement,
rackedObject: RackedCourtObject,
) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject
switch (rackedObject.key) {
case BALL_TYPE:
const ballObj = content.components.findIndex(
(o) => o.type == BALL_TYPE,
)
const playerCollidedIdx = getComponentCollided(
refBounds,
content.components.toSpliced(ballObj, 1),
() => isBallOnCourt(content) ? [] : [{key: "ball"}],
)
if (playerCollidedIdx != -1) {
onBallDropOnComponent(playerCollidedIdx)
return
}
courtObject = {
type: BALL_TYPE,
id: BALL_ID,
rightRatio: x,
bottomRatio: y,
}
break
default:
throw new Error("unknown court object " + rackedObject.key)
}
setContent((content) => {
return {
...content,
components: [...content.components, courtObject],
}
})
}
const getComponentCollided = (
bounds: DOMRect,
components: TacticComponent[],
): number | -1 => {
for (let i = 0; i < components.length; i++) {
const component = components[i]
const playerBounds = document
.getElementById(component.id)!
.getBoundingClientRect()
if (overlaps(playerBounds, bounds)) {
return i
}
}
return -1
}
function updateActions(actions: Action[], components: TacticComponent[]) {
return actions.map((action) => {
const originHasBall = (
components.find(
(p) => p.type == "player" && p.id == action.fromId,
)! as Player
).hasBall
let type = action.type
if (originHasBall && type == ActionKind.MOVE) {
type = ActionKind.DRIBBLE
} else if (originHasBall && type == ActionKind.SCREEN) {
type = ActionKind.SHOOT
} else if (type == ActionKind.DRIBBLE) {
type = ActionKind.MOVE
} else if (type == ActionKind.SHOOT) {
type = ActionKind.SCREEN
}
return {
...action,
type,
}
})
}
const [previewAction, setPreviewAction] = useState<Action | null>(null)
const onBallDropOnComponent = (collidedComponentIdx: number) => {
setContent((content) => {
const ballObj = content.components.findIndex(
(p) => p.type == BALL_TYPE,
)
let component = content.components[collidedComponentIdx]
if (component.type != "player") {
return content //do nothing if the ball isn't dropped on a player.
}
const components = content.components.toSpliced(
collidedComponentIdx,
1,
{
...component,
hasBall: true,
},
)
// Maybe the ball is not present on the court as an object component
// if so, don't bother removing it from the court.
// This can occur if the user drags and drop the ball from a player that already has the ball
// to another component
if (ballObj != -1) {
components.splice(ballObj, 1)
}
return {
...content,
actions: updateActions(content.actions, components),
components,
}
})
}
const courtRef = useRef<HTMLDivElement>(null)
const onBallMoved = (refBounds: DOMRect) => {
if (!isBoundsOnCourt(refBounds)) {
removeCourtBall()
return
}
const playerCollidedIdx = getComponentCollided(
refBounds,
content.components,
)
if (playerCollidedIdx != -1) {
setContent((content) => {
return {
...content,
components: content.components.map((c) =>
c.type == "player"
? {
const setActions = (action: SetStateAction<Action[]>) => {
setContent((c) => ({
...c,
hasBall: false,
}
: c,
),
}
})
onBallDropOnComponent(playerCollidedIdx)
return
}
if (content.components.findIndex((o) => o.type == "ball") != -1) {
return
actions: typeof action == "function" ? action(c.actions) : action,
}))
}
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(refBounds, courtBounds)
const courtObject = {
type: BALL_TYPE,
id: BALL_ID,
rightRatio: x,
bottomRatio: y,
} as Ball
let components = content.components.map((c) =>
c.type == "player"
? {
const setComponents = (action: SetStateAction<TacticComponent[]>) => {
setContent((c) => ({
...c,
hasBall: false,
}
: c,
)
components = [...components, courtObject]
setContent((content) => ({
...content,
actions: updateActions(content.actions, components),
components,
components:
typeof action == "function" ? action(c.components) : action,
}))
}
const removePlayer = (player: Player) => {
setContent((content) => ({
...content,
components: replaceOrInsert(content.components, player, false),
actions: content.actions.filter(
(a) => a.toId !== player.id && a.fromId !== player.id,
),
}))
const insertRackedPlayer = (player: Player) => {
let setter
switch (player.team) {
case PlayerTeam.Opponents:
@ -361,8 +169,8 @@ function EditorView({
case PlayerTeam.Allies:
setter = setAllies
}
if (player.hasBall) {
setObjects([{ key: "ball" }])
if (player.ballState == BallState.HOLDS) {
setObjects([{key: "ball"}])
}
setter((players) => [
...players,
@ -374,26 +182,168 @@ function EditorView({
])
}
const removeCourtBall = () => {
const doMoveBall = (newBounds: DOMRect) => {
setContent((content) => {
const ballObj = content.components.findIndex(
(o) => o.type == "ball",
const {newContent, removed} = placeBallAt(
newBounds,
courtBounds(),
content,
)
const components = content.components.map((c) =>
c.type == "player"
? ({
...c,
hasBall: false,
} as Player)
: c,
if (removed) {
setObjects((objects) => [...objects, {key: "ball"}])
}
return newContent
})
}
const courtBounds = () => courtRef.current!.getBoundingClientRect()
const renderPlayer = (component: Player | PlayerPhantom) => {
let info: PlayerInfo
let canPlaceArrows: boolean
const isPhantom = component.type == "phantom"
if (isPhantom) {
const origin = getOrigin(component, content.components)
const path = origin.path!
// phantoms can only place other arrows if they are the head of the path
canPlaceArrows = path.items.indexOf(component.id) == path.items.length - 1
if (canPlaceArrows) {
// and if their only action is to shoot the ball
// list the actions the phantoms does
const phantomArrows = content.actions.filter(c => c.fromId == component.id)
canPlaceArrows = phantomArrows.length == 0 || phantomArrows.findIndex(c => c.type != ActionKind.SHOOT) == -1
}
info = {
id: component.id,
team: origin.team,
role: origin.role,
bottomRatio: component.bottomRatio,
rightRatio: component.rightRatio,
ballState: component.ballState,
}
} else {
// a player
info = component
// can place arrows only if the
canPlaceArrows = component.path == null || content.actions.findIndex(p => p.fromId == component.id && p.type != ActionKind.SHOOT) == -1
}
return (
<CourtPlayer
key={component.id}
className={isPhantom ? "phantom" : "player"}
playerInfo={info}
onMoves={() =>
setActions((actions) =>
repositionActionsRelatedTo(info.id, courtBounds(), actions),
)
components.splice(ballObj, 1)
return {
...content,
components,
}
onPositionValidated={(newPos) => {
setContent((content) =>
moveComponent(
newPos,
component,
info,
courtBounds(),
content,
(content) => {
if (!isPhantom) insertRackedPlayer(component)
return removePlayer(component, content)
},
),
)
}}
onRemove={() => {
setContent((c) => removePlayer(component, c))
if (!isPhantom) insertRackedPlayer(component)
}}
courtRef={courtRef}
availableActions={(pieceRef) => [
canPlaceArrows && (
<ArrowAction
key={1}
onHeadMoved={(headPos) => {
const arrowHeadPos = middlePos(headPos)
const targetIdx = getComponentCollided(headPos, content.components)
setPreviewAction((action) => ({
...action!,
segments: [
{
next: ratioWithinBase(
arrowHeadPos,
courtBounds(),
),
},
],
type: getActionKind(targetIdx != -1, info.ballState),
}))
}}
onHeadPicked={(headPos) => {
(document.activeElement as HTMLElement).blur()
setPreviewAction({
type: getActionKind(false, info.ballState),
fromId: info.id,
toId: null,
moveFrom: ratioWithinBase(
middlePos(
pieceRef.getBoundingClientRect(),
),
courtBounds(),
),
segments: [
{
next: ratioWithinBase(
middlePos(headPos),
courtBounds(),
),
},
],
})
}}
onHeadDropped={(headRect) => {
setContent((content) => {
let {createdAction, newContent} = placeArrow(
component,
courtBounds(),
headRect,
content,
)
let originNewBallState = component.ballState
if (createdAction.type == ActionKind.SHOOT) {
const targetIdx = newContent.components.findIndex(c => c.id == createdAction.toId)
newContent = dropBallOnComponent(targetIdx, newContent)
originNewBallState = BallState.SHOOTED
}
newContent = updateComponent({
...(newContent.components.find(c => c.id == component.id)! as Player | PlayerPhantom),
ballState: originNewBallState
}, newContent)
return newContent
})
setObjects([{ key: "ball" }])
setPreviewAction(null)
}}
/>
),
info.ballState != BallState.NONE && (
<BallAction
key={2}
onDrop={doMoveBall}
/>
),
]}
/>
)
}
return (
@ -403,7 +353,7 @@ function EditorView({
Home
</button>
<div id="topbar-left">
<SavingState state={saveState} />
<SavingState state={saveState}/>
</div>
<div id="title-input-div">
<TitleInput
@ -416,7 +366,7 @@ function EditorView({
}}
/>
</div>
<div id="topbar-right" />
<div id="topbar-right"/>
</div>
<div id="edit-div">
<div id="racks">
@ -425,10 +375,19 @@ function EditorView({
objects={allies}
onChange={setAllies}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
overlaps(courtBounds(), div.getBoundingClientRect())
}
onElementDetached={(r, e) =>
setComponents((components) => [
...components,
placePlayerAt(
r.getBoundingClientRect(),
courtBounds(),
e,
),
])
}
onElementDetached={onRackPieceDetach}
render={({ team, key }) => (
render={({team, key}) => (
<PlayerPiece
team={team}
text={key}
@ -443,9 +402,18 @@ function EditorView({
objects={objects}
onChange={setObjects}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
overlaps(courtBounds(), div.getBoundingClientRect())
}
onElementDetached={(r, e) =>
setContent((content) =>
placeObjectAt(
r.getBoundingClientRect(),
courtBounds(),
e,
content,
),
)
}
onElementDetached={onRackedObjectDetach}
render={renderCourtObject}
/>
@ -454,10 +422,19 @@ function EditorView({
objects={opponents}
onChange={setOpponents}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
overlaps(courtBounds(), div.getBoundingClientRect())
}
onElementDetached={(r, e) =>
setComponents((components) => [
...components,
placePlayerAt(
r.getBoundingClientRect(),
courtBounds(),
e,
),
])
}
onElementDetached={onRackPieceDetach}
render={({ team, key }) => (
render={({team, key}) => (
<PlayerPiece
team={team}
text={key}
@ -472,28 +449,85 @@ function EditorView({
<BasketCourt
components={content.components}
actions={content.actions}
onBallMoved={onBallMoved}
courtImage={<Court courtType={courtType} />}
courtRef={courtDivContentRef}
setActions={(actions) =>
setContent((content) => ({
...content,
actions: actions(content.actions),
}))
courtImage={<Court courtType={courtType}/>}
courtRef={courtRef}
previewAction={previewAction}
renderComponent={(component) => {
if (
component.type == "player" ||
component.type == "phantom"
) {
return renderPlayer(component)
}
if (component.type == BALL_TYPE) {
return (
<CourtBall
key="ball"
ball={component}
onPosValidated={doMoveBall}
onMoves={() =>
setActions((actions) =>
repositionActionsRelatedTo(
component.id,
courtBounds(),
actions,
),
)
}
onRemove={() => {
setContent((content) =>
removeBall(content),
)
setObjects(objects => [...objects, {key: "ball"}])
}}
/>
)
}
throw new Error(
"unknown tactic component " + component,
)
}}
renderAction={(action, i) => (
<CourtAction
key={i}
action={action}
courtRef={courtDivContentRef}
courtRef={courtRef}
onActionDeleted={() => {
setContent((content) => ({
setContent((content) => {
content = {
...content,
actions: content.actions.toSpliced(
actions:
content.actions.toSpliced(
i,
1,
),
}))
}
if (action.toId == null)
return content
const target =
content.components.find(
(c) => action.toId == c.id,
)!
if (target.type == "phantom") {
const origin = getOrigin(
target,
content.components,
)
if (origin.id != action.fromId) {
return content
}
content = truncatePlayerPath(
origin,
target,
content,
)
}
return content
})
}}
onActionChanges={(a) =>
setContent((content) => ({
@ -507,25 +541,6 @@ function EditorView({
}
/>
)}
onPlayerChange={(player) => {
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
if (!isBoundsOnCourt(playerBounds)) {
removePlayer(player)
return
}
setContent((content) => ({
...content,
components: replaceOrInsert(
content.components,
player,
true,
),
}))
}}
onPlayerRemove={removePlayer}
onBallRemove={removeCourtBall}
/>
</div>
</div>
@ -537,46 +552,30 @@ function EditorView({
function isBallOnCourt(content: TacticContent) {
return (
content.components.findIndex(
(c) => (c.type == "player" && c.hasBall) || c.type == BALL_TYPE,
(c) => (c.type == "player" && c.ballState == BallState.HOLDS) || c.type == BALL_TYPE,
) != -1
)
}
function renderCourtObject(courtObject: RackedCourtObject) {
if (courtObject.key == "ball") {
return <BallPiece />
return <BallPiece/>
}
throw new Error("unknown racked court object " + courtObject.key)
}
function Court({ courtType }: { courtType: string }) {
function Court({courtType}: { courtType: string }) {
return (
<div id="court-image-div">
{courtType == "PLAIN" ? (
<PlainCourt id="court-image" />
<PlainCourt id="court-image"/>
) : (
<HalfCourt id="court-image" />
<HalfCourt id="court-image"/>
)}
</div>
)
}
function getRackPlayers(
team: PlayerTeam,
components: TacticComponent[],
): RackedPlayer[] {
return ["1", "2", "3", "4", "5"]
.filter(
(role) =>
components.findIndex(
(c) =>
c.type == "player" && c.team == team && c.role == role,
) == -1,
)
.map((key) => ({ team, key }))
}
function debounceAsync<A, B>(
f: (args: A) => Promise<B>,
delay = 1000,
@ -605,6 +604,7 @@ function useContentState<S>(
typeof newState === "function"
? (newState as (state: S) => S)(content)
: newState
if (state !== content) {
setSavingState(SaveStates.Saving)
saveStateCallback(state)
@ -619,12 +619,3 @@ function useContentState<S>(
return [content, setContentSynced, savingState]
}
function replaceOrInsert<A extends TacticComponent>(
array: A[],
it: A,
replace: boolean,
): A[] {
const idx = array.findIndex((i) => i.id == it.id)
return array.toSpliced(idx, 1, ...(replace ? [it] : []))
}

@ -47,7 +47,7 @@ export function CourtAction({
wavy={action.type == ActionKind.DRIBBLE}
//TODO place those magic values in constants
endRadius={action.toId ? 26 : 17}
startRadius={0}
startRadius={10}
onDeleteRequested={onActionDeleted}
style={{
head,

@ -1,7 +1,7 @@
<?php
/**
* @return PDO The PDO instance of the configuration's database connexion.
* @return PDO The PDO instance of the configuration's database connection.
*/
function get_database(): PDO {
// defined by profiles.

Loading…
Cancel
Save