stabilize phantoms, spread changes between players and phantoms if an action changes
continuous-integration/drone/push Build is failing Details

maxime 1 year ago
parent a38f15771a
commit d95e84c413

@ -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,18 @@ export default function ArrowAction({
)
}
export function ScreenHead() {
export function ScreenHead({color}: {color: string}) {
return (
<div
style={{ backgroundColor: "black", height: "5px", width: "25px" }}
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,5 +1,6 @@
import {
CSSProperties,
MouseEvent as ReactMouseEvent,
ReactElement,
RefObject,
useCallback,
@ -7,21 +8,21 @@ 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 "../../geo/Pos"
import "../../style/bendable_arrows.css"
@ -46,12 +47,14 @@ 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 {
@ -109,7 +112,7 @@ export default function BendableArrow({
startRadius = 0,
endRadius = 0,
onDeleteRequested,
}: BendableArrowProps) {
}: BendableArrowProps) {
const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(null)
@ -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))
}, [computeInternalSegments])
}, [computeInternalSegments, segments])
const [isSelected, setIsSelected] = useState(false)
@ -159,7 +162,7 @@ export default function BendableArrow({
* @param parentBase
*/
function computePoints(parentBase: DOMRect) {
return segments.flatMap(({ next, controlPoint }, i) => {
return segments.flatMap(({next, controlPoint}, i) => {
const prev = i == 0 ? startPos : segments[i - 1].next
const prevRelative = getPosWithinBase(prev, parentBase)
@ -245,6 +248,8 @@ export default function BendableArrow({
* Updates the states based on given parameters, which causes the arrow to re-render.
*/
const update = useCallback(() => {
const parentBase = area.current!.getBoundingClientRect()
const segment = internalSegments[0] ?? null
@ -309,8 +314,8 @@ export default function BendableArrow({
},
]
: internalSegments
).map(({ start, controlPoint, end }, idx) => {
const svgPosRelativeToBase = { x: left, y: top }
).map(({start, controlPoint, end}) => {
const svgPosRelativeToBase = {x: left, y: top}
const nextRelative = relativeTo(
getPosWithinBase(end, parentBase),
@ -336,7 +341,7 @@ export default function BendableArrow({
})
const computedSegments = segmentsRelatives
.map(({ start, cp, end: e }, idx) => {
.map(({start, cp, end: e}, idx) => {
let end = e
if (idx == segmentsRelatives.length - 1) {
//if it is the last element
@ -355,14 +360,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(" ")
@ -370,21 +375,14 @@ export default function BendableArrow({
const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments
pathRef.current!.setAttribute("d", d)
Object.assign(svgRef.current!.style, svgStyle)
}, [
startPos,
internalSegments,
forceStraight,
startRadius,
endRadius,
style,
])
}, [area, internalSegments, startPos, forceStraight, startRadius, endRadius, wavy])
// Will update the arrow when the props change
useEffect(update, [update])
useEffect(() => {
const observer = new MutationObserver(update)
const config = { attributes: true }
const config = {attributes: true}
if (typeof startPos == "string") {
observer.observe(document.getElementById(startPos)!, config)
}
@ -396,7 +394,7 @@ export default function BendableArrow({
}
return () => observer.disconnect()
}, [startPos, segments])
}, [startPos, segments, update])
// Adds a selection handler
// Also force an update when the window is resized
@ -423,7 +421,7 @@ export default function BendableArrow({
if (forceStraight) return
const parentBase = area.current!.getBoundingClientRect()
const clickAbsolutePos: Pos = { x: e.pageX, y: e.pageY }
const clickAbsolutePos: Pos = {x: e.pageX, y: e.pageY}
const clickPosBaseRatio = ratioWithinBase(
clickAbsolutePos,
parentBase,
@ -504,7 +502,7 @@ export default function BendableArrow({
return (
<div
ref={containerRef}
style={{ position: "absolute", top: 0, left: 0 }}>
style={{position: "absolute", top: 0, left: 0}}>
<svg
ref={svgRef}
style={{
@ -515,7 +513,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
@ -532,14 +530,14 @@ export default function BendableArrow({
<div
className={"arrow-head"}
style={{ position: "absolute", transformOrigin: "center" }}
style={{position: "absolute", transformOrigin: "center"}}
ref={headRef}>
{style?.head?.call(style)}
</div>
<div
className={"arrow-tail"}
style={{ position: "absolute", transformOrigin: "center" }}
style={{position: "absolute", transformOrigin: "center"}}
ref={tailRef}>
{style?.tail?.call(style)}
</div>
@ -556,8 +554,8 @@ function getPosWithinBase(target: Pos | string, area: DOMRect): Pos {
return posWithinBase(target, area)
}
const targetPos = document.getElementById(target)!.getBoundingClientRect()
return relativeTo(middlePos(targetPos), area)
const targetPos = document.getElementById(target)?.getBoundingClientRect()
return targetPos ? relativeTo(middlePos(targetPos), area) : NULL_POS
}
function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos {
@ -565,8 +563,8 @@ function getRatioWithinBase(target: Pos | string, area: DOMRect): Pos {
return target
}
const targetPos = document.getElementById(target)!.getBoundingClientRect()
return ratioWithinBase(middlePos(targetPos), area)
const targetPos = document.getElementById(target)?.getBoundingClientRect()
return targetPos ? ratioWithinBase(middlePos(targetPos), area) : NULL_POS
}
interface ControlPointProps {
@ -613,7 +611,7 @@ function wavyBezier(
const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t)
const velocityLength = norm(velocity)
//rotate the velocity by 90 deg
const projection = { x: velocity.y, y: -velocity.x }
const projection = {x: velocity.y, y: -velocity.x}
return {
x: (projection.x / velocityLength) * amplitude,
@ -635,7 +633,7 @@ function wavyBezier(
// 3 : down to middle
let phase = 0
for (let t = step; t <= 1; ) {
for (let t = step; t <= 1;) {
const pos = cubicBeziers(start, cp1, cp2, end, t)
const amplification = getVerticalAmplification(t)
@ -760,7 +758,7 @@ function ArrowPoint({
onPosValidated,
onRemove,
radius = 7,
}: ControlPointProps) {
}: ControlPointProps) {
const ref = useRef<HTMLDivElement>(null)
const pos = posWithinBase(posRatio, parentBase)
@ -776,7 +774,7 @@ function ArrowPoint({
const pointPos = middlePos(ref.current!.getBoundingClientRect())
onMoves(ratioWithinBase(pointPos, parentBase))
}}
position={{ x: pos.x - radius, y: pos.y - radius }}>
position={{x: pos.x - radius, y: pos.y - radius}}>
<div
ref={ref}
className={`arrow-point ${className}`}

@ -24,6 +24,7 @@ export interface BasketCourtProps {
export interface ActionPreview extends Action {
origin: ComponentId
isInvalid: boolean
}
export function BasketCourt({
@ -36,6 +37,7 @@ export function BasketCourt({
courtImage,
courtRef,
}: BasketCourtProps) {
return (
<div
className="court-container"
@ -51,6 +53,7 @@ export function BasketCourt({
courtRef={courtRef}
action={previewAction}
origin={previewAction.origin}
isInvalid={previewAction.isInvalid}
//do nothing on interacted, not really possible as it's a preview arrow
onActionDeleted={() => {}}
onActionChanges={() => {}}

@ -1,9 +1,9 @@
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 { BallState, PlayerInfo } from "../../model/tactic/Player"
import { NULL_POS, Pos, ratioWithinBase } from "../../geo/Pos"
import {PlayerPiece} from "./PlayerPiece"
import {BallState, PlayerInfo} from "../../model/tactic/Player"
import {NULL_POS, Pos, ratioWithinBase} from "../../geo/Pos"
export interface CourtPlayerProps {
playerInfo: PlayerInfo
@ -26,7 +26,7 @@ export default function CourtPlayer({
onRemove,
courtRef,
availableActions,
}: CourtPlayerProps) {
}: CourtPlayerProps) {
const usesBall = playerInfo.ballState != BallState.NONE
const x = playerInfo.rightRatio
const y = playerInfo.bottomRatio
@ -38,13 +38,15 @@ export default function CourtPlayer({
nodeRef={pieceRef}
//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 pos = ratioWithinBase(pieceBounds, parentBounds)
if (pos.x !== x || pos.y != y)
onPositionValidated(pos)
}}>
}, [courtRef, onPositionValidated, x, y])}>
<div
id={playerInfo.id}
ref={pieceRef}
@ -57,9 +59,9 @@ 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>

@ -1,61 +1,139 @@
import { BallState, Player, PlayerPhantom } from "../model/tactic/Player"
import { middlePos, ratioWithinBase } from "../geo/Pos"
import {
ComponentId,
TacticComponent,
TacticContent,
} from "../model/tactic/Tactic"
import { overlaps } from "../geo/Box"
import { Action, ActionKind } from "../model/tactic/Action"
import { removeBall, updateComponent } from "./TacticContentDomains"
import { getOrigin } from "./PlayerDomains"
// export function refreshAllActions(
// actions: Action[],
// components: TacticComponent[],
// ) {
// return actions.map((action) => ({
// ...action,
// type: getActionKindFrom(action.fromId, action.toId, components),
// }))
// }
export function getActionKindFrom(
originId: ComponentId,
targetId: ComponentId | null,
components: TacticComponent[],
import {BallState, Player, PlayerPhantom} 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,
): ActionKind {
const origin = components.find((p) => p.id == originId)!
const target = components.find((p) => p.id == targetId)
switch (ballState) {
case BallState.HOLDS_ORIGIN:
case BallState.HOLDS_BY_PASS:
return target
? ActionKind.SHOOT
: ActionKind.DRIBBLE
case BallState.PASSED_ORIGIN:
case BallState.PASSED:
case BallState.NONE:
return target && target.type != BALL_TYPE
? ActionKind.SCREEN
: ActionKind.MOVE
}
}
export function getActionKindBetween(origin: Player | PlayerPhantom, target: TacticComponent | null, state: BallState): ActionKind {
//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.actions.find(a => moves(a.type)) && origin.ballState != BallState.HOLDS_BY_PASS) {
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;
let ballState = BallState.NONE
if (origin.type == "player" || origin.type == "phantom") {
ballState = origin.ballState
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
let hasTarget = target
? target.type != "phantom" || target.originPlayerId != origin.id
: false
visited.push(itemId)
return getActionKind(hasTarget, ballState)
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
}
export function getActionKind(
hasTarget: boolean,
ballState: BallState,
): ActionKind {
switch (ballState) {
case BallState.HOLDS:
return hasTarget ? ActionKind.SHOOT : ActionKind.DRIBBLE
case BallState.SHOOTED:
return ActionKind.MOVE
case BallState.NONE:
return hasTarget ? ActionKind.SCREEN : ActionKind.MOVE
function alreadyHasAnAnteriorActionWith(origin: Player | PlayerPhantom, target: Player | PlayerPhantom, 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 (Player | PlayerPhantom)
if (phantom.actions.find(a => typeof a.target === "string" && (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 (Player | PlayerPhantom)
if (phantom.actions.find(a => typeof a.target === "string" && targetOriginPath.indexOf(a.target) > targetIdx)) {
return true;
}
}
return false;
}
export function placeArrow(
export function createAction(
origin: Player | PlayerPhantom,
courtBounds: DOMRect,
arrowHead: DOMRect,
@ -64,10 +142,9 @@ export function placeArrow(
/**
* Creates a new phantom component.
* Be aware that this function will reassign the `content` parameter.
* @param receivesBall
*/
function createPhantom(receivesBall: boolean): ComponentId {
const { x, y } = ratioWithinBase(arrowHead, courtBounds)
function createPhantom(originState: BallState): ComponentId {
const {x, y} = ratioWithinBase(arrowHead, courtBounds)
let itemIndex: number
let originPlayer: Player
@ -99,20 +176,27 @@ export function placeArrow(
content,
)
const ballState = receivesBall
? BallState.HOLDS
: origin.ballState == BallState.HOLDS
? BallState.HOLDS
: BallState.NONE
let phantomState: BallState
switch (originState) {
case BallState.HOLDS_ORIGIN:
phantomState = BallState.HOLDS_BY_PASS
break
case BallState.PASSED:
case BallState.PASSED_ORIGIN:
phantomState = BallState.NONE
break
default:
phantomState = originState
}
const phantom: PlayerPhantom = {
actions: [],
type: "phantom",
id: phantomId,
rightRatio: x,
bottomRatio: y,
originPlayerId: originPlayer.id,
ballState,
ballState: phantomState,
actions: [],
}
content = {
...content,
@ -140,14 +224,14 @@ export function placeArrow(
const action: Action = {
target: toId,
type: getActionKind(true, origin.ballState),
segments: [{ next: component.id }],
type: getActionKind(component, origin.ballState),
segments: [{next: toId}],
}
return {
newContent: updateComponent(
{
...origin,
...content.components.find((c) => c.id == origin.id)!,
actions: [...origin.actions, action],
},
content,
@ -157,12 +241,12 @@ export function placeArrow(
}
}
const phantomId = createPhantom(origin.ballState == BallState.HOLDS)
const phantomId = createPhantom(origin.ballState)
const action: Action = {
target: phantomId,
type: getActionKind(false, origin.ballState),
segments: [{ next: phantomId }],
type: getActionKind(null, origin.ballState),
segments: [{next: phantomId}],
}
return {
newContent: updateComponent(
@ -180,7 +264,7 @@ export function removeAllActionsTargeting(
componentId: ComponentId,
content: TacticContent,
): TacticContent {
let components = []
const components = []
for (let i = 0; i < content.components.length; i++) {
const component = content.components[i]
components.push({
@ -194,3 +278,119 @@ export function removeAllActionsTargeting(
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 (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 === "player" || target.type === "phantom")
content = changePlayerBallState(target, BallState.NONE, 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: Player | PlayerPhantom, 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 Player | PlayerPhantom;
let targetState: BallState = actionTarget.ballState
let deleteAction = false
if (isNextInPath(origin, actionTarget, content.components)) {
/// If the target is the next phantom from the origin, its state is propagated.
targetState = (newState === BallState.PASSED || newState === BallState.PASSED_ORIGIN) ? BallState.NONE : 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 type = action.type == ActionKind.SHOOT
? ActionKind.SHOOT
: getActionKindBetween(origin, actionTarget, newState)
origin = {
...origin,
actions: origin.actions.toSpliced(i, 1, {
...action,
type
})
}
content = updateComponent(origin, content)
}
content = spreadNewStateFromOriginStateChange(actionTarget, targetState, content)
}
return content
}

@ -1,7 +1,8 @@
import { Player, PlayerPhantom } from "../model/tactic/Player"
import { TacticComponent, TacticContent } from "../model/tactic/Tactic"
import { removeComponent, updateComponent } from "./TacticContentDomains"
import { removeAllActionsTargeting } from "./ActionsDomains"
import {BallState, Player, 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,
@ -11,6 +12,36 @@ export function getOrigin(
return components.find((c) => c.id == pathItem.originPlayerId)! as Player
}
export function areInSamePath(
a: Player | PlayerPhantom,
b: Player | PlayerPhantom,
) {
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: Player | PlayerPhantom, other: Player | PlayerPhantom, 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,
@ -21,6 +52,7 @@ export function removePlayerPath(
for (const pathElement of player.path.items) {
content = removeComponent(pathElement, content)
content = removeAllActionsTargeting(pathElement, content)
}
return updateComponent(
{
@ -43,7 +75,17 @@ export function removePlayer(
}
content = removePlayerPath(player, content)
return removeComponent(player.id, 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 (Player | PlayerPhantom)
return spreadNewStateFromOriginStateChange(actionTarget, BallState.NONE, content)
}
return content
}
export function truncatePlayerPath(
@ -55,16 +97,14 @@ export function truncatePlayerPath(
const path = player.path!
let truncateStartIdx = -1
const truncateStartIdx = path.items.indexOf(phantom.id)
for (let i = 0; i < path.items.length; i++) {
for (let i = truncateStartIdx; i < path.items.length; i++) {
const pathPhantomId = path.items[i]
if (truncateStartIdx != -1 || pathPhantomId == phantom.id) {
if (truncateStartIdx == -1) truncateStartIdx = i
//remove the phantom from the tactic
content = removeComponent(pathPhantomId, content)
}
content = removeAllActionsTargeting(pathPhantomId, content)
}
return updateComponent(
@ -81,3 +121,7 @@ export function truncatePlayerPath(
content,
)
}
export function changePlayerBallState(player: Player | PlayerPhantom, newState: BallState, content: TacticContent): TacticContent {
return spreadNewStateFromOriginStateChange(player, newState, content)
}

@ -1,31 +1,17 @@
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 { getOrigin } from "./PlayerDomains"
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)
const {x, y} = ratioWithinBase(refBounds, courtBounds)
return {
type: "player",
@ -46,7 +32,7 @@ export function placeObjectAt(
rackedObject: RackedCourtObject,
content: TacticContent,
): TacticContent {
const { x, y } = ratioWithinBase(refBounds, courtBounds)
const {x, y} = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject
@ -58,7 +44,7 @@ export function placeObjectAt(
BALL_ID,
)
if (playerCollidedIdx != -1) {
return dropBallOnComponent(playerCollidedIdx, content)
return dropBallOnComponent(playerCollidedIdx, content, true)
}
courtObject = {
@ -83,77 +69,31 @@ export function placeObjectAt(
export function dropBallOnComponent(
targetedComponentIdx: number,
content: TacticContent,
setAsOrigin: boolean
): TacticContent {
let components = content.components
let component = components[targetedComponentIdx]
const component = content.components[targetedComponentIdx]
let origin
let isPhantom: boolean
if ((component.type == 'player' || component.type == 'phantom')) {
const newState = setAsOrigin
? (component.ballState === BallState.PASSED || component.ballState === BallState.PASSED_ORIGIN) ? BallState.PASSED_ORIGIN : BallState.HOLDS_ORIGIN
: BallState.HOLDS_BY_PASS
if (component.type == "phantom") {
isPhantom = true
origin = getOrigin(component, components)
} else if (component.type == "player") {
isPhantom = false
origin = component
} else {
return content
}
components = components.toSpliced(targetedComponentIdx, 1, {
...component,
ballState: BallState.HOLDS,
})
if (origin.path != null) {
const phantoms = origin.path!.items
const headingPhantoms = isPhantom
? phantoms.slice(phantoms.indexOf(component.id))
: phantoms
components = components.map((c) =>
headingPhantoms.indexOf(c.id) != -1
? {
...c,
hasBall: true,
}
: c,
)
content = changePlayerBallState(component, newState, content)
}
const ballObj = components.findIndex((p) => p.type == BALL_TYPE)
// Maybe the ball is not present on the court as an object component
// if so, don't bother removing it from the court.
// This can occur if the user drags and drop the ball from a player that already has the ball
// to another component
if (ballObj != -1) {
components.splice(ballObj, 1)
}
return {
...content,
components,
}
return removeBall(content)
}
export function removeBall(content: TacticContent): TacticContent {
const ballObj = content.components.findIndex((o) => o.type == "ball")
const ballObjIdx = content.components.findIndex((o) => o.type == "ball")
const components = content.components.map((c) =>
c.type == "player" || c.type == "phantom"
? {
...c,
hasBall: false,
}
: c,
)
// if the ball is already not on the court, do nothing
if (ballObj != -1) {
components.splice(ballObj, 1)
if (ballObjIdx == -1) {
return content
}
return {
...content,
components,
components: content.components.toSpliced(ballObjIdx, 1),
}
}
@ -161,47 +101,23 @@ export function placeBallAt(
refBounds: DOMRect,
courtBounds: DOMRect,
content: TacticContent,
): {
newContent: TacticContent
removed: boolean
} {
): TacticContent {
if (!overlaps(courtBounds, refBounds)) {
return { newContent: removeBall(content), removed: true }
return removeBall(content)
}
const playerCollidedIdx = getComponentCollided(
refBounds,
content.components,
BALL_ID,
)
if (playerCollidedIdx != -1) {
return {
newContent: dropBallOnComponent(playerCollidedIdx, {
...content,
components: content.components.map((c) =>
c.type == "player" || c.type == "phantom"
? {
...c,
hasBall: false,
}
: c,
),
}),
removed: false,
}
return dropBallOnComponent(playerCollidedIdx, content, true)
}
const ballIdx = content.components.findIndex((o) => o.type == "ball")
const { x, y } = ratioWithinBase(refBounds, courtBounds)
const components = content.components.map((c) =>
c.type == "player" || c.type == "phantom"
? {
...c,
hasBall: false,
}
: c,
)
const {x, y} = ratioWithinBase(refBounds, courtBounds)
const ball: Ball = {
type: BALL_TYPE,
@ -210,18 +126,18 @@ export function placeBallAt(
bottomRatio: y,
actions: [],
}
let components = content.components
if (ballIdx != -1) {
components.splice(ballIdx, 1, ball)
components = components.toSpliced(ballIdx, 1, ball)
} else {
components.push(ball)
components = components.concat(ball)
}
return {
newContent: {
...content,
components,
},
removed: false,
}
}
@ -311,5 +227,5 @@ export function getRackPlayers(
c.type == "player" && c.team == team && c.role == role,
) == -1,
)
.map((key) => ({ team, key }))
.map((key) => ({team, key}))
}

@ -12,7 +12,10 @@ export enum ActionKind {
export type Action = { type: ActionKind } & MovementAction
export interface MovementAction {
// fromId: ComponentId
target: ComponentId | Pos
segments: Segment[]
}
export function moves(kind: ActionKind): boolean {
return kind != ActionKind.SHOOT
}

@ -44,8 +44,10 @@ export interface PlayerInfo {
export enum BallState {
NONE,
HOLDS,
SHOOTED,
HOLDS_ORIGIN,
HOLDS_BY_PASS,
PASSED,
PASSED_ORIGIN,
}
export interface Player extends Component<"player">, PlayerInfo {

@ -1,8 +1,10 @@
import {
CSSProperties,
Dispatch,
RefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
@ -12,23 +14,20 @@ import TitleInput from "../components/TitleInput"
import PlainCourt from "../assets/court/full_court.svg?react"
import HalfCourt from "../assets/court/half_court.svg?react"
import { BallPiece } from "../components/editor/BallPiece"
import {BallPiece} from "../components/editor/BallPiece"
import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
import {Rack} from "../components/Rack"
import {PlayerPiece} from "../components/editor/PlayerPiece"
import { Tactic, TacticComponent, TacticContent } from "../model/tactic/Tactic"
import { fetchAPI } from "../Fetcher"
import {Tactic, TacticComponent, TacticContent} from "../model/tactic/Tactic"
import {fetchAPI} from "../Fetcher"
import SavingState, {
SaveState,
SaveStates,
} from "../components/editor/SavingState"
import SavingState, {SaveState, SaveStates,} from "../components/editor/SavingState"
import { BALL_TYPE } from "../model/tactic/CourtObjects"
import { CourtAction } from "./editor/CourtAction"
import { ActionPreview, BasketCourt } from "../components/editor/BasketCourt"
import { overlaps } from "../geo/Box"
import {BALL_TYPE} from "../model/tactic/CourtObjects"
import {CourtAction} from "./editor/CourtAction"
import {ActionPreview, BasketCourt} from "../components/editor/BasketCourt"
import {overlaps} from "../geo/Box"
import {
dropBallOnComponent,
getComponentCollided,
@ -40,27 +39,17 @@ import {
removeBall,
updateComponent,
} from "../editor/TacticContentDomains"
import {
BallState,
Player,
PlayerInfo,
PlayerPhantom,
PlayerTeam,
} from "../model/tactic/Player"
import { RackedCourtObject } from "../editor/RackedItems"
import {BallState, Player, PlayerInfo, PlayerPhantom, PlayerTeam,} from "../model/tactic/Player"
import {RackedCourtObject, RackedPlayer} from "../editor/RackedItems"
import CourtPlayer from "../components/editor/CourtPlayer"
import { getActionKind, placeArrow } from "../editor/ActionsDomains"
import {createAction, getActionKind, isActionValid, removeAction} from "../editor/ActionsDomains"
import ArrowAction from "../components/actions/ArrowAction"
import { middlePos, ratioWithinBase } from "../geo/Pos"
import { Action, ActionKind } from "../model/tactic/Action"
import {middlePos, Pos, ratioWithinBase} from "../geo/Pos"
import {Action, ActionKind} from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction"
import {
getOrigin,
removePlayer,
truncatePlayerPath,
} from "../editor/PlayerDomains"
import { CourtBall } from "../components/editor/CourtBall"
import { BASE } from "../Constants"
import {changePlayerBallState, getOrigin, removePlayer,} from "../editor/PlayerDomains"
import {CourtBall} from "../components/editor/CourtBall"
import {BASE} from "../Constants"
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
@ -83,7 +72,7 @@ export interface EditorProps {
courtType: "PLAIN" | "HALF"
}
export default function Editor({ id, name, courtType, content }: EditorProps) {
export default function Editor({id, name, courtType, content}: EditorProps) {
const isInGuestMode = id == -1
const storage_content = localStorage.getItem(GUEST_MODE_CONTENT_STORAGE_KEY)
@ -109,7 +98,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) {
)
return SaveStates.Guest
}
return fetchAPI(`tactic/${id}/save`, { content }).then((r) =>
return fetchAPI(`tactic/${id}/save`, {content}).then((r) =>
r.ok ? SaveStates.Ok : SaveStates.Err,
)
}}
@ -118,7 +107,7 @@ export default function Editor({ id, name, courtType, content }: EditorProps) {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
return true //simulate that the name has been changed
}
return fetchAPI(`tactic/${id}/edit/name`, { name }).then(
return fetchAPI(`tactic/${id}/edit/name`, {name}).then(
(r) => r.ok,
)
}}
@ -128,11 +117,12 @@ export default function Editor({ id, name, courtType, content }: EditorProps) {
}
function EditorView({
tactic: { id, name, content: initialContent },
tactic: {id, name, content: initialContent},
onContentChange,
onNameChange,
courtType,
}: EditorViewProps) {
}: EditorViewProps) {
const isInGuestMode = id == -1
const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
@ -160,7 +150,7 @@ function EditorView({
)
const [objects, setObjects] = useState<RackedCourtObject[]>(() =>
isBallOnCourt(content) ? [] : [{ key: "ball" }],
isBallOnCourt(content) ? [] : [{key: "ball"}],
)
const [previewAction, setPreviewAction] = useState<ActionPreview | null>(
@ -169,8 +159,6 @@ function EditorView({
const courtRef = useRef<HTMLDivElement>(null)
const actionsReRenderHooks = []
const setComponents = (action: SetStateAction<TacticComponent[]>) => {
setContent((c) => ({
...c,
@ -179,6 +167,12 @@ function EditorView({
}))
}
const courtBounds = useCallback(() => courtRef.current!.getBoundingClientRect(), [courtRef])
useEffect(() => {
setObjects(isBallOnCourt(content) ? [] : [{key: "ball"}])
}, [setObjects, content]);
const insertRackedPlayer = (player: Player) => {
let setter
switch (player.team) {
@ -188,8 +182,8 @@ function EditorView({
case PlayerTeam.Allies:
setter = setAllies
}
if (player.ballState == BallState.HOLDS) {
setObjects([{ key: "ball" }])
if (player.ballState == BallState.HOLDS_BY_PASS) {
setObjects([{key: "ball"}])
}
setter((players) => [
...players,
@ -201,47 +195,97 @@ function EditorView({
])
}
const doMoveBall = (newBounds: DOMRect) => {
const doRemovePlayer = useCallback((component: Player | PlayerPhantom) => {
setContent((c) => removePlayer(component, c))
if (component.type == "player") insertRackedPlayer(component)
}, [setContent])
const doMoveBall = useCallback((newBounds: DOMRect, from?: Player | PlayerPhantom) => {
setContent((content) => {
const { newContent, removed } = placeBallAt(
if (from) {
content = changePlayerBallState(from, BallState.NONE, content)
}
content = placeBallAt(
newBounds,
courtBounds(),
content,
)
if (removed) {
setObjects((objects) => [...objects, { key: "ball" }])
}
return newContent
return content
})
}
}, [courtBounds, setContent])
const courtBounds = () => courtRef.current!.getBoundingClientRect()
const validatePlayerPosition = useCallback((player: Player | PlayerPhantom, info: PlayerInfo, newPos: Pos) => {
setContent((content) =>
moveComponent(
newPos,
player,
info,
courtBounds(),
content,
const renderPlayer = (component: Player | PlayerPhantom) => {
let info: PlayerInfo
(content) => {
if (player.type == "player") insertRackedPlayer(player)
return removePlayer(player, content)
},
),
)
}, [courtBounds, setContent])
const renderAvailablePlayerActions = useCallback((info: PlayerInfo, player: Player | PlayerPhantom) => {
let canPlaceArrows: boolean
const isPhantom = component.type == "phantom"
if (isPhantom) {
const origin = getOrigin(component, content.components)
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(component.id) == path.items.length - 1
path.items.indexOf(player.id) == path.items.length - 1
if (canPlaceArrows) {
// and if their only action is to shoot the ball
// list the actions the phantoms does
const phantomActions = component.actions
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 renderPlayer = useCallback((component: Player | PlayerPhantom) => {
let info: PlayerInfo
const isPhantom = component.type == "phantom"
if (isPhantom) {
const origin = getOrigin(component, content.components)
info = {
id: component.id,
team: origin.team,
@ -251,14 +295,7 @@ function EditorView({
ballState: component.ballState,
}
} else {
// a player
info = component
// can place arrows only if the
canPlaceArrows =
component.path == null ||
component.actions.findIndex(
(p) => p.type != ActionKind.SHOOT,
) == -1
}
return (
@ -266,165 +303,87 @@ function EditorView({
key={component.id}
className={isPhantom ? "phantom" : "player"}
playerInfo={info}
onPositionValidated={(newPos) => {
setContent((content) =>
moveComponent(
newPos,
component,
info,
courtBounds(),
content,
(content) => {
if (!isPhantom) insertRackedPlayer(component)
return removePlayer(component, content)
},
),
)
}}
onRemove={() => {
setContent((c) => removePlayer(component, c))
if (!isPhantom) insertRackedPlayer(component)
}}
onPositionValidated={(newPos) => validatePlayerPosition(component, info, newPos)}
onRemove={() => doRemovePlayer(component)}
courtRef={courtRef}
availableActions={() => [
canPlaceArrows && (
<ArrowAction
key={1}
onHeadMoved={(headPos) => {
const arrowHeadPos = middlePos(headPos)
const targetIdx = getComponentCollided(
headPos,
content.components,
availableActions={() => renderAvailablePlayerActions(info, component)}
/>
)
}, [content.components, doRemovePlayer, renderAvailablePlayerActions, validatePlayerPosition])
setPreviewAction((action) => ({
...action!,
segments: [
{
next: ratioWithinBase(
arrowHeadPos,
courtBounds(),
),
},
],
type: getActionKind(
targetIdx != -1,
info.ballState,
),
}))
}}
onHeadPicked={(headPos) => {
;(document.activeElement as HTMLElement).blur()
const doDeleteAction = useCallback((
action: Action,
idx: number,
origin: TacticComponent,
) => {
setContent((content) => removeAction(origin, action, idx, content))
}, [setContent])
setPreviewAction({
origin: component.id,
type: getActionKind(false, info.ballState),
target: ratioWithinBase(
headPos,
courtBounds(),
),
segments: [
const doUpdateAction = useCallback((component: TacticComponent, action: Action, actionIndex: number) => {
setContent((content) =>
updateComponent(
{
next: ratioWithinBase(
middlePos(headPos),
courtBounds(),
...component,
actions:
component.actions.toSpliced(
actionIndex,
1,
action,
),
},
],
})
}}
onHeadDropped={(headRect) => {
setContent((content) => {
let { createdAction, newContent } =
placeArrow(
component,
courtBounds(),
headRect,
content,
),
)
}, [setContent])
let originNewBallState = component.ballState
const renderComponent = useCallback((component: TacticComponent) => {
if (
createdAction.type == ActionKind.SHOOT
component.type == "player" ||
component.type == "phantom"
) {
const targetIdx =
newContent.components.findIndex(
(c) =>
c.id ==
createdAction.target,
)
newContent = dropBallOnComponent(
targetIdx,
newContent,
)
originNewBallState = BallState.SHOOTED
return renderPlayer(component)
}
newContent = updateComponent(
{
...(newContent.components.find(
(c) => c.id == component.id,
)! as Player | PlayerPhantom),
ballState: originNewBallState,
},
newContent,
if (component.type == BALL_TYPE) {
return (
<CourtBall
key="ball"
ball={component}
onPosValidated={doMoveBall}
onRemove={() => {
setContent((content) =>
removeBall(content),
)
return newContent
})
setPreviewAction(null)
setObjects((objects) => [
...objects,
{key: "ball"},
])
}}
/>
),
info.ballState != BallState.NONE && (
<BallAction key={2} onDrop={doMoveBall} />
),
]}
/>
)
}
const doDeleteAction = (
action: Action,
idx: number,
component: TacticComponent,
) => {
setContent((content) => {
content = updateComponent(
{
...component,
actions: component.actions.toSpliced(idx, 1),
},
content,
throw new Error(
"unknown tactic component " + component,
)
}, [renderPlayer, doMoveBall, setContent])
if (action.target == null) return content
const target = content.components.find(
(c) => action.target == c.id,
)!
if (target.type == "phantom") {
let path = null
if (component.type == "player") {
path = component.path
} else if (component.type == "phantom") {
path = getOrigin(component, content.components).path
}
if (
path == null ||
path.items.find((c) => c == target.id) == null
) {
return content
}
content = removePlayer(target, content)
}
return content
})
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">
@ -433,58 +392,34 @@ function EditorView({
Home
</button>
<div id="topbar-left">
<SavingState state={saveState} />
<SavingState state={saveState}/>
</div>
<div id="title-input-div">
<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 id="topbar-right"/>
</div>
<div id="edit-div">
<div id="racks">
<Rack
id="allies-rack"
objects={allies}
onChange={setAllies}
canDetach={(div) =>
overlaps(courtBounds(), div.getBoundingClientRect())
}
onElementDetached={(r, e) =>
setComponents((components) => [
...components,
placePlayerAt(
r.getBoundingClientRect(),
courtBounds(),
e,
),
])
}
render={({ team, key }) => (
<PlayerPiece
team={team}
text={key}
key={key}
hasBall={false}
/>
)}
/>
<PlayerRack id={"allies"} objects={allies} setObjects={setAllies} setComponents={setComponents}
courtRef={courtRef}/>
<Rack
id={"objects"}
objects={objects}
onChange={setObjects}
canDetach={(div) =>
canDetach={useCallback((div) =>
overlaps(courtBounds(), div.getBoundingClientRect())
}
onElementDetached={(r, e) =>
, [courtBounds])}
onElementDetached={useCallback((r, e: RackedCourtObject) =>
setContent((content) =>
placeObjectAt(
r.getBoundingClientRect(),
@ -493,18 +428,51 @@ function EditorView({
content,
),
)
}
, [courtBounds, setContent])}
render={renderCourtObject}
/>
<PlayerRack id={"opponents"} objects={opponents} 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>
)
}
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="opponent-rack"
objects={opponents}
onChange={setOpponents}
canDetach={(div) =>
id={id}
objects={objects}
onChange={setObjects}
canDetach={useCallback((div) =>
overlaps(courtBounds(), div.getBoundingClientRect())
}
onElementDetached={(r, e) =>
, [courtBounds])}
onElementDetached={useCallback((r, e: RackedPlayer) =>
setComponents((components) => [
...components,
placePlayerAt(
@ -513,87 +481,138 @@ function EditorView({
e,
),
])
}
render={({ team, key }) => (
, [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
components={content.components}
courtImage={<Court courtType={courtType} />}
courtRef={courtRef}
previewAction={previewAction}
renderComponent={(component) => {
if (
component.type == "player" ||
component.type == "phantom"
) {
return renderPlayer(component)
}
if (component.type == BALL_TYPE) {
return (
<CourtBall
key="ball"
ball={component}
onPosValidated={doMoveBall}
onRemove={() => {
setContent((content) =>
removeBall(content),
)
setObjects((objects) => [
...objects,
{ key: "ball" },
])
}}
), [])}
/>
)
}
throw new Error(
"unknown tactic component " + component,
}
interface CourtPlayerArrowActionProps {
playerInfo: PlayerInfo
player: Player | PlayerPhantom
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,
),
isInvalid: !overlaps(headPos, courtBounds()) || !isActionValid(player, target, content.components)
}))
}}
renderActions={(component) =>
component.actions.map((action, i) => (
<CourtAction
key={"action-" + component.id + "-" + i}
action={action}
origin={component.id}
courtRef={courtRef}
onActionDeleted={() => {
doDeleteAction(action, i, component)
}}
onActionChanges={(a) =>
setContent((content) =>
updateComponent(
onHeadPicked={(headPos) => {
(document.activeElement as HTMLElement).blur()
setPreviewAction({
origin: playerInfo.id,
type: getActionKind(null, playerInfo.ballState),
target: ratioWithinBase(
headPos,
courtBounds(),
),
segments: [
{
...component,
actions:
component.actions.toSpliced(
i,
1,
a,
next: ratioWithinBase(
middlePos(headPos),
courtBounds(),
),
},
],
isInvalid: false
})
}}
onHeadDropped={(headRect) => {
if (isInvalid) {
setPreviewAction(null)
return
}
setContent((content) => {
let {createdAction, newContent} =
createAction(
player,
courtBounds(),
headRect,
content,
),
)
if (
createdAction.type == ActionKind.SHOOT
) {
const targetIdx =
newContent.components.findIndex(
(c) =>
c.id ==
createdAction.target,
)
newContent = dropBallOnComponent(
targetIdx,
newContent,
false
)
newContent = updateComponent(
{
...(newContent.components.find(
(c) => c.id == player.id,
)! as Player | PlayerPhantom),
ballState: BallState.PASSED,
},
newContent,
)
}
return newContent
})
setPreviewAction(null)
}}
/>
))
}
/>
</div>
</div>
</div>
</div>
)
}
@ -601,7 +620,7 @@ function isBallOnCourt(content: TacticContent) {
return (
content.components.findIndex(
(c) =>
(c.type == "player" && c.ballState == BallState.HOLDS) ||
(c.type == "player" && (c.ballState === BallState.HOLDS_ORIGIN || c.ballState === BallState.HOLDS_BY_PASS)) ||
c.type == BALL_TYPE,
) != -1
)
@ -609,18 +628,18 @@ function isBallOnCourt(content: TacticContent) {
function renderCourtObject(courtObject: RackedCourtObject) {
if (courtObject.key == "ball") {
return <BallPiece />
return <BallPiece/>
}
throw new Error("unknown racked court object " + courtObject.key)
}
function Court({ courtType }: { courtType: string }) {
function Court({courtType}: { courtType: string }) {
return (
<div id="court-image-div">
{courtType == "PLAIN" ? (
<PlainCourt id="court-image" />
<PlainCourt id="court-image"/>
) : (
<HalfCourt id="court-image" />
<HalfCourt id="court-image"/>
)}
</div>
)

@ -1,9 +1,8 @@
import { Action, ActionKind } from "../../model/tactic/Action"
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"
import { middlePos, Pos, ratioWithinBase } from "../../geo/Pos"
import {RefObject} from "react"
import {MoveToHead, ScreenHead} from "../../components/actions/ArrowAction"
import {ComponentId} from "../../model/tactic/Tactic"
export interface CourtActionProps {
origin: ComponentId
@ -11,6 +10,7 @@ export interface CourtActionProps {
onActionChanges: (a: Action) => void
onActionDeleted: () => void
courtRef: RefObject<HTMLElement>
isInvalid: boolean
}
export function CourtAction({
@ -19,16 +19,20 @@ export function CourtAction({
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
}
@ -56,6 +60,7 @@ export function CourtAction({
style={{
head,
dashArray,
color
}}
/>
)

@ -81,6 +81,4 @@ class AccountGateway {
return new Account($acc["token"], new User($acc["email"], $acc["username"], $acc["id"], $acc["profilePicture"]));
}
}

Loading…
Cancel
Save