Merge pull request 'Add Phantoms' (#95) from editor/phantoms into master
continuous-integration/drone/push Build is passing Details

Reviewed-on: #95
pull/107/head
Maxime BATISTA 1 year ago
commit 00da8d7f54

@ -13,6 +13,8 @@ module.exports = {
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended'
],
rules: {
},
settings: {
react: {
version: 'detect'

@ -4,13 +4,13 @@ import "../style/title_input.css"
export interface TitleInputOptions {
style: CSSProperties
default_value: string
on_validated: (a: string) => void
onValidated: (a: string) => void
}
export default function TitleInput({
style,
default_value,
on_validated,
onValidated,
}: TitleInputOptions) {
const [value, setValue] = useState(default_value)
const ref = useRef<HTMLInputElement>(null)
@ -23,7 +23,7 @@ export default function TitleInput({
type="text"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={(_) => on_validated(value)}
onBlur={(_) => onValidated(value)}
onKeyUp={(event) => {
if (event.key == "Enter") ref.current?.blur()
}}

@ -44,18 +44,16 @@ export default function ArrowAction({
)
}
export function ScreenHead() {
export function ScreenHead({ color }: { color: string }) {
return (
<div
style={{ backgroundColor: "black", height: "5px", width: "25px" }}
/>
<div style={{ backgroundColor: color, height: "5px", width: "25px" }} />
)
}
export function MoveToHead() {
export function MoveToHead({ color }: { color: string }) {
return (
<svg viewBox={"0 0 50 50"} width={20} height={20}>
<polygon points={"50 0, 0 0, 25 40"} fill="#000" />
<polygon points={"50 0, 0 0, 25 40"} fill={color} />
</svg>
)
}

@ -1,15 +1,19 @@
import { BallPiece } from "../editor/BallPiece"
import Draggable from "react-draggable"
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) {
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 />
</div>

@ -1,5 +1,6 @@
import {
CSSProperties,
MouseEvent as ReactMouseEvent,
ReactElement,
RefObject,
useCallback,
@ -7,29 +8,29 @@ import {
useLayoutEffect,
useRef,
useState,
MouseEvent as ReactMouseEvent,
} from "react"
import {
add,
angle,
middle,
distance,
middle,
middlePos,
minus,
mul,
norm,
NULL_POS,
Pos,
posWithinBase,
ratioWithinBase,
relativeTo,
norm,
} from "./Pos"
} from "../../geo/Pos"
import "../../style/bendable_arrows.css"
import Draggable from "react-draggable"
export interface BendableArrowProps {
area: RefObject<HTMLElement>
startPos: Pos
startPos: Pos | string
segments: Segment[]
onSegmentsChanges: (edges: Segment[]) => void
forceStraight: boolean
@ -46,16 +47,18 @@ export interface BendableArrowProps {
export interface ArrowStyle {
width?: number
dashArray?: string
color: string
head?: () => ReactElement
tail?: () => ReactElement
}
const ArrowStyleDefaults: ArrowStyle = {
width: 3,
color: "black",
}
export interface Segment {
next: Pos
next: Pos | string
controlPoint?: Pos
}
@ -134,7 +137,7 @@ export default function BendableArrow({
}
})
},
[segments, startPos],
[startPos],
)
// Cache the segments so that when the user is changing the segments (it moves an ArrowPoint),
@ -147,7 +150,7 @@ export default function BendableArrow({
// If the (original) segments changes, overwrite the current ones.
useLayoutEffect(() => {
setInternalSegments(computeInternalSegments(segments))
}, [startPos, segments, computeInternalSegments])
}, [computeInternalSegments, segments])
const [isSelected, setIsSelected] = useState(false)
@ -162,8 +165,8 @@ export default function BendableArrow({
return segments.flatMap(({ next, controlPoint }, i) => {
const prev = i == 0 ? startPos : segments[i - 1].next
const prevRelative = posWithinBase(prev, parentBase)
const nextRelative = posWithinBase(next, parentBase)
const prevRelative = getPosWithinBase(prev, parentBase)
const nextRelative = getPosWithinBase(next, parentBase)
const cpPos =
controlPoint ||
@ -204,7 +207,7 @@ export default function BendableArrow({
<ArrowPoint
key={i + "-2"}
className={"arrow-point-next"}
posRatio={next}
posRatio={getRatioWithinBase(next, parentBase)}
parentBase={parentBase}
onPosValidated={(next) => {
const currentSegment = segments[i]
@ -252,19 +255,19 @@ export default function BendableArrow({
const lastSegment = internalSegments[internalSegments.length - 1]
const startRelative = posWithinBase(startPos, parentBase)
const endRelative = posWithinBase(lastSegment.end, parentBase)
const startRelative = getPosWithinBase(startPos, parentBase)
const endRelative = getPosWithinBase(lastSegment.end, parentBase)
const startNext =
segment.controlPoint && !forceStraight
? posWithinBase(segment.controlPoint, parentBase)
: posWithinBase(segment.end, parentBase)
: getPosWithinBase(segment.end, parentBase)
const endPrevious = forceStraight
? startRelative
: lastSegment.controlPoint
? posWithinBase(lastSegment.controlPoint, parentBase)
: posWithinBase(lastSegment.start, parentBase)
: getPosWithinBase(lastSegment.start, parentBase)
const tailPos = constraintInCircle(
startRelative,
@ -309,15 +312,15 @@ export default function BendableArrow({
},
]
: internalSegments
).map(({ start, controlPoint, end }, idx) => {
).map(({ start, controlPoint, end }) => {
const svgPosRelativeToBase = { x: left, y: top }
const nextRelative = relativeTo(
posWithinBase(end, parentBase),
getPosWithinBase(end, parentBase),
svgPosRelativeToBase,
)
const startRelative = relativeTo(
posWithinBase(start, parentBase),
getPosWithinBase(start, parentBase),
svgPosRelativeToBase,
)
const controlPointRelative =
@ -355,14 +358,14 @@ export default function BendableArrow({
? add(start, previousSegmentCpAndCurrentPosVector)
: cp
if (wavy) {
return wavyBezier(start, smoothCp, cp, end, 10, 10)
}
if (forceStraight) {
return `L${end.x} ${end.y}`
}
if (wavy) {
return wavyBezier(start, smoothCp, cp, end, 10, 10)
}
return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}`
})
.join(" ")
@ -371,17 +374,34 @@ export default function BendableArrow({
pathRef.current!.setAttribute("d", d)
Object.assign(svgRef.current!.style, svgStyle)
}, [
startPos,
area,
internalSegments,
startPos,
forceStraight,
startRadius,
endRadius,
style,
wavy,
])
// Will update the arrow when the props change
useEffect(update, [update])
useEffect(() => {
const observer = new MutationObserver(update)
const config = { attributes: true }
if (typeof startPos == "string") {
observer.observe(document.getElementById(startPos)!, config)
}
for (const segment of segments) {
if (typeof segment.next == "string") {
observer.observe(document.getElementById(segment.next)!, config)
}
}
return () => observer.disconnect()
}, [startPos, segments, update])
// Adds a selection handler
// Also force an update when the window is resized
useEffect(() => {
@ -418,10 +438,16 @@ export default function BendableArrow({
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
const beforeSegment = i != 0 ? segments[i - 1] : undefined
const beforeSegmentPos = i > 1 ? segments[i - 2].next : startPos
const beforeSegmentPos = getRatioWithinBase(
i > 1 ? segments[i - 2].next : startPos,
parentBase,
)
const currentPos = beforeSegment ? beforeSegment.next : startPos
const nextPos = segment.next
const currentPos = getRatioWithinBase(
beforeSegment ? beforeSegment.next : startPos,
parentBase,
)
const nextPos = getRatioWithinBase(segment.next, parentBase)
const segmentCp = segment.controlPoint
? segment.controlPoint
: middle(currentPos, nextPos)
@ -493,7 +519,7 @@ export default function BendableArrow({
<path
className="arrow-path"
ref={pathRef}
stroke={"#000"}
stroke={style?.color ?? ArrowStyleDefaults.color}
strokeWidth={styleWidth}
strokeDasharray={
style?.dashArray ?? ArrowStyleDefaults.dashArray
@ -529,6 +555,24 @@ export default function BendableArrow({
)
}
function getPosWithinBase(target: Pos | string, area: DOMRect): Pos {
if (typeof target != "string") {
return posWithinBase(target, area)
}
const targetPos = document.getElementById(target)?.getBoundingClientRect()
return targetPos ? relativeTo(middlePos(targetPos), area) : NULL_POS
}
function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos {
if (typeof target != "string") {
return target
}
const targetPos = document.getElementById(target)?.getBoundingClientRect()
return targetPos ? ratioWithinBase(middlePos(targetPos), area) : NULL_POS
}
interface ControlPointProps {
className: string
posRatio: Pos
@ -546,9 +590,9 @@ enum PointSegmentSearchResult {
}
interface FullSegment {
start: Pos
start: Pos | string
controlPoint: Pos | null
end: Pos
end: Pos | string
}
/**

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

@ -1,149 +1,42 @@
import { CourtBall } from "./CourtBall"
import {
ReactElement,
ReactNode,
RefObject,
useCallback,
useEffect,
useLayoutEffect,
useState,
} from "react"
import CourtPlayer from "./CourtPlayer"
import { Action } from "../../model/tactic/Action"
import { Player } from "../../model/tactic/Player"
import { Action, ActionKind } from "../../model/tactic/Action"
import ArrowAction from "../actions/ArrowAction"
import { middlePos, ratioWithinBase } from "../arrows/Pos"
import BallAction from "../actions/BallAction"
import { CourtObject } from "../../model/tactic/Ball"
import { contains } from "../arrows/Box"
import { CourtAction } from "../../views/editor/CourtAction"
import { ComponentId, TacticComponent } from "../../model/tactic/Tactic"
export interface BasketCourtProps {
players: Player[]
actions: Action[]
objects: CourtObject[]
renderAction: (a: Action, key: number) => ReactElement
setActions: (f: (a: Action[]) => Action[]) => void
onPlayerRemove: (p: Player) => void
onPlayerChange: (p: Player) => void
components: TacticComponent[]
previewAction: ActionPreview | null
onBallRemove: () => void
onBallMoved: (ball: DOMRect) => void
renderComponent: (comp: TacticComponent) => ReactNode
renderActions: (comp: TacticComponent) => ReactNode[]
courtImage: ReactElement
courtRef: RefObject<HTMLDivElement>
}
export interface ActionPreview extends Action {
origin: ComponentId
isInvalid: boolean
}
export function BasketCourt({
players,
actions,
objects,
renderAction,
setActions,
onPlayerRemove,
onPlayerChange,
components,
previewAction,
onBallMoved,
onBallRemove,
renderComponent,
renderActions,
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 player of players) {
if (player.id == origin.id) {
continue
}
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
if (
!(
playerBounds.top > arrowHead.bottom ||
playerBounds.right < arrowHead.left ||
playerBounds.bottom < arrowHead.top ||
playerBounds.left > arrowHead.right
)
) {
const targetPos = document
.getElementById(player.id)!
.getBoundingClientRect()
const end = ratioWithinBase(middlePos(targetPos), courtBounds)
const action: Action = {
fromPlayerId: originRef.id,
toPlayerId: player.id,
type: origin.hasBall ? ActionKind.SHOOT : ActionKind.SCREEN,
moveFrom: start,
segments: [{ next: end }],
}
setActions((actions) => [...actions, action])
return
}
}
const action: Action = {
fromPlayerId: 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((player: Player) => {
const newPos = ratioWithinBase(
middlePos(
document.getElementById(player.id)!.getBoundingClientRect(),
),
courtRef.current!.getBoundingClientRect(),
)
setActions((actions) =>
actions.map((a) => {
if (a.fromPlayerId == player.id) {
return { ...a, moveFrom: newPos }
}
if (a.toPlayerId == player.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])
return (
<div
className="court-container"
@ -151,118 +44,16 @@ export function BasketCourt({
style={{ position: "relative" }}>
{courtImage}
{players.map((player) => (
<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 = players.find(
(p) =>
p != player &&
contains(
document
.getElementById(p.id)!
.getBoundingClientRect(),
arrowHeadPos,
),
)
setPreviewAction((action) => ({
...action!,
segments: [
{
next: ratioWithinBase(
arrowHeadPos,
baseBounds,
),
},
],
type: player.hasBall
? target
? ActionKind.SHOOT
: ActionKind.DRIBBLE
: target
? ActionKind.SCREEN
: ActionKind.MOVE,
}))
}}
onHeadPicked={(headPos) => {
;(document.activeElement as HTMLElement).blur()
const baseBounds =
courtRef.current!.getBoundingClientRect()
setPreviewAction({
type: player.hasBall
? ActionKind.DRIBBLE
: ActionKind.MOVE,
fromPlayerId: player.id,
toPlayerId: 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())
}
/>
),
]}
/>
))}
{internActions.map((action, idx) => renderAction(action, idx))}
{objects.map((object) => {
if (object.type == "ball") {
return (
<CourtBall
onMoved={onBallMoved}
ball={object}
onRemove={onBallRemove}
key="ball"
/>
)
}
throw new Error("unknown court object" + object.type)
})}
{components.map(renderComponent)}
{components.flatMap(renderActions)}
{previewAction && (
<CourtAction
courtRef={courtRef}
action={previewAction}
//do nothing on change, not really possible as it's a preview arrow
origin={previewAction.origin}
isInvalid={previewAction.isInvalid}
//do nothing on interacted, not really possible as it's a preview arrow
onActionDeleted={() => {}}
onActionChanges={() => {}}
/>

@ -1,15 +1,16 @@
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 {
onMoved: (rect: DOMRect) => void
onPosValidated: (rect: DOMRect) => void
onRemove: () => void
ball: Ball
}
export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) {
export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) {
const pieceRef = useRef<HTMLDivElement>(null)
const x = ball.rightRatio
@ -17,7 +18,10 @@ export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) {
return (
<Draggable
onStop={() => onMoved(pieceRef.current!.getBoundingClientRect())}
onStop={() =>
onPosValidated(pieceRef.current!.getBoundingClientRect())
}
position={NULL_POS}
nodeRef={pieceRef}>
<div
className={"ball-div"}

@ -1,14 +1,15 @@
import { ReactNode, RefObject, useRef } from "react"
import React, { ReactNode, RefObject, useCallback, useRef } from "react"
import "../../style/player.css"
import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../model/tactic/Player"
import { NULL_POS, ratioWithinBase } from "../arrows/Pos"
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
onPositionValidated: (newPos: Pos) => void
onRemove: () => void
courtRef: RefObject<HTMLElement>
availableActions: (ro: HTMLElement) => ReactNode[]
@ -18,44 +19,37 @@ 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,
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}
//The piece is positioned using top/bottom style attributes instead
position={NULL_POS}
onStop={() => {
onStop={useCallback(() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = courtRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(pieceBounds, parentBounds)
const pos = ratioWithinBase(pieceBounds, parentBounds)
onChange({
id: player.id,
rightRatio: x,
bottomRatio: y,
team: player.team,
role: player.role,
hasBall: player.hasBall,
})
}}>
if (pos.x !== x || pos.y != y) onPositionValidated(pos)
}, [courtRef, onPositionValidated, x, y])}>
<div
id={player.id}
id={playerInfo.id}
ref={pieceRef}
className="player"
className={"player " + (className ?? "")}
style={{
position: "absolute",
left: `${x * 100}%`,
@ -64,16 +58,19 @@ export default function CourtPlayer({
<div
tabIndex={0}
className="player-content"
onKeyUp={(e) => {
onKeyUp={useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key == "Delete") onRemove()
}}>
},
[onRemove],
)}>
<div className="player-actions">
{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,510 @@
import {
BallState,
Player,
PlayerPhantom,
PlayerLike,
} from "../model/tactic/Player"
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 {
areInSamePath,
changePlayerBallState,
getOrigin,
isNextInPath,
removePlayer,
} from "./PlayerDomains"
import { BALL_TYPE } from "../model/tactic/CourtObjects"
export function getActionKind(
target: TacticComponent | null,
ballState: BallState,
): { kind: ActionKind; nextState: BallState } {
switch (ballState) {
case BallState.HOLDS_ORIGIN:
return target
? { kind: ActionKind.SHOOT, nextState: BallState.PASSED_ORIGIN }
: { kind: ActionKind.DRIBBLE, nextState: ballState }
case BallState.HOLDS_BY_PASS:
return target
? { kind: ActionKind.SHOOT, nextState: BallState.PASSED }
: { kind: ActionKind.DRIBBLE, nextState: ballState }
case BallState.PASSED_ORIGIN:
case BallState.PASSED:
case BallState.NONE:
return {
kind:
target && target.type != BALL_TYPE
? ActionKind.SCREEN
: ActionKind.MOVE,
nextState: ballState,
}
}
}
export function getActionKindBetween(
origin: PlayerLike,
target: TacticComponent | null,
state: BallState,
): { kind: ActionKind; nextState: BallState } {
//remove the target if the target is a phantom that is within the origin's path
if (
target != null &&
target.type == "phantom" &&
areInSamePath(origin, target)
) {
target = null
}
return getActionKind(target, state)
}
export function isActionValid(
origin: TacticComponent,
target: TacticComponent | null,
components: TacticComponent[],
): boolean {
/// action is valid if the origin is neither a phantom nor a player
if (origin.type != "phantom" && origin.type != "player") {
return true
}
// action is invalid if the origin already moves (unless the origin holds a ball which will lead to a ball pass)
if (
origin.ballState != BallState.HOLDS_BY_PASS &&
origin.ballState != BallState.HOLDS_ORIGIN &&
origin.actions.find((a) => moves(a.type))
) {
return false
}
//Action is valid if the target is null
if (target == null) {
return true
}
// action is invalid if it targets its own origin
if (origin.id === target.id) {
return false
}
// action is invalid if the target already moves and is not indirectly bound with origin
if (
target.actions.find((a) => moves(a.type)) &&
(hasBoundWith(target, origin, components) ||
hasBoundWith(origin, target, components))
) {
return false
}
// Action is invalid if there is already an action between origin and target.
if (
origin.actions.find((a) => a.target === target?.id) ||
target?.actions.find((a) => a.target === origin.id)
) {
return false
}
// Action is invalid if there is already an anterior action within the target's path
if (target.type == "phantom" || target.type == "player") {
// cant have an action with current path
if (areInSamePath(origin, target)) return false
if (alreadyHasAnAnteriorActionWith(origin, target, components)) {
return false
}
}
return true
}
function hasBoundWith(
origin: TacticComponent,
target: TacticComponent,
components: TacticComponent[],
): boolean {
const toVisit = [origin.id]
const visited: string[] = []
let itemId: string | undefined
while ((itemId = toVisit.pop())) {
if (visited.indexOf(itemId) !== -1) continue
visited.push(itemId)
const item = components.find((c) => c.id === itemId)!
const itemBounds = item.actions.flatMap((a) =>
typeof a.target == "string" ? [a.target] : [],
)
if (itemBounds.indexOf(target.id) !== -1) {
return true
}
toVisit.push(...itemBounds)
}
return false
}
function alreadyHasAnAnteriorActionWith(
origin: PlayerLike,
target: PlayerLike,
components: TacticComponent[],
): boolean {
const targetOrigin =
target.type === "phantom" ? getOrigin(target, components) : target
const targetOriginPath = [
targetOrigin.id,
...(targetOrigin.path?.items ?? []),
]
const originOrigin =
origin.type === "phantom" ? getOrigin(origin, components) : origin
const originOriginPath = [
originOrigin.id,
...(originOrigin.path?.items ?? []),
]
const targetIdx = targetOriginPath.indexOf(target.id)
for (let i = targetIdx; i < targetOriginPath.length; i++) {
const phantom = components.find(
(c) => c.id === targetOriginPath[i],
)! as PlayerLike
if (
phantom.actions.find(
(a) =>
typeof a.target === "string" &&
moves(a.type) &&
originOriginPath.indexOf(a.target) !== -1,
)
) {
return true
}
}
const originIdx = originOriginPath.indexOf(origin.id)
for (let i = 0; i <= originIdx; i++) {
const phantom = components.find(
(c) => c.id === originOriginPath[i],
)! as PlayerLike
if (
phantom.actions.find(
(a) =>
typeof a.target === "string" &&
moves(a.type) &&
targetOriginPath.indexOf(a.target) > targetIdx,
)
) {
return true
}
}
return false
}
export function createAction(
origin: PlayerLike,
courtBounds: DOMRect,
arrowHead: DOMRect,
content: TacticContent,
): { createdAction: Action; newContent: TacticContent } {
/**
* Creates a new phantom component.
* Be aware that this function will reassign the `content` parameter.
*/
function createPhantom(forceHasBall: 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,
)
let phantomState: BallState
if (forceHasBall) phantomState = BallState.HOLDS_ORIGIN
else
switch (origin.ballState) {
case BallState.HOLDS_ORIGIN:
phantomState = BallState.HOLDS_BY_PASS
break
case BallState.PASSED:
case BallState.PASSED_ORIGIN:
phantomState = BallState.NONE
break
default:
phantomState = origin.ballState
}
const phantom: PlayerPhantom = {
type: "phantom",
id: phantomId,
rightRatio: x,
bottomRatio: y,
originPlayerId: originPlayer.id,
ballState: phantomState,
actions: [],
}
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)) {
let toId = component.id
if (component.type == "ball") {
toId = createPhantom(true)
content = removeBall(content)
}
const action: Action = {
target: toId,
type: getActionKind(component, origin.ballState).kind,
segments: [{ next: toId }],
}
return {
newContent: updateComponent(
{
...content.components.find((c) => c.id == origin.id)!,
actions: [...origin.actions, action],
},
content,
),
createdAction: action,
}
}
}
const phantomId = createPhantom(false)
const action: Action = {
target: phantomId,
type: getActionKind(null, origin.ballState).kind,
segments: [{ next: phantomId }],
}
return {
newContent: updateComponent(
{
...content.components.find((c) => c.id == origin.id)!,
actions: [...origin.actions, action],
},
content,
),
createdAction: action,
}
}
export function removeAllActionsTargeting(
componentId: ComponentId,
content: TacticContent,
): TacticContent {
const components = []
for (let i = 0; i < content.components.length; i++) {
const component = content.components[i]
components.push({
...component,
actions: component.actions.filter((a) => a.target != componentId),
})
}
return {
...content,
components,
}
}
export function removeAction(
origin: TacticComponent,
action: Action,
actionIdx: number,
content: TacticContent,
): TacticContent {
origin = {
...origin,
actions: origin.actions.toSpliced(actionIdx, 1),
}
content = updateComponent(origin, content)
if (action.target == null) return content
const target = content.components.find((c) => action.target == c.id)!
// if the removed action is a shoot, set the origin as holding the ball
if (
action.type == ActionKind.SHOOT &&
(origin.type === "player" || origin.type === "phantom")
) {
if (target.type === "player" || target.type === "phantom")
content = changePlayerBallState(target, BallState.NONE, content)
if (origin.ballState === BallState.PASSED) {
content = changePlayerBallState(
origin,
BallState.HOLDS_BY_PASS,
content,
)
} else if (origin.ballState === BallState.PASSED_ORIGIN) {
content = changePlayerBallState(
origin,
BallState.HOLDS_ORIGIN,
content,
)
}
}
if (target.type === "phantom") {
let path = null
if (origin.type === "player") {
path = origin.path
} else if (origin.type === "phantom") {
path = getOrigin(origin, content.components).path
}
if (path != null && path.items.find((c) => c === target.id)) {
content = removePlayer(target, content)
}
}
return content
}
/**
* Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with
* the given newState.
* @param origin
* @param newState
* @param content
*/
export function spreadNewStateFromOriginStateChange(
origin: PlayerLike,
newState: BallState,
content: TacticContent,
): TacticContent {
if (origin.ballState === newState) {
return content
}
origin = {
...origin,
ballState: newState,
}
content = updateComponent(origin, content)
for (let i = 0; i < origin.actions.length; i++) {
const action = origin.actions[i]
if (typeof action.target !== "string") {
continue
}
const actionTarget = content.components.find(
(c) => action.target === c.id,
)! as PlayerLike
let targetState: BallState = actionTarget.ballState
let deleteAction = false
if (isNextInPath(origin, actionTarget, content.components)) {
switch (newState) {
case BallState.PASSED:
case BallState.PASSED_ORIGIN:
targetState = BallState.NONE
break
case BallState.HOLDS_ORIGIN:
targetState = BallState.HOLDS_BY_PASS
break
default:
targetState = newState
}
} else if (
newState === BallState.NONE &&
action.type === ActionKind.SHOOT
) {
/// if the new state removes the ball from the player, remove all actions that were meant to shoot the ball
deleteAction = true
targetState = BallState.NONE // Then remove the ball for the target as well
} else if (
(newState === BallState.HOLDS_BY_PASS ||
newState === BallState.HOLDS_ORIGIN) &&
action.type === ActionKind.SCREEN
) {
targetState = BallState.HOLDS_BY_PASS
}
if (deleteAction) {
content = removeAction(origin, action, i, content)
origin = content.components.find((c) => c.id === origin.id)! as
| Player
| PlayerPhantom
i-- // step back
} else {
// do not change the action type if it is a shoot action
const { kind, nextState } = getActionKindBetween(
origin,
actionTarget,
newState,
)
origin = {
...origin,
ballState: nextState,
actions: origin.actions.toSpliced(i, 1, {
...action,
type: kind,
}),
}
content = updateComponent(origin, content)
}
content = spreadNewStateFromOriginStateChange(
actionTarget,
targetState,
content,
)
}
return content
}

@ -0,0 +1,149 @@
import {
BallState,
Player,
PlayerLike,
PlayerPhantom,
} from "../model/tactic/Player"
import { TacticComponent, TacticContent } from "../model/tactic/Tactic"
import { removeComponent, updateComponent } from "./TacticContentDomains"
import {
removeAllActionsTargeting,
spreadNewStateFromOriginStateChange,
} from "./ActionsDomains"
import { ActionKind } from "../model/tactic/Action"
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 areInSamePath(a: PlayerLike, b: PlayerLike) {
if (a.type === "phantom" && b.type === "phantom") {
return a.originPlayerId === b.originPlayerId
}
if (a.type === "phantom") {
return b.id === a.originPlayerId
}
if (b.type === "phantom") {
return a.id === b.originPlayerId
}
return false
}
/**
* @param origin
* @param other
* @param components
* @returns true if the `other` player is the phantom next-to the origin's path.
*/
export function isNextInPath(
origin: PlayerLike,
other: PlayerLike,
components: TacticComponent[],
): boolean {
if (origin.type === "player") {
return origin.path?.items[0] === other.id
}
const originPath = getOrigin(origin, components).path!
return (
originPath.items!.indexOf(origin.id) ===
originPath.items!.indexOf(other.id) - 1
)
}
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)
content = removeAllActionsTargeting(pathElement, content)
}
return updateComponent(
{
...player,
path: null,
},
content,
)
}
export function removePlayer(
player: PlayerLike,
content: TacticContent,
): TacticContent {
content = removeAllActionsTargeting(player.id, content)
if (player.type == "phantom") {
const origin = getOrigin(player, content.components)
return truncatePlayerPath(origin, player, content)
}
content = removePlayerPath(player, content)
content = removeComponent(player.id, content)
for (const action of player.actions) {
if (action.type !== ActionKind.SHOOT) {
continue
}
const actionTarget = content.components.find(
(c) => c.id === action.target,
)! as PlayerLike
return spreadNewStateFromOriginStateChange(
actionTarget,
BallState.NONE,
content,
)
}
return content
}
export function truncatePlayerPath(
player: Player,
phantom: PlayerPhantom,
content: TacticContent,
): TacticContent {
if (player.path == null) return content
const path = player.path!
const truncateStartIdx = path.items.indexOf(phantom.id)
for (let i = truncateStartIdx; i < path.items.length; i++) {
const pathPhantomId = path.items[i]
//remove the phantom from the tactic
content = removeComponent(pathPhantomId, content)
content = removeAllActionsTargeting(pathPhantomId, content)
}
return updateComponent(
{
...player,
path:
truncateStartIdx == 0
? null
: {
...path,
items: path.items.toSpliced(truncateStartIdx),
},
},
content,
)
}
export function changePlayerBallState(
player: PlayerLike,
newState: BallState,
content: TacticContent,
): TacticContent {
return spreadNewStateFromOriginStateChange(player, newState, 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,243 @@
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 }))
}

@ -28,6 +28,14 @@ export function surrounds(pos: Pos, width: number, height: number): Box {
}
}
export function overlaps(a: Box, b: Box): boolean {
if (a.x + a.width < b.x || b.x + b.width < a.x) {
return false
}
return !(a.y + a.height < b.y || b.y + b.height < a.y)
}
export function contains(box: Box, pos: Pos): boolean {
return (
pos.x >= box.x &&

@ -1,6 +1,6 @@
import { Pos } from "../../components/arrows/Pos"
import { Pos } from "../../geo/Pos"
import { Segment } from "../../components/arrows/BendableArrow"
import { PlayerId } from "./Player"
import { ComponentId } from "./Tactic"
export enum ActionKind {
SCREEN = "SCREEN",
@ -12,8 +12,10 @@ export enum ActionKind {
export type Action = { type: ActionKind } & MovementAction
export interface MovementAction {
fromPlayerId: PlayerId
toPlayerId?: PlayerId
moveFrom: Pos
target: ComponentId | Pos
segments: Segment[]
}
export function moves(kind: ActionKind): boolean {
return kind != ActionKind.SHOOT
}

@ -1,17 +0,0 @@
export type CourtObject = { type: "ball" } & Ball
export interface Ball {
/**
* The ball is a "ball" court object
*/
readonly type: "ball"
/**
* 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
}

@ -0,0 +1,9 @@
import { Component } from "./Tactic"
export const BALL_ID = "ball"
export const BALL_TYPE = "ball"
//place here all different kinds of objects
export type CourtObject = Ball
export type Ball = Component<typeof BALL_TYPE>

@ -1,13 +1,23 @@
import { Component, ComponentId } from "./Tactic"
export type PlayerId = string
export type PlayerLike = Player | PlayerPhantom
export enum PlayerTeam {
Allies = "allies",
Opponents = "opponents",
}
export interface Player {
export interface Player extends PlayerInfo, Component<"player"> {
readonly id: PlayerId
}
/**
* All information about a player
*/
export interface PlayerInfo {
readonly id: string
/**
* the player's team
* */
@ -18,6 +28,11 @@ export interface Player {
* */
readonly role: string
/**
* True if the player has a basketball
*/
readonly ballState: BallState
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
@ -27,9 +42,34 @@ export interface Player {
* 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_ORIGIN,
HOLDS_BY_PASS,
PASSED,
PASSED_ORIGIN,
}
export interface Player extends Component<"player">, PlayerInfo {
/**
* True if the player has a basketball
*/
readonly hasBall: boolean
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 { CourtObject } from "./Ball"
import { Player, PlayerPhantom } from "./Player"
import { Action } from "./Action"
import { CourtObject } from "./CourtObjects"
export interface Tactic {
id: number
@ -9,7 +9,31 @@ export interface Tactic {
}
export interface TacticContent {
players: Player[]
objects: CourtObject[]
actions: Action[]
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[]
}

@ -5,6 +5,7 @@
.arrow-action-icon {
user-select: none;
-moz-user-select: none;
-webkit-user-drag: none;
max-width: 17px;
max-height: 17px;
}

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

@ -1,8 +1,10 @@
import {
CSSProperties,
Dispatch,
RefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
@ -16,22 +18,55 @@ import { BallPiece } from "../components/editor/BallPiece"
import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
import { Player } from "../model/tactic/Player"
import { Tactic, TacticContent } from "../model/tactic/Tactic"
import { Tactic, TacticComponent, TacticContent } from "../model/tactic/Tactic"
import { fetchAPI } from "../Fetcher"
import { PlayerTeam } from "../model/tactic/Player"
import SavingState, {
SaveState,
SaveStates,
} from "../components/editor/SavingState"
import { CourtObject } from "../model/tactic/Ball"
import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "./editor/CourtAction"
import { BasketCourt } from "../components/editor/BasketCourt"
import { ratioWithinBase } from "../components/arrows/Pos"
import { ActionPreview, 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,
PlayerLike,
PlayerTeam,
} from "../model/tactic/Player"
import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems"
import CourtPlayer from "../components/editor/CourtPlayer"
import {
createAction,
getActionKind,
isActionValid,
removeAction,
} from "../editor/ActionsDomains"
import ArrowAction from "../components/actions/ArrowAction"
import { middlePos, Pos, ratioWithinBase } from "../geo/Pos"
import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction"
import {
changePlayerBallState,
getOrigin,
removePlayer,
} from "../editor/PlayerDomains"
import { CourtBall } from "../components/editor/CourtBall"
import { BASE } from "../Constants"
const ERROR_STYLE: CSSProperties = {
@ -55,16 +90,6 @@ 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) {
const isInGuestMode = id == -1
@ -126,263 +151,278 @@ function EditorView({
),
)
const [allies, setAllies] = useState(
getRackPlayers(PlayerTeam.Allies, content.players),
const [allies, setAllies] = useState(() =>
getRackPlayers(PlayerTeam.Allies, content.components),
)
const [opponents, setOpponents] = useState(
getRackPlayers(PlayerTeam.Opponents, content.players),
const [opponents, setOpponents] = useState(() =>
getRackPlayers(PlayerTeam.Opponents, content.components),
)
const [objects, setObjects] = useState<RackedCourtObject[]>(
const [objects, setObjects] = useState<RackedCourtObject[]>(() =>
isBallOnCourt(content) ? [] : [{ key: "ball" }],
)
const courtDivContentRef = useRef<HTMLDivElement>(null)
const [previewAction, setPreviewAction] = useState<ActionPreview | null>(
null,
)
const isBoundsOnCourt = (bounds: DOMRect) => {
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const courtRef = useRef<HTMLDivElement>(null)
// check if refBounds overlaps courtBounds
return !(
bounds.top > courtBounds.bottom ||
bounds.right < courtBounds.left ||
bounds.bottom < courtBounds.top ||
bounds.left > courtBounds.right
)
const setComponents = (action: SetStateAction<TacticComponent[]>) => {
setContent((c) => ({
...c,
components:
typeof action == "function" ? action(c.components) : action,
}))
}
const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
)
const { x, y } = ratioWithinBase(refBounds, courtBounds)
useEffect(() => {
setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }])
}, [setObjects, content])
setContent((content) => {
return {
...content,
players: [
...content.players,
const insertRackedPlayer = (player: Player) => {
let setter
switch (player.team) {
case PlayerTeam.Opponents:
setter = setOpponents
break
case PlayerTeam.Allies:
setter = setAllies
}
if (player.ballState == BallState.HOLDS_BY_PASS) {
setObjects([{ key: "ball" }])
}
setter((players) => [
...players,
{
id: "player-" + element.key + "-" + element.team,
team: element.team,
role: element.key,
rightRatio: x,
bottomRatio: y,
hasBall: false,
team: player.team,
pos: player.role,
key: player.role,
},
],
actions: content.actions,
}
})
])
}
const onObjectDetach = (
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":
const ballObj = content.objects.findIndex(
(o) => o.type == "ball",
)
const playerCollidedIdx = getPlayerCollided(
refBounds,
content.players,
const doRemovePlayer = useCallback(
(component: PlayerLike) => {
setContent((c) => removePlayer(component, c))
if (component.type == "player") insertRackedPlayer(component)
},
[setContent],
)
if (playerCollidedIdx != -1) {
onBallDropOnPlayer(playerCollidedIdx)
setContent((content) => {
return {
...content,
objects: content.objects.toSpliced(ballObj, 1),
}
})
return
}
courtObject = {
type: "ball",
rightRatio: x,
bottomRatio: y,
const doMoveBall = useCallback(
(newBounds: DOMRect, from?: PlayerLike) => {
setContent((content) => {
if (from) {
content = changePlayerBallState(
from,
BallState.NONE,
content,
)
}
break
default:
throw new Error("unknown court object " + rackedObject.key)
}
content = placeBallAt(newBounds, courtBounds(), content)
setContent((content) => {
return {
...content,
objects: [...content.objects, courtObject],
}
return content
})
}
const getPlayerCollided = (
bounds: DOMRect,
players: Player[],
): number | -1 => {
for (let i = 0; i < players.length; i++) {
const player = players[i]
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
const doesOverlap = !(
bounds.top > playerBounds.bottom ||
bounds.right < playerBounds.left ||
bounds.bottom < playerBounds.top ||
bounds.left > playerBounds.right
},
[courtBounds, setContent],
)
if (doesOverlap) {
return i
}
}
return -1
}
function updateActions(actions: Action[], players: Player[]) {
return actions.map((action) => {
const originHasBall = players.find(
(p) => p.id == action.fromPlayerId,
)!.hasBall
let type = action.type
const validatePlayerPosition = useCallback(
(player: PlayerLike, info: PlayerInfo, newPos: Pos) => {
setContent((content) =>
moveComponent(
newPos,
player,
info,
courtBounds(),
content,
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,
}
})
}
(content) => {
if (player.type == "player") insertRackedPlayer(player)
return removePlayer(player, content)
},
),
)
},
[courtBounds, setContent],
)
const onBallDropOnPlayer = (playerCollidedIdx: number) => {
setContent((content) => {
const ballObj = content.objects.findIndex((o) => o.type == "ball")
let player = content.players.at(playerCollidedIdx) as Player
const players = content.players.toSpliced(playerCollidedIdx, 1, {
...player,
hasBall: true,
})
return {
...content,
actions: updateActions(content.actions, players),
players,
objects: content.objects.toSpliced(ballObj, 1),
}
})
}
const renderAvailablePlayerActions = useCallback(
(info: PlayerInfo, player: PlayerLike) => {
let canPlaceArrows: boolean
if (player.type == "player") {
canPlaceArrows =
player.path == null ||
player.actions.findIndex(
(p) => p.type != ActionKind.SHOOT,
) == -1
} else {
const origin = getOrigin(player, content.components)
const path = origin.path!
// phantoms can only place other arrows if they are the head of the path
canPlaceArrows =
path.items.indexOf(player.id) == path.items.length - 1
if (canPlaceArrows) {
// and if their only action is to shoot the ball
const phantomActions = player.actions
canPlaceArrows =
phantomActions.length == 0 ||
phantomActions.findIndex(
(c) => c.type != ActionKind.SHOOT,
) == -1
}
}
return [
canPlaceArrows && (
<CourtPlayerArrowAction
key={1}
player={player}
isInvalid={previewAction?.isInvalid ?? false}
setPreviewAction={setPreviewAction}
playerInfo={info}
content={content}
courtRef={courtRef}
setContent={setContent}
/>
),
(info.ballState === BallState.HOLDS_ORIGIN ||
info.ballState === BallState.PASSED_ORIGIN) && (
<BallAction
key={2}
onDrop={(ballBounds) => {
doMoveBall(ballBounds, player)
}}
/>
),
]
},
[content, doMoveBall, previewAction?.isInvalid, setContent],
)
const onBallDrop = (refBounds: DOMRect) => {
if (!isBoundsOnCourt(refBounds)) {
removeCourtBall()
return
}
const playerCollidedIdx = getPlayerCollided(refBounds, content.players)
if (playerCollidedIdx != -1) {
setContent((content) => {
return {
...content,
players: content.players.map((player) => ({
...player,
hasBall: false,
})),
const renderPlayer = useCallback(
(component: PlayerLike) => {
let info: PlayerInfo
const isPhantom = component.type == "phantom"
if (isPhantom) {
const origin = getOrigin(component, content.components)
info = {
id: component.id,
team: origin.team,
role: origin.role,
bottomRatio: component.bottomRatio,
rightRatio: component.rightRatio,
ballState: component.ballState,
}
})
onBallDropOnPlayer(playerCollidedIdx)
return
}
if (content.objects.findIndex((o) => o.type == "ball") != -1) {
return
} else {
info = component
}
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject
courtObject = {
type: "ball",
rightRatio: x,
bottomRatio: y,
return (
<CourtPlayer
key={component.id}
className={isPhantom ? "phantom" : "player"}
playerInfo={info}
onPositionValidated={(newPos) =>
validatePlayerPosition(component, info, newPos)
}
onRemove={() => doRemovePlayer(component)}
courtRef={courtRef}
availableActions={() =>
renderAvailablePlayerActions(info, component)
}
/>
)
},
[
content.components,
doRemovePlayer,
renderAvailablePlayerActions,
validatePlayerPosition,
],
)
const players = content.players.map((player) => ({
...player,
hasBall: false,
}))
setContent((content) => {
return {
...content,
actions: updateActions(content.actions, players),
players,
objects: [...content.objects, courtObject],
}
})
}
const doDeleteAction = useCallback(
(action: Action, idx: number, origin: TacticComponent) => {
setContent((content) => removeAction(origin, action, idx, content))
},
[setContent],
)
const removePlayer = (player: Player) => {
setContent((content) => ({
...content,
players: toSplicedPlayers(content.players, player, false),
objects: [...content.objects],
actions: content.actions.filter(
(a) =>
a.toPlayerId !== player.id && a.fromPlayerId !== player.id,
),
}))
let setter
switch (player.team) {
case PlayerTeam.Opponents:
setter = setOpponents
break
case PlayerTeam.Allies:
setter = setAllies
}
if (player.hasBall) {
setObjects([{ key: "ball" }])
}
setter((players) => [
...players,
const doUpdateAction = useCallback(
(component: TacticComponent, action: Action, actionIndex: number) => {
setContent((content) =>
updateComponent(
{
team: player.team,
pos: player.role,
key: player.role,
...component,
actions: component.actions.toSpliced(
actionIndex,
1,
action,
),
},
content,
),
)
},
[setContent],
)
const renderComponent = useCallback(
(component: TacticComponent) => {
if (component.type == "player" || component.type == "phantom") {
return renderPlayer(component)
}
if (component.type == BALL_TYPE) {
return (
<CourtBall
key="ball"
ball={component}
onPosValidated={doMoveBall}
onRemove={() => {
setContent((content) => removeBall(content))
setObjects((objects) => [
...objects,
{ key: "ball" },
])
}}
/>
)
}
throw new Error("unknown tactic component " + component)
},
[renderPlayer, doMoveBall, setContent],
)
const removeCourtBall = () => {
setContent((content) => {
const ballObj = content.objects.findIndex((o) => o.type == "ball")
return {
...content,
players: content.players.map((player) => ({
...player,
hasBall: false,
})),
objects: content.objects.toSpliced(ballObj, 1),
}
})
setObjects([{ key: "ball" }])
const renderActions = useCallback(
(component: TacticComponent) =>
component.actions.map((action, i) => {
return (
<CourtAction
key={"action-" + component.id + "-" + i}
action={action}
origin={component.id}
courtRef={courtRef}
isInvalid={false}
onActionDeleted={() => {
doDeleteAction(action, i, component)
}}
onActionChanges={(action) =>
doUpdateAction(component, action, i)
}
/>
)
}),
[doDeleteAction, doUpdateAction],
)
return (
<div id="main-div">
@ -397,138 +437,262 @@ function EditorView({
<TitleInput
style={titleStyle}
default_value={name}
on_validated={(new_name) => {
onValidated={useCallback(
(new_name) => {
onNameChange(new_name).then((success) => {
setTitleStyle(success ? {} : ERROR_STYLE)
})
}}
},
[onNameChange],
)}
/>
</div>
<div id="topbar-right" />
</div>
<div id="edit-div">
<div id="racks">
<Rack
id="allies-rack"
<PlayerRack
id={"allies"}
objects={allies}
onChange={setAllies}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
)}
setObjects={setAllies}
setComponents={setComponents}
courtRef={courtRef}
/>
<Rack
id={"objects"}
objects={objects}
onChange={setObjects}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onObjectDetach}
canDetach={useCallback(
(div) =>
overlaps(
courtBounds(),
div.getBoundingClientRect(),
),
[courtBounds],
)}
onElementDetached={useCallback(
(r, e: RackedCourtObject) =>
setContent((content) =>
placeObjectAt(
r.getBoundingClientRect(),
courtBounds(),
e,
content,
),
),
[courtBounds, setContent],
)}
render={renderCourtObject}
/>
<Rack
id="opponent-rack"
<PlayerRack
id={"opponents"}
objects={opponents}
onChange={setOpponents}
canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
setObjects={setOpponents}
setComponents={setComponents}
courtRef={courtRef}
/>
</div>
<div id="court-div">
<div id="court-div-bounds">
<BasketCourt
components={content.components}
courtImage={<Court courtType={courtType} />}
courtRef={courtRef}
previewAction={previewAction}
renderComponent={renderComponent}
renderActions={renderActions}
/>
</div>
</div>
</div>
</div>
)
}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
interface PlayerRackProps {
id: string
objects: RackedPlayer[]
setObjects: (state: RackedPlayer[]) => void
setComponents: (
f: (components: TacticComponent[]) => TacticComponent[],
) => void
courtRef: RefObject<HTMLDivElement>
}
function PlayerRack({
id,
objects,
setObjects,
courtRef,
setComponents,
}: PlayerRackProps) {
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
)
return (
<Rack
id={id}
objects={objects}
onChange={setObjects}
canDetach={useCallback(
(div) => overlaps(courtBounds(), div.getBoundingClientRect()),
[courtBounds],
)}
onElementDetached={useCallback(
(r, e: RackedPlayer) =>
setComponents((components) => [
...components,
placePlayerAt(
r.getBoundingClientRect(),
courtBounds(),
e,
),
]),
[courtBounds, setComponents],
)}
render={useCallback(
({ team, key }: { team: PlayerTeam; key: string }) => (
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
),
[],
)}
/>
</div>
<div id="court-div">
<div id="court-div-bounds">
<BasketCourt
players={content.players}
objects={content.objects}
actions={content.actions}
onBallMoved={onBallDrop}
courtImage={<Court courtType={courtType} />}
courtRef={courtDivContentRef}
setActions={(actions) =>
setContent((content) => ({
...content,
players: content.players,
actions: actions(content.actions),
}))
)
}
renderAction={(action, i) => (
<CourtAction
key={i}
action={action}
courtRef={courtDivContentRef}
onActionDeleted={() => {
setContent((content) => ({
...content,
actions: content.actions.toSpliced(
i,
1,
),
interface CourtPlayerArrowActionProps {
playerInfo: PlayerInfo
player: PlayerLike
isInvalid: boolean
content: TacticContent
setContent: (state: SetStateAction<TacticContent>) => void
setPreviewAction: (state: SetStateAction<ActionPreview | null>) => void
courtRef: RefObject<HTMLDivElement>
}
function CourtPlayerArrowAction({
playerInfo,
player,
isInvalid,
content,
setContent,
setPreviewAction,
courtRef,
}: CourtPlayerArrowActionProps) {
const courtBounds = useCallback(
() => courtRef.current!.getBoundingClientRect(),
[courtRef],
)
return (
<ArrowAction
key={1}
onHeadMoved={(headPos) => {
const arrowHeadPos = middlePos(headPos)
const targetIdx = getComponentCollided(
headPos,
content.components,
)
const target = content.components[targetIdx]
setPreviewAction((action) => ({
...action!,
segments: [
{
next: ratioWithinBase(arrowHeadPos, courtBounds()),
},
],
type: getActionKind(target, playerInfo.ballState).kind,
isInvalid:
!overlaps(headPos, courtBounds()) ||
!isActionValid(player, target, content.components),
}))
}}
onActionChanges={(a) =>
setContent((content) => ({
...content,
actions: content.actions.toSpliced(
i,
1,
a,
onHeadPicked={(headPos) => {
;(document.activeElement as HTMLElement).blur()
setPreviewAction({
origin: playerInfo.id,
type: getActionKind(null, playerInfo.ballState).kind,
target: ratioWithinBase(headPos, courtBounds()),
segments: [
{
next: ratioWithinBase(
middlePos(headPos),
courtBounds(),
),
}))
}
/>
)}
onPlayerChange={(player) => {
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
if (!isBoundsOnCourt(playerBounds)) {
removePlayer(player)
},
],
isInvalid: false,
})
}}
onHeadDropped={(headRect) => {
if (isInvalid) {
setPreviewAction(null)
return
}
setContent((content) => ({
...content,
players: toSplicedPlayers(
content.players,
setContent((content) => {
let { createdAction, newContent } = createAction(
player,
true,
),
}))
courtBounds(),
headRect,
content,
)
if (createdAction.type == ActionKind.SHOOT) {
const targetIdx = newContent.components.findIndex(
(c) => c.id == createdAction.target,
)
newContent = dropBallOnComponent(
targetIdx,
newContent,
false,
)
const ballState =
player.ballState === BallState.HOLDS_ORIGIN
? BallState.PASSED_ORIGIN
: BallState.PASSED
newContent = updateComponent(
{
...(newContent.components.find(
(c) => c.id == player.id,
)! as PlayerLike),
ballState,
},
newContent,
)
}
return newContent
})
setPreviewAction(null)
}}
onPlayerRemove={removePlayer}
onBallRemove={removeCourtBall}
/>
</div>
</div>
</div>
</div>
)
}
function isBallOnCourt(content: TacticContent) {
if (content.players.findIndex((p) => p.hasBall) != -1) {
return true
}
return content.objects.findIndex((o) => o.type == "ball") != -1
return (
content.components.findIndex(
(c) =>
((c.type === "player" || c.type === "phantom") &&
(c.ballState === BallState.HOLDS_ORIGIN ||
c.ballState === BallState.PASSED_ORIGIN)) ||
c.type === BALL_TYPE,
) != -1
)
}
function renderCourtObject(courtObject: RackedCourtObject) {
@ -550,16 +714,6 @@ function Court({ courtType }: { courtType: string }) {
)
}
function getRackPlayers(team: PlayerTeam, players: Player[]): RackedPlayer[] {
return ["1", "2", "3", "4", "5"]
.filter(
(role) =>
players.findIndex((p) => p.team == team && p.role == role) ==
-1,
)
.map((key) => ({ team, key }))
}
function debounceAsync<A, B>(
f: (args: A) => Promise<B>,
delay = 1000,
@ -588,6 +742,7 @@ function useContentState<S>(
typeof newState === "function"
? (newState as (state: S) => S)(content)
: newState
if (state !== content) {
setSavingState(SaveStates.Saving)
saveStateCallback(state)
@ -602,15 +757,3 @@ function useContentState<S>(
return [content, setContentSynced, savingState]
}
function toSplicedPlayers(
players: Player[],
player: Player,
replace: boolean,
): Player[] {
const idx = players.findIndex(
(p) => p.team === player.team && p.role === player.role,
)
return players.toSpliced(idx, 1, ...(replace ? [player] : []))
}

@ -2,29 +2,36 @@ import { Action, ActionKind } from "../../model/tactic/Action"
import BendableArrow from "../../components/arrows/BendableArrow"
import { RefObject } from "react"
import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction"
import { ComponentId } from "../../model/tactic/Tactic"
export interface CourtActionProps {
origin: ComponentId
action: Action
onActionChanges: (a: Action) => void
onActionDeleted: () => void
courtRef: RefObject<HTMLElement>
isInvalid: boolean
}
export function CourtAction({
origin,
action,
onActionChanges,
onActionDeleted,
courtRef,
isInvalid,
}: CourtActionProps) {
const color = isInvalid ? "red" : "black"
let head
switch (action.type) {
case ActionKind.DRIBBLE:
case ActionKind.MOVE:
case ActionKind.SHOOT:
head = () => <MoveToHead />
head = () => <MoveToHead color={color} />
break
case ActionKind.SCREEN:
head = () => <ScreenHead />
head = () => <ScreenHead color={color} />
break
}
@ -39,19 +46,20 @@ export function CourtAction({
<BendableArrow
forceStraight={action.type == ActionKind.SHOOT}
area={courtRef}
startPos={action.moveFrom}
startPos={origin}
segments={action.segments}
onSegmentsChanges={(edges) => {
onActionChanges({ ...action, segments: edges })
}}
wavy={action.type == ActionKind.DRIBBLE}
//TODO place those magic values in constants
endRadius={action.toPlayerId ? 26 : 17}
startRadius={0}
endRadius={action.target ? 26 : 17}
startRadius={10}
onDeleteRequested={onActionDeleted}
style={{
head,
dashArray,
color,
}}
/>
)

@ -17,7 +17,7 @@ export function Header({ username }: { username: string }) {
location.pathname = BASE + "/"
}}>
<span id="IQ">IQ</span>
<span id="Ball">Ball</span>
<span id="Ball">CourtObjects</span>
</h1>
</div>
<div id="header-right">

@ -5,7 +5,7 @@ use IQBall\Core\Gateway\AccountGateway;
use IQBall\Core\Model\AuthModel;
/**
* @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.

@ -26,7 +26,7 @@ CREATE TABLE Tactic
name varchar NOT NULL,
creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
owner integer NOT NULL,
content varchar DEFAULT '{"players": [], "actions": [], "objects": []}' NOT NULL,
content varchar DEFAULT '{"components": []}' NOT NULL,
court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL,
FOREIGN KEY (owner) REFERENCES Account
);

@ -42,7 +42,7 @@ class EditorController {
return ViewHttpResponse::react("views/Editor.tsx", [
"id" => -1, //-1 id means that the editor will not support saves
"name" => TacticModel::TACTIC_DEFAULT_NAME,
"content" => '{"players": [], "objects": [], "actions": []}',
"content" => '{"components": []}',
"courtType" => $courtType->name(),
]);
}

@ -52,7 +52,7 @@
<body>
<button onclick="location.pathname='{{ path('/disconnect') }}'"> Se déconnecter</button>
<div id="bandeau">
<h1>IQ Ball</h1>
<h1>IQ CourtObjects</h1>
<div id="account" onclick="location.pathname='{{ path('/settings') }}'">
<img
src="{{ path('/assets/icon/account.svg') }}"

Loading…
Cancel
Save