Add screen phantoms #113

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

@ -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) {

@ -37,8 +37,8 @@ export function BasketCourt({
style={{ position: "relative" }}>
{courtImage}
{components.map(renderComponent)}
{components.flatMap(renderActions)}
{courtRef.current && components.map(renderComponent)}
{courtRef.current && components.flatMap(renderActions)}
{previewAction && (
<CourtAction

@ -13,8 +13,7 @@ export interface 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

@ -15,6 +15,10 @@ 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
* */
@ -28,8 +32,7 @@ export default function CourtPlayer({
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,11 @@ 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,8 +1,8 @@
import {
BallState,
Player,
PlayerPhantom,
PlayerLike,
PlayerPhantom,
} from "../model/tactic/Player"
import { ratioWithinBase } from "../geo/Pos"
import {
@ -16,7 +16,9 @@ import { removeBall, updateComponent } from "./TacticContentDomains"
import {
areInSamePath,
changePlayerBallState,
getComponent,
getOrigin,
getPlayerNextTo,
isNextInPath,
removePlayer,
} from "./PlayerDomains"
@ -173,11 +175,9 @@ function alreadyHasAnAnteriorActionWith(
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
const component = getComponent(targetOriginPath[i], components)
if (
phantom.actions.find(
component.actions.find(
(a) =>
typeof a.target === "string" &&
moves(a.type) &&
@ -218,7 +218,10 @@ export function createAction(
* Creates a new phantom component.
* Be aware that this function will reassign the `content` parameter.
*/
function createPhantom(forceHasBall: boolean): ComponentId {
function createPhantom(
forceHasBall: boolean,
attachedTo?: ComponentId,
): ComponentId {
const { x, y } = ratioWithinBase(arrowHead, courtBounds)
let itemIndex: number
@ -269,8 +272,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,10 +310,24 @@ 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 {
@ -318,11 +343,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,
type: actionKind,
segments: [{ next: phantomId }],
}
return {
@ -358,10 +390,10 @@ export function removeAllActionsTargeting(
export function removeAction(
origin: TacticComponent,
action: Action,
actionIdx: number,
content: TacticContent,
): TacticContent {
const action = origin.actions[actionIdx]
origin = {
...origin,
actions: origin.actions.toSpliced(actionIdx, 1),
@ -408,6 +440,15 @@ 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 screenPhantom = getPlayerNextTo(origin, 1, content.components)!
content = removePlayer(screenPhantom, content)
}
return content
}
@ -440,9 +481,10 @@ export function spreadNewStateFromOriginStateChange(
continue
}
const actionTarget = content.components.find(
(c) => action.target === c.id,
)! as PlayerLike
const actionTarget: PlayerLike = getComponent(
action.target,
content.components,
)
let targetState: BallState = actionTarget.ballState
let deleteAction = false
@ -472,13 +514,23 @@ export function spreadNewStateFromOriginStateChange(
action.type === ActionKind.SCREEN
) {
targetState = BallState.HOLDS_BY_PASS
const screenPhantom = getPlayerNextTo(
origin,
1,
content.components,
)!
if (
screenPhantom.type === "phantom" &&
screenPhantom.pos.type === "follows"
) {
content = removePlayer(screenPhantom, content)
origin = getComponent(origin.id, content.components)
}
}
if (deleteAction) {
content = removeAction(origin, action, i, content)
origin = content.components.find((c) => c.id === origin.id)! as
| Player
| PlayerPhantom
content = removeAction(origin, i, content)
origin = getComponent(origin.id, content.components)
i-- // step back
} else {
// do not change the action type if it is a shoot action

@ -4,13 +4,27 @@ import {
PlayerLike,
PlayerPhantom,
} from "../model/tactic/Player"
import { TacticComponent, TacticContent } from "../model/tactic/Tactic"
import {
ComponentId,
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 +34,107 @@ export function getOrigin(
return components.find((c) => c.id == pathItem.originPlayerId)! as Player
}
export function getPlayerNextTo(
player: PlayerLike,
n: number,
components: TacticComponent[],
): PlayerLike | undefined {
const playerOrigin =
player.type === "player" ? player : getOrigin(player, components)
const pathItems = playerOrigin.path!.items
// add one as there is a shifting because a Player is never at the head of its own path
const idx = pathItems.indexOf(player.id) + 1 // is 0 if the player is the origin
const targetIdx = idx + n
// remove the screen phantom
return targetIdx == 0
? playerOrigin
: getComponent<PlayerLike>(pathItems[targetIdx - 1], components)
}
//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
let pivotPoint = lastSegment.controlPoint
if (!pivotPoint) {
if (lastSegmentStart) {
pivotPoint =
typeof lastSegmentStart === "string"
? document
.getElementById(lastSegmentStart)!
.getBoundingClientRect()
: lastSegmentStart
} else {
pivotPoint =
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
@ -54,7 +169,7 @@ export function isNextInPath(
)
}
export function removePlayerPath(
export function clearPlayerPath(
player: Player,
content: TacticContent,
): TacticContent {
@ -75,18 +190,60 @@ export function removePlayerPath(
)
}
function removeAllPhantomsAttached(
to: ComponentId,
content: TacticContent,
): TacticContent {
let i = 0
while (i < content.components.length) {
const component = content.components[i]
if (component.type === "phantom") {
if (
component.pos.type === "follows" &&
component.pos.attach === to
) {
content = removePlayer(component, content)
continue
}
}
i++
}
return content
}
export function removePlayer(
player: PlayerLike,
content: TacticContent,
): TacticContent {
content = removeAllActionsTargeting(player.id, content)
content = removeAllPhantomsAttached(player.id, content)
if (player.type === "phantom") {
const pos = player.pos
// if the phantom was attached to another player, remove the action that symbolizes the attachment
if (pos.type === "follows") {
const playerBefore = getPlayerNextTo(
player,
-1,
content.components,
)!
const actions = playerBefore.actions.filter(
(a) => a.target === pos.attach,
)
content = updateComponent(
{
...playerBefore,
actions,
},
content,
)
}
if (player.type == "phantom") {
const origin = getOrigin(player, content.components)
return truncatePlayerPath(origin, player, content)
}
content = removePlayerPath(player, content)
content = clearPlayerPath(player, content)
content = removeComponent(player.id, content)
for (const action of player.actions) {

@ -3,6 +3,7 @@ import {
BallState,
Player,
PlayerInfo,
PlayerLike,
PlayerTeam,
} from "../model/tactic/Player"
import {
@ -18,22 +19,22 @@ import {
} from "../model/tactic/Tactic"
import { overlaps } from "../geo/Box"
import { RackedCourtObject, RackedPlayer } from "./RackedItems"
import { changePlayerBallState } from "./PlayerDomains"
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 +47,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 +65,7 @@ export function placeObjectAt(
courtObject = {
type: BALL_TYPE,
id: BALL_ID,
rightRatio: x,
bottomRatio: y,
pos,
actions: [],
}
break
@ -134,13 +134,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 +173,61 @@ 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(

@ -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,10 +10,6 @@ export enum PlayerTeam {
Opponents = "opponents",
}
export interface Player extends PlayerInfo, Component<"player"> {
readonly id: PlayerId
}
/**
* All information about a player
*/
@ -33,15 +30,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 +41,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 +54,34 @@ 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[]
}

@ -68,6 +68,7 @@ import { Action, ActionKind } from "../model/tactic/Action"
import BallAction from "../components/actions/BallAction"
import {
changePlayerBallState,
computePhantomPositioning,
getOrigin,
removePlayer,
} from "../editor/PlayerDomains"
@ -329,7 +330,7 @@ function EditorView({
content,
(content) => {
if (player.type == "player") insertRackedPlayer(player)
if (player.type === "player") insertRackedPlayer(player)
return removePlayer(player, content)
},
),
@ -402,8 +403,11 @@ 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 {
@ -435,8 +439,8 @@ function EditorView({
)
const doDeleteAction = useCallback(
(action: Action, idx: number, origin: TacticComponent) => {
setContent((content) => removeAction(origin, action, idx, content))
(_: Action, idx: number, origin: TacticComponent) => {
setContent((content) => removeAction(origin, idx, content))
},
[setContent],
)

@ -57,10 +57,10 @@ export default function HomePage() {
}
function Home({
lastTactics,
allTactics,
teams,
}: {
lastTactics,
allTactics,
teams,
}: {
lastTactics: Tactic[]
allTactics: Tactic[]
teams: Team[]
@ -77,10 +77,10 @@ function Home({
}
function Body({
lastTactics,
allTactics,
teams,
}: {
lastTactics,
allTactics,
teams,
}: {
lastTactics: Tactic[]
allTactics: Tactic[]
teams: Team[]
@ -100,10 +100,10 @@ function Body({
}
function SideMenu({
width,
lastTactics,
teams,
}: {
width,
lastTactics,
teams,
}: {
width: number
lastTactics: Tactic[]
teams: Team[]
@ -123,9 +123,9 @@ function SideMenu({
}
function PersonalSpace({
width,
allTactics,
}: {
width,
allTactics,
}: {
width: number
allTactics: Tactic[]
}) {
@ -198,17 +198,15 @@ function TableData({ allTactics }: { allTactics: Tactic[] }) {
function BodyPersonalSpace({ allTactics }: { allTactics: Tactic[] }) {
return (
<div id="body-personal-space">
{
allTactics.length == 0
? <p>Aucune tactique créée !</p>
:
<table>
<tbody key="tbody">
{allTactics.length == 0 ? (
<p>Aucune tactique créée !</p>
) : (
<table>
<tbody key="tbody">
<TableData allTactics={allTactics} />
</tbody>
</table>
}
</tbody>
</table>
)}
</div>
)
}

@ -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