add phantoms for screen actions

pull/113/head
maxime 1 year ago committed by maxime.batista
parent 264b46cd02
commit c3cf23da0c

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

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

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

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

@ -1,9 +1,9 @@
import React, { ReactNode, RefObject, useCallback, useRef } from "react"
import React, {ReactNode, RefObject, useCallback, useRef} from "react"
import "../../style/player.css"
import 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
@ -15,21 +15,24 @@ export interface CourtPlayerProps {
availableActions: (ro: HTMLElement) => ReactNode[]
}
const MOVE_AREA_SENSIBILITY = 0.001
export const PLAYER_RADIUS_PIXELS = 20
/**
* A player that is placed on the court, which can be selected, and moved in the associated bounds
* */
export default function CourtPlayer({
playerInfo,
className,
playerInfo,
className,
onPositionValidated,
onRemove,
courtRef,
availableActions,
}: CourtPlayerProps) {
onPositionValidated,
onRemove,
courtRef,
availableActions,
}: CourtPlayerProps) {
const usesBall = playerInfo.ballState != BallState.NONE
const x = playerInfo.rightRatio
const y = playerInfo.bottomRatio
const {x, y} = playerInfo.pos
const pieceRef = useRef<HTMLDivElement>(null)
return (
@ -44,7 +47,8 @@ export default function CourtPlayer({
const pos = ratioWithinBase(pieceBounds, parentBounds)
if (pos.x !== x || pos.y != y) onPositionValidated(pos)
if (Math.abs(pos.x - x) >= MOVE_AREA_SENSIBILITY || Math.abs(pos.y - y) >= MOVE_AREA_SENSIBILITY) onPositionValidated(pos)
}, [courtRef, onPositionValidated, x, y])}>
<div
id={playerInfo.id}

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

@ -1,16 +1,10 @@
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"
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"
import {add, minus, norm, Pos, posWithinBase, ratioWithinBase, relativeTo} from "../geo/Pos.ts"
import {PLAYER_RADIUS_PIXELS} from "../components/editor/CourtPlayer.tsx";
export function getOrigin(
pathItem: PlayerPhantom,
@ -20,6 +14,65 @@ export function getOrigin(
return components.find((c) => c.id == pathItem.originPlayerId)! as Player
}
//FIXME this function can be a bottleneck if the phantom's position is
// following another phantom and / or the origin of the phantom is another
export function computePhantomPositioning(phantom: PlayerPhantom,
content: TacticContent,
area: DOMRect): Pos {
const positioning = phantom.pos
// If the position is already known and fixed, return the pos
if (positioning.type === "fixed")
return positioning
// If the position is to determine (positioning.type = "follows"), determine the phantom's pos
// by calculating it from the referent position, and the action that targets the referent.
const components = content.components
// Get the referent from the components
const referent: PlayerLike = getComponent(positioning.attach, components)
const referentPos = referent.type === "player"
? referent.pos
: computePhantomPositioning(referent, content, area)
// Get the origin
const origin = getOrigin(phantom, components)
const originPathItems = origin.path!.items
const phantomIdx = originPathItems.indexOf(phantom.id)
const playerBeforePhantom: PlayerLike = phantomIdx == 0 ? origin : getComponent(originPathItems[phantomIdx - 1], components)
const action = playerBeforePhantom.actions.find(a => a.target === positioning.attach)!
const segments = action.segments
const lastSegment = segments[segments.length - 1]
const lastSegmentStart = segments[segments.length - 2]?.next
const pivotPoint = lastSegment.controlPoint ?? (lastSegmentStart
? typeof lastSegmentStart === "string"
? document.getElementById(lastSegmentStart)!.getBoundingClientRect()
: lastSegmentStart
: playerBeforePhantom.type === "phantom"
? computePhantomPositioning(playerBeforePhantom, content, area)
: playerBeforePhantom.pos)
const segment = posWithinBase(relativeTo(referentPos, pivotPoint), area)
const segmentLength = norm(segment)
const phantomDistanceFromReferent = PLAYER_RADIUS_PIXELS //TODO Place this in constants
const segmentProjection = minus(area, {
x: (segment.x / segmentLength) * phantomDistanceFromReferent,
y: (segment.y / segmentLength) * phantomDistanceFromReferent
})
const segmentProjectionRatio: Pos = ratioWithinBase(segmentProjection, area)
return add(referentPos, segmentProjectionRatio)
}
export function getComponent<T extends TacticComponent>(id: string, components: TacticComponent[]): T {
return components.find(c => c.id === id)! as T
}
export function areInSamePath(a: PlayerLike, b: PlayerLike) {
if (a.type === "phantom" && b.type === "phantom") {
return a.originPlayerId === b.originPlayerId
@ -132,9 +185,9 @@ export function truncatePlayerPath(
truncateStartIdx == 0
? null
: {
...path,
items: path.items.toSpliced(truncateStartIdx),
},
...path,
items: path.items.toSpliced(truncateStartIdx),
},
},
content,
)

@ -1,39 +1,25 @@
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"
import {Pos, ratioWithinBase} from "../geo/Pos"
import {BallState, Player, PlayerInfo, PlayerLike, 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, getComponent, getOrigin} from "./PlayerDomains"
import {ActionKind} from "../model/tactic/Action.ts";
export function placePlayerAt(
refBounds: DOMRect,
courtBounds: DOMRect,
element: RackedPlayer,
): Player {
const { x, y } = ratioWithinBase(refBounds, courtBounds)
const pos = ratioWithinBase(refBounds, courtBounds)
return {
type: "player",
id: "player-" + element.key + "-" + element.team,
team: element.team,
role: element.key,
rightRatio: x,
bottomRatio: y,
pos,
ballState: BallState.NONE,
path: null,
actions: [],
@ -46,7 +32,7 @@ export function placeObjectAt(
rackedObject: RackedCourtObject,
content: TacticContent,
): TacticContent {
const { x, y } = ratioWithinBase(refBounds, courtBounds)
const pos = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject
@ -64,8 +50,7 @@ export function placeObjectAt(
courtObject = {
type: BALL_TYPE,
id: BALL_ID,
rightRatio: x,
bottomRatio: y,
pos,
actions: [],
}
break
@ -134,13 +119,12 @@ export function placeBallAt(
const ballIdx = content.components.findIndex((o) => o.type == "ball")
const { x, y } = ratioWithinBase(refBounds, courtBounds)
const pos = ratioWithinBase(refBounds, courtBounds)
const ball: Ball = {
type: BALL_TYPE,
id: BALL_ID,
rightRatio: x,
bottomRatio: y,
pos,
actions: [],
}
@ -174,14 +158,44 @@ export function moveComponent(
if (!overlaps(playerBounds, courtBounds)) {
return removed(content)
}
return updateComponent(
{
const isPhantom = component.type === "phantom"
if (isPhantom && component.pos.type === "follows") {
const referent = component.pos.attach
const origin = getOrigin(component, content.components)
const originPathItems = origin.path!.items
const phantomIdx = originPathItems.indexOf(component.id)
const playerBeforePhantom: PlayerLike = phantomIdx == 0 ? origin : getComponent(originPathItems[phantomIdx - 1], content.components)
// detach the action from the screen target and transform it to a regular move action to the phantom.
content = updateComponent({
...playerBeforePhantom,
actions: playerBeforePhantom.actions.map(a => a.target === referent ? {
...a,
segments: a.segments.toSpliced(a.segments.length - 2, 1, {
...a.segments[a.segments.length - 1],
next: component.id,
}),
target: component.id,
type: ActionKind.MOVE
} : a),
}, content)
}
content = updateComponent(
<TacticComponent>{
...component,
rightRatio: newPos.x,
bottomRatio: newPos.y,
pos: isPhantom
? {
type: "fixed",
...newPos
}
: newPos
},
content,
)
return content
}
export function removeComponent(
@ -239,5 +253,5 @@ export function getRackPlayers(
c.type == "player" && c.team == team && c.role == role,
) == -1,
)
.map((key) => ({ team, key }))
.map((key) => ({team, key}))
}

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

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

@ -1,4 +1,5 @@
import { Component, ComponentId } from "./Tactic"
import { Pos } from "../../geo/Pos.ts"
export type PlayerId = string
@ -9,7 +10,7 @@ export enum PlayerTeam {
Opponents = "opponents",
}
export interface Player extends PlayerInfo, Component<"player"> {
export interface Player extends PlayerInfo, Component<"player", Pos> {
readonly id: PlayerId
}
@ -33,15 +34,7 @@ export interface PlayerInfo {
*/
readonly ballState: BallState
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
readonly bottomRatio: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
readonly rightRatio: number
readonly pos: Pos,
}
export enum BallState {
@ -52,7 +45,7 @@ export enum BallState {
PASSED_ORIGIN,
}
export interface Player extends Component<"player">, PlayerInfo {
export interface Player extends Component<"player", Pos>, PlayerInfo {
/**
* True if the player has a basketball
*/
@ -65,11 +58,32 @@ export interface MovementPath {
readonly items: ComponentId[]
}
/**
* The position of the phantom is known and fixed
*/
export type FixedPhantomPositioning = ({ type: "fixed" } & Pos)
/**
* The position of the phantom is constrained to a given component.
* The actual position of the phantom is to determine given its environment.
*/
export type FollowsPhantomPositioning = { type: "follows", attach: ComponentId }
/**
* Defines the different kind of positioning a phantom can have
*/
export type PhantomPositioning = FixedPhantomPositioning | FollowsPhantomPositioning
/**
* A player phantom is a kind of component that represents the future state of a player
* according to the court's step information
*/
export interface PlayerPhantom extends Component<"phantom"> {
export interface PlayerPhantom extends Component<"phantom", PhantomPositioning> {
readonly originPlayerId: ComponentId
readonly ballState: BallState
/**
* Defines a component this phantom will be attached to.
*/
readonly attachedTo?: ComponentId
}

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

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

@ -1,87 +0,0 @@
.step-piece {
position: relative;
font-family: monospace;
pointer-events: all;
background-color: var(--editor-tree-step-piece);
color: var(--selected-team-secondarycolor);
border-radius: 100px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
cursor: pointer;
border: 2px solid var(--editor-tree-background);
}
.step-piece-selected {
border: 2px solid var(--selection-color-light);
}
.step-piece-selected,
.step-piece:focus,
.step-piece:hover {
background-color: var(--editor-tree-step-piece-hovered);
}
.step-piece-actions {
display: none;
position: absolute;
column-gap: 5px;
top: -140%;
}
.step-piece-selected .step-piece-actions {
display: flex;
}
.add-icon,
.remove-icon {
background-color: white;
border-radius: 100%;
}
.add-icon {
fill: var(--add-icon-fill);
}
.remove-icon {
fill: var(--remove-icon-fill);
}
.step-children {
margin-top: 10vh;
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
.step-group {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
}
.steps-tree {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding-top: 10%;
height: 100%;
}
Loading…
Cancel
Save