Add Phantoms #95

Merged
maxime.batista merged 7 commits from editor/phantoms into master 1 year ago

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

@ -4,13 +4,13 @@ import "../style/title_input.css"
export interface TitleInputOptions { export interface TitleInputOptions {
style: CSSProperties style: CSSProperties
default_value: string default_value: string
on_validated: (a: string) => void onValidated: (a: string) => void
} }
export default function TitleInput({ export default function TitleInput({
style, style,
default_value, default_value,
on_validated, onValidated,
}: TitleInputOptions) { }: TitleInputOptions) {
const [value, setValue] = useState(default_value) const [value, setValue] = useState(default_value)
const ref = useRef<HTMLInputElement>(null) const ref = useRef<HTMLInputElement>(null)
@ -23,7 +23,7 @@ export default function TitleInput({
type="text" type="text"
value={value} value={value}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
onBlur={(_) => on_validated(value)} onBlur={(_) => onValidated(value)}
onKeyUp={(event) => { onKeyUp={(event) => {
if (event.key == "Enter") ref.current?.blur() 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 ( return (
<div <div style={{ backgroundColor: color, height: "5px", width: "25px" }} />
style={{ backgroundColor: "black", height: "5px", width: "25px" }}
/>
) )
} }
export function MoveToHead() { export function MoveToHead({ color }: { color: string }) {
return ( return (
<svg viewBox={"0 0 50 50"} width={20} height={20}> <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> </svg>
) )
} }

@ -1,15 +1,19 @@
import { BallPiece } from "../editor/BallPiece" import { BallPiece } from "../editor/BallPiece"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { useRef } from "react" import { useRef } from "react"
import { NULL_POS } from "../../geo/Pos"
export interface BallActionProps { 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) const ref = useRef<HTMLDivElement>(null)
return ( return (
<Draggable onStop={() => onDrop(ref.current!)} nodeRef={ref}> <Draggable
nodeRef={ref}
onStop={() => onDrop(ref.current!.getBoundingClientRect())}
position={NULL_POS}>
<div ref={ref}> <div ref={ref}>
<BallPiece /> <BallPiece />
</div> </div>

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

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

@ -1,149 +1,42 @@
import { CourtBall } from "./CourtBall"
import { import {
ReactElement, ReactElement,
ReactNode,
RefObject, RefObject,
useCallback, useEffect,
useLayoutEffect, useLayoutEffect,
useState, useState,
} from "react" } 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 { CourtAction } from "../../views/editor/CourtAction"
import { ComponentId, TacticComponent } from "../../model/tactic/Tactic"
export interface BasketCourtProps { export interface BasketCourtProps {
players: Player[] components: TacticComponent[]
actions: Action[] previewAction: ActionPreview | null
objects: CourtObject[]
renderAction: (a: Action, key: number) => ReactElement
setActions: (f: (a: Action[]) => Action[]) => void
onPlayerRemove: (p: Player) => void
onPlayerChange: (p: Player) => void
onBallRemove: () => void renderComponent: (comp: TacticComponent) => ReactNode
onBallMoved: (ball: DOMRect) => void renderActions: (comp: TacticComponent) => ReactNode[]
courtImage: ReactElement courtImage: ReactElement
courtRef: RefObject<HTMLDivElement> courtRef: RefObject<HTMLDivElement>
} }
export interface ActionPreview extends Action {
origin: ComponentId
isInvalid: boolean
}
export function BasketCourt({ export function BasketCourt({
players, components,
actions, previewAction,
objects,
renderAction,
setActions,
onPlayerRemove,
onPlayerChange,
onBallMoved, renderComponent,
onBallRemove, renderActions,
courtImage, courtImage,
courtRef, courtRef,
}: BasketCourtProps) { }: 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 ( return (
<div <div
className="court-container" className="court-container"
@ -151,118 +44,16 @@ export function BasketCourt({
style={{ position: "relative" }}> style={{ position: "relative" }}>
{courtImage} {courtImage}
{players.map((player) => ( {components.map(renderComponent)}
<CourtPlayer {components.flatMap(renderActions)}
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)
})}
{previewAction && ( {previewAction && (
<CourtAction <CourtAction
courtRef={courtRef} courtRef={courtRef}
action={previewAction} 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={() => {}} onActionDeleted={() => {}}
onActionChanges={() => {}} onActionChanges={() => {}}
/> />

@ -1,15 +1,16 @@
import React, { useRef } from "react" import React, { useRef } from "react"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { BallPiece } from "./BallPiece" 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 { export interface CourtBallProps {
onMoved: (rect: DOMRect) => void onPosValidated: (rect: DOMRect) => void
onRemove: () => void onRemove: () => void
ball: Ball ball: Ball
} }
export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) { export function CourtBall({ onPosValidated, ball, onRemove }: CourtBallProps) {
const pieceRef = useRef<HTMLDivElement>(null) const pieceRef = useRef<HTMLDivElement>(null)
const x = ball.rightRatio const x = ball.rightRatio
@ -17,7 +18,10 @@ export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) {
return ( return (
<Draggable <Draggable
onStop={() => onMoved(pieceRef.current!.getBoundingClientRect())} onStop={() =>
onPosValidated(pieceRef.current!.getBoundingClientRect())
}
position={NULL_POS}
nodeRef={pieceRef}> nodeRef={pieceRef}>
<div <div
className={"ball-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 "../../style/player.css"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece" import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../model/tactic/Player" import { BallState, PlayerInfo } from "../../model/tactic/Player"
import { NULL_POS, ratioWithinBase } from "../arrows/Pos" import { NULL_POS, Pos, ratioWithinBase } from "../../geo/Pos"
export interface PlayerProps { export interface CourtPlayerProps {
player: Player playerInfo: PlayerInfo
onDrag: () => void className?: string
onChange: (p: Player) => void
onPositionValidated: (newPos: Pos) => void
onRemove: () => void onRemove: () => void
courtRef: RefObject<HTMLElement> courtRef: RefObject<HTMLElement>
availableActions: (ro: HTMLElement) => ReactNode[] 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 * A player that is placed on the court, which can be selected, and moved in the associated bounds
* */ * */
export default function CourtPlayer({ export default function CourtPlayer({
player, playerInfo,
onDrag, className,
onChange,
onPositionValidated,
onRemove, onRemove,
courtRef, courtRef,
availableActions, availableActions,
}: PlayerProps) { }: CourtPlayerProps) {
const hasBall = player.hasBall const usesBall = playerInfo.ballState != BallState.NONE
const x = player.rightRatio const x = playerInfo.rightRatio
const y = player.bottomRatio const y = playerInfo.bottomRatio
const pieceRef = useRef<HTMLDivElement>(null) const pieceRef = useRef<HTMLDivElement>(null)
return ( return (
<Draggable <Draggable
handle=".player-piece" handle=".player-piece"
nodeRef={pieceRef} nodeRef={pieceRef}
onDrag={onDrag}
//The piece is positioned using top/bottom style attributes instead //The piece is positioned using top/bottom style attributes instead
position={NULL_POS} position={NULL_POS}
onStop={() => { onStop={useCallback(() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect() const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = courtRef.current!.getBoundingClientRect() const parentBounds = courtRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(pieceBounds, parentBounds) const pos = ratioWithinBase(pieceBounds, parentBounds)
onChange({ if (pos.x !== x || pos.y != y) onPositionValidated(pos)
id: player.id, }, [courtRef, onPositionValidated, x, y])}>
rightRatio: x,
bottomRatio: y,
team: player.team,
role: player.role,
hasBall: player.hasBall,
})
}}>
<div <div
id={player.id} id={playerInfo.id}
ref={pieceRef} ref={pieceRef}
className="player" className={"player " + (className ?? "")}
style={{ style={{
position: "absolute", position: "absolute",
left: `${x * 100}%`, left: `${x * 100}%`,
@ -64,16 +58,19 @@ export default function CourtPlayer({
<div <div
tabIndex={0} tabIndex={0}
className="player-content" className="player-content"
onKeyUp={(e) => { onKeyUp={useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key == "Delete") onRemove() if (e.key == "Delete") onRemove()
}}> },
[onRemove],
)}>
<div className="player-actions"> <div className="player-actions">
{availableActions(pieceRef.current!)} {availableActions(pieceRef.current!)}
</div> </div>
<PlayerPiece <PlayerPiece
team={player.team} team={playerInfo.team}
text={player.role} text={playerInfo.role}
hasBall={hasBall} hasBall={usesBall}
/> />
</div> </div>
</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)
Review
-    for (let i = 0; i <= originIdx; i++) {
+    for (let i = 0; i < originIdx; i++) {
```diff - for (let i = 0; i <= originIdx; i++) { + for (let i = 0; i < originIdx; i++) { ```
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(
maxime.batista marked this conversation as resolved
Review
-    const componentIdx = content.components.findIndex(
-        (c) => c.id == componentId,
-    )
-
-    return {
-        ...content,
-        components: content.components.toSpliced(componentIdx, 1),
+    return {
+        components: content.components.filter((c) => c.id != componentId),
```diff - const componentIdx = content.components.findIndex( - (c) => c.id == componentId, - ) - - return { - ...content, - components: content.components.toSpliced(componentIdx, 1), + return { + components: content.components.filter((c) => c.id != componentId), ```
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[],
maxime.batista marked this conversation as resolved
Review
-    const componentIdx = content.components.findIndex(
-        (c) => c.id == component.id,
-    )
     return {
-        ...content,
-        components: content.components.toSpliced(componentIdx, 1, component),
+        components: content.components.map((c) =>
+            c.id === component.id ? component : c,
+        ),
     }
```diff - const componentIdx = content.components.findIndex( - (c) => c.id == component.id, - ) return { - ...content, - components: content.components.toSpliced(componentIdx, 1, component), + components: content.components.map((c) => + c.id === component.id ? component : c, + ), } ```
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 { export function contains(box: Box, pos: Pos): boolean {
return ( return (
pos.x >= box.x && 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 { Segment } from "../../components/arrows/BendableArrow"
import { PlayerId } from "./Player" import { ComponentId } from "./Tactic"
export enum ActionKind { export enum ActionKind {
SCREEN = "SCREEN", SCREEN = "SCREEN",
@ -12,8 +12,10 @@ export enum ActionKind {
export type Action = { type: ActionKind } & MovementAction export type Action = { type: ActionKind } & MovementAction
export interface MovementAction { export interface MovementAction {
fromPlayerId: PlayerId target: ComponentId | Pos
toPlayerId?: PlayerId
moveFrom: Pos
segments: Segment[] 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 PlayerId = string
maxime.batista marked this conversation as resolved
Review

Might be better adding a type PlayerLike = Player | PlayerPhantom union since it is often used.

Might be better adding a `type PlayerLike = Player | PlayerPhantom` union since it is often used.
export type PlayerLike = Player | PlayerPhantom
export enum PlayerTeam { export enum PlayerTeam {
Allies = "allies", Allies = "allies",
Opponents = "opponents", Opponents = "opponents",
} }
export interface Player { export interface Player extends PlayerInfo, Component<"player"> {
readonly id: PlayerId readonly id: PlayerId
}
/**
* All information about a player
*/
export interface PlayerInfo {
readonly id: string
/** /**
* the player's team * the player's team
* */ * */
@ -18,6 +28,11 @@ export interface Player {
* */ * */
readonly role: string 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) * 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) * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/ */
readonly rightRatio: number 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 * 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 { Player, PlayerPhantom } from "./Player"
import { CourtObject } from "./Ball"
import { Action } from "./Action" import { Action } from "./Action"
import { CourtObject } from "./CourtObjects"
export interface Tactic { export interface Tactic {
id: number id: number
@ -9,7 +9,31 @@ export interface Tactic {
} }
export interface TacticContent { export interface TacticContent {
players: Player[] components: TacticComponent[]
objects: CourtObject[] //actions: Action[]
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 { .arrow-action-icon {
user-select: none; user-select: none;
-moz-user-select: none; -moz-user-select: none;
-webkit-user-drag: none;
max-width: 17px; max-width: 17px;
max-height: 17px; max-height: 17px;
} }

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

@ -1,8 +1,10 @@
import { import {
CSSProperties, CSSProperties,
Dispatch, Dispatch,
RefObject,
SetStateAction, SetStateAction,
useCallback, useCallback,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@ -16,22 +18,55 @@ import { BallPiece } from "../components/editor/BallPiece"
import { Rack } from "../components/Rack" import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece" 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 { fetchAPI } from "../Fetcher"
import { PlayerTeam } from "../model/tactic/Player"
import SavingState, { import SavingState, {
SaveState, SaveState,
SaveStates, SaveStates,
} from "../components/editor/SavingState" } from "../components/editor/SavingState"
import { CourtObject } from "../model/tactic/Ball" import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "./editor/CourtAction" import { CourtAction } from "./editor/CourtAction"
import { BasketCourt } from "../components/editor/BasketCourt" import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt"
import { ratioWithinBase } from "../components/arrows/Pos" 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 { 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" import { BASE } from "../Constants"
const ERROR_STYLE: CSSProperties = { const ERROR_STYLE: CSSProperties = {
@ -55,16 +90,6 @@ export interface EditorProps {
courtType: "PLAIN" | "HALF" 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 isInGuestMode = id == -1
@ -134,263 +159,278 @@ function EditorView({
), ),
) )
const [allies, setAllies] = useState( const [allies, setAllies] = useState(() =>
getRackPlayers(PlayerTeam.Allies, content.players), getRackPlayers(PlayerTeam.Allies, content.components),
) )
const [opponents, setOpponents] = useState( const [opponents, setOpponents] = useState(() =>
getRackPlayers(PlayerTeam.Opponents, content.players), getRackPlayers(PlayerTeam.Opponents, content.components),
) )
const [objects, setObjects] = useState<RackedCourtObject[]>( const [objects, setObjects] = useState<RackedCourtObject[]>(() =>
isBallOnCourt(content) ? [] : [{ key: "ball" }], isBallOnCourt(content) ? [] : [{ key: "ball" }],
) )
const courtDivContentRef = useRef<HTMLDivElement>(null) const [previewAction, setPreviewAction] = useState<ActionPreview | null>(
null,
)
const isBoundsOnCourt = (bounds: DOMRect) => { const courtRef = useRef<HTMLDivElement>(null)
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
// check if refBounds overlaps courtBounds const setComponents = (action: SetStateAction<TacticComponent[]>) => {
return !( setContent((c) => ({
bounds.top > courtBounds.bottom || ...c,
bounds.right < courtBounds.left || components:
bounds.bottom < courtBounds.top || typeof action == "function" ? action(c.components) : action,
bounds.left > courtBounds.right }))
)
} }
const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => { const courtBounds = useCallback(
const refBounds = ref.getBoundingClientRect() () => courtRef.current!.getBoundingClientRect(),
const courtBounds = courtDivContentRef.current!.getBoundingClientRect() [courtRef],
)
const { x, y } = ratioWithinBase(refBounds, courtBounds) useEffect(() => {
setObjects(isBallOnCourt(content) ? [] : [{ key: "ball" }])
}, [setObjects, content])
setContent((content) => { const insertRackedPlayer = (player: Player) => {
return { let setter
...content, switch (player.team) {
players: [ case PlayerTeam.Opponents:
...content.players, 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: player.team,
team: element.team, pos: player.role,
role: element.key, key: player.role,
rightRatio: x,
bottomRatio: y,
hasBall: false,
}, },
], ])
actions: content.actions,
}
})
} }
const onObjectDetach = ( const doRemovePlayer = useCallback(
ref: HTMLDivElement, (component: PlayerLike) => {
rackedObject: RackedCourtObject, setContent((c) => removePlayer(component, c))
) => { if (component.type == "player") insertRackedPlayer(component)
const refBounds = ref.getBoundingClientRect() },
const courtBounds = courtDivContentRef.current!.getBoundingClientRect() [setContent],
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,
) )
if (playerCollidedIdx != -1) {
onBallDropOnPlayer(playerCollidedIdx)
setContent((content) => {
return {
...content,
objects: content.objects.toSpliced(ballObj, 1),
}
})
return
}
courtObject = { const doMoveBall = useCallback(
type: "ball", (newBounds: DOMRect, from?: PlayerLike) => {
rightRatio: x, setContent((content) => {
bottomRatio: y, if (from) {
content = changePlayerBallState(
from,
BallState.NONE,
content,
)
} }
break
default: content = placeBallAt(newBounds, courtBounds(), content)
throw new Error("unknown court object " + rackedObject.key)
}
setContent((content) => { return content
return {
...content,
objects: [...content.objects, courtObject],
}
}) })
} },
[courtBounds, setContent],
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
) )
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) { (content) => {
type = ActionKind.DRIBBLE if (player.type == "player") insertRackedPlayer(player)
} else if (originHasBall && type == ActionKind.SCREEN) { return removePlayer(player, content)
type = ActionKind.SHOOT },
} else if (type == ActionKind.DRIBBLE) { ),
type = ActionKind.MOVE )
} else if (type == ActionKind.SHOOT) { },
type = ActionKind.SCREEN [courtBounds, setContent],
} )
return {
...action,
type,
}
})
}
const onBallDropOnPlayer = (playerCollidedIdx: number) => { const renderAvailablePlayerActions = useCallback(
setContent((content) => { (info: PlayerInfo, player: PlayerLike) => {
const ballObj = content.objects.findIndex((o) => o.type == "ball") let canPlaceArrows: boolean
let player = content.players.at(playerCollidedIdx) as Player
const players = content.players.toSpliced(playerCollidedIdx, 1, { if (player.type == "player") {
...player, canPlaceArrows =
hasBall: true, player.path == null ||
}) player.actions.findIndex(
return { (p) => p.type != ActionKind.SHOOT,
...content, ) == -1
actions: updateActions(content.actions, players), } else {
players, const origin = getOrigin(player, content.components)
objects: content.objects.toSpliced(ballObj, 1), 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) => { const renderPlayer = useCallback(
if (!isBoundsOnCourt(refBounds)) { (component: PlayerLike) => {
removeCourtBall() let info: PlayerInfo
return const isPhantom = component.type == "phantom"
} if (isPhantom) {
const playerCollidedIdx = getPlayerCollided(refBounds, content.players) const origin = getOrigin(component, content.components)
if (playerCollidedIdx != -1) { info = {
setContent((content) => { id: component.id,
return { team: origin.team,
...content, role: origin.role,
players: content.players.map((player) => ({ bottomRatio: component.bottomRatio,
...player, rightRatio: component.rightRatio,
hasBall: false, ballState: component.ballState,
})),
} }
}) } else {
onBallDropOnPlayer(playerCollidedIdx) info = component
return
}
if (content.objects.findIndex((o) => o.type == "ball") != -1) {
return
} }
const courtBounds = courtDivContentRef.current!.getBoundingClientRect() return (
const { x, y } = ratioWithinBase(refBounds, courtBounds) <CourtPlayer
let courtObject: CourtObject key={component.id}
className={isPhantom ? "phantom" : "player"}
courtObject = { playerInfo={info}
type: "ball", onPositionValidated={(newPos) =>
rightRatio: x, validatePlayerPosition(component, info, newPos)
bottomRatio: y, }
onRemove={() => doRemovePlayer(component)}
courtRef={courtRef}
availableActions={() =>
renderAvailablePlayerActions(info, component)
} }
/>
)
},
[
content.components,
doRemovePlayer,
renderAvailablePlayerActions,
validatePlayerPosition,
],
)
const players = content.players.map((player) => ({ const doDeleteAction = useCallback(
...player, (action: Action, idx: number, origin: TacticComponent) => {
hasBall: false, setContent((content) => removeAction(origin, action, idx, content))
})) },
[setContent],
setContent((content) => { )
return {
...content,
actions: updateActions(content.actions, players),
players,
objects: [...content.objects, courtObject],
}
})
}
const removePlayer = (player: Player) => { const doUpdateAction = useCallback(
setContent((content) => ({ (component: TacticComponent, action: Action, actionIndex: number) => {
...content, setContent((content) =>
players: toSplicedPlayers(content.players, player, false), updateComponent(
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,
{ {
team: player.team, ...component,
pos: player.role, actions: component.actions.toSpliced(
key: player.role, 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 = () => { const renderActions = useCallback(
setContent((content) => { (component: TacticComponent) =>
const ballObj = content.objects.findIndex((o) => o.type == "ball") component.actions.map((action, i) => {
return { return (
...content, <CourtAction
players: content.players.map((player) => ({ key={"action-" + component.id + "-" + i}
...player, action={action}
hasBall: false, origin={component.id}
})), courtRef={courtRef}
objects: content.objects.toSpliced(ballObj, 1), isInvalid={false}
} onActionDeleted={() => {
}) doDeleteAction(action, i, component)
setObjects([{ key: "ball" }]) }}
onActionChanges={(action) =>
doUpdateAction(component, action, i)
} }
/>
)
}),
[doDeleteAction, doUpdateAction],
)
return ( return (
<div id="main-div"> <div id="main-div">
@ -405,138 +445,262 @@ function EditorView({
<TitleInput <TitleInput
style={titleStyle} style={titleStyle}
default_value={name} default_value={name}
on_validated={(new_name) => { onValidated={useCallback(
(new_name) => {
onNameChange(new_name).then((success) => { onNameChange(new_name).then((success) => {
setTitleStyle(success ? {} : ERROR_STYLE) setTitleStyle(success ? {} : ERROR_STYLE)
}) })
}} },
[onNameChange],
)}
/> />
</div> </div>
<div id="topbar-right" /> <div id="topbar-right" />
</div> </div>
<div id="edit-div"> <div id="edit-div">
<div id="racks"> <div id="racks">
<Rack <PlayerRack
id="allies-rack" id={"allies"}
objects={allies} objects={allies}
onChange={setAllies} setObjects={setAllies}
canDetach={(div) => setComponents={setComponents}
isBoundsOnCourt(div.getBoundingClientRect()) courtRef={courtRef}
}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
)}
/> />
<Rack <Rack
id={"objects"} id={"objects"}
objects={objects} objects={objects}
onChange={setObjects} onChange={setObjects}
canDetach={(div) => canDetach={useCallback(
isBoundsOnCourt(div.getBoundingClientRect()) (div) =>
} overlaps(
onElementDetached={onObjectDetach} courtBounds(),
div.getBoundingClientRect(),
),
[courtBounds],
)}
onElementDetached={useCallback(
(r, e: RackedCourtObject) =>
setContent((content) =>
placeObjectAt(
r.getBoundingClientRect(),
courtBounds(),
e,
content,
),
),
[courtBounds, setContent],
)}
render={renderCourtObject} render={renderCourtObject}
/> />
<Rack <PlayerRack
id="opponent-rack" id={"opponents"}
objects={opponents} objects={opponents}
onChange={setOpponents} setObjects={setOpponents}
canDetach={(div) => setComponents={setComponents}
isBoundsOnCourt(div.getBoundingClientRect()) 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 <PlayerPiece
team={team} team={team}
text={key} text={key}
key={key} key={key}
hasBall={false} 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 interface CourtPlayerArrowActionProps {
key={i} playerInfo: PlayerInfo
action={action} player: PlayerLike
courtRef={courtDivContentRef} isInvalid: boolean
onActionDeleted={() => {
setContent((content) => ({ content: TacticContent
...content, setContent: (state: SetStateAction<TacticContent>) => void
actions: content.actions.toSpliced( setPreviewAction: (state: SetStateAction<ActionPreview | null>) => void
i, courtRef: RefObject<HTMLDivElement>
1, }
),
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) => onHeadPicked={(headPos) => {
setContent((content) => ({ ;(document.activeElement as HTMLElement).blur()
maxime.batista marked this conversation as resolved
Review
-                ;(document.activeElement as HTMLElement).blur() 
+                (document.activeElement as HTMLElement).blur() 
```diff - ;(document.activeElement as HTMLElement).blur() + (document.activeElement as HTMLElement).blur() ```
...content,
actions: content.actions.toSpliced( setPreviewAction({
i, origin: playerInfo.id,
1, type: getActionKind(null, playerInfo.ballState).kind,
a, target: ratioWithinBase(headPos, courtBounds()),
segments: [
{
next: ratioWithinBase(
middlePos(headPos),
courtBounds(),
), ),
})) },
} ],
/> isInvalid: false,
)} })
onPlayerChange={(player) => { }}
const playerBounds = document onHeadDropped={(headRect) => {
.getElementById(player.id)! if (isInvalid) {
.getBoundingClientRect() setPreviewAction(null)
if (!isBoundsOnCourt(playerBounds)) {
removePlayer(player)
return return
} }
setContent((content) => ({
...content, setContent((content) => {
players: toSplicedPlayers( let { createdAction, newContent } = createAction(
content.players,
player, 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) { function isBallOnCourt(content: TacticContent) {
if (content.players.findIndex((p) => p.hasBall) != -1) { return (
return true content.components.findIndex(
} (c) =>
return content.objects.findIndex((o) => o.type == "ball") != -1 ((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) { function renderCourtObject(courtObject: RackedCourtObject) {
@ -558,16 +722,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>( function debounceAsync<A, B>(
f: (args: A) => Promise<B>, f: (args: A) => Promise<B>,
delay = 1000, delay = 1000,
@ -596,6 +750,7 @@ function useContentState<S>(
typeof newState === "function" typeof newState === "function"
? (newState as (state: S) => S)(content) ? (newState as (state: S) => S)(content)
: newState : newState
if (state !== content) { if (state !== content) {
setSavingState(SaveStates.Saving) setSavingState(SaveStates.Saving)
saveStateCallback(state) saveStateCallback(state)
@ -610,15 +765,3 @@ function useContentState<S>(
return [content, setContentSynced, savingState] 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 BendableArrow from "../../components/arrows/BendableArrow"
import { RefObject } from "react" import { RefObject } from "react"
import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction" import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction"
import { ComponentId } from "../../model/tactic/Tactic"
export interface CourtActionProps { export interface CourtActionProps {
origin: ComponentId
action: Action action: Action
onActionChanges: (a: Action) => void onActionChanges: (a: Action) => void
onActionDeleted: () => void onActionDeleted: () => void
courtRef: RefObject<HTMLElement> courtRef: RefObject<HTMLElement>
isInvalid: boolean
} }
export function CourtAction({ export function CourtAction({
origin,
action, action,
onActionChanges, onActionChanges,
onActionDeleted, onActionDeleted,
courtRef, courtRef,
isInvalid,
}: CourtActionProps) { }: CourtActionProps) {
const color = isInvalid ? "red" : "black"
let head let head
switch (action.type) { switch (action.type) {
case ActionKind.DRIBBLE: case ActionKind.DRIBBLE:
case ActionKind.MOVE: case ActionKind.MOVE:
case ActionKind.SHOOT: case ActionKind.SHOOT:
head = () => <MoveToHead /> head = () => <MoveToHead color={color} />
break break
case ActionKind.SCREEN: case ActionKind.SCREEN:
head = () => <ScreenHead /> head = () => <ScreenHead color={color} />
break break
} }
@ -39,19 +46,20 @@ export function CourtAction({
<BendableArrow <BendableArrow
forceStraight={action.type == ActionKind.SHOOT} forceStraight={action.type == ActionKind.SHOOT}
area={courtRef} area={courtRef}
startPos={action.moveFrom} startPos={origin}
segments={action.segments} segments={action.segments}
onSegmentsChanges={(edges) => { onSegmentsChanges={(edges) => {
onActionChanges({ ...action, segments: edges }) onActionChanges({ ...action, segments: edges })
}} }}
wavy={action.type == ActionKind.DRIBBLE} wavy={action.type == ActionKind.DRIBBLE}
//TODO place those magic values in constants //TODO place those magic values in constants
endRadius={action.toPlayerId ? 26 : 17} endRadius={action.target ? 26 : 17}
startRadius={0} startRadius={10}
onDeleteRequested={onActionDeleted} onDeleteRequested={onActionDeleted}
style={{ style={{
head, head,
dashArray, dashArray,
color,
}} }}
/> />
) )

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

@ -1,7 +1,7 @@
<?php <?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 { function get_database(): PDO {
// defined by profiles. // defined by profiles.

@ -26,7 +26,7 @@ CREATE TABLE Tactic
name varchar NOT NULL, name varchar NOT NULL,
creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
owner integer 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, court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL,
FOREIGN KEY (owner) REFERENCES Account FOREIGN KEY (owner) REFERENCES Account
); );

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

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

Loading…
Cancel
Save