You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
555 lines
15 KiB
555 lines
15 KiB
import { equals, Pos, ratioWithinBase } from "../geo/Pos.ts"
|
|
|
|
import {
|
|
BallState,
|
|
Player,
|
|
PlayerInfo,
|
|
PlayerLike,
|
|
PlayerPhantom,
|
|
PlayerTeam,
|
|
} from "../model/tactic/Player.ts"
|
|
import {
|
|
Ball,
|
|
BALL_ID,
|
|
BALL_TYPE,
|
|
CourtObject,
|
|
} from "../model/tactic/CourtObjects.ts"
|
|
import {
|
|
ComponentId,
|
|
StepContent,
|
|
TacticComponent,
|
|
} from "../model/tactic/Tactic.ts"
|
|
|
|
import { overlaps } from "../geo/Box.ts"
|
|
import { RackedCourtObject, RackedPlayer } from "../editor/RackedItems.ts"
|
|
import {
|
|
getComponent,
|
|
getOrigin,
|
|
getPrecomputedPosition,
|
|
removePlayer,
|
|
tryGetComponent,
|
|
} from "./PlayerDomains.ts"
|
|
import { Action, ActionKind } from "../model/tactic/Action.ts"
|
|
import { spreadNewStateFromOriginStateChange } from "./ActionsDomains.ts"
|
|
|
|
export function placePlayerAt(
|
|
refBounds: DOMRect,
|
|
courtBounds: DOMRect,
|
|
element: RackedPlayer,
|
|
): Player {
|
|
const pos = ratioWithinBase(refBounds, courtBounds)
|
|
|
|
return {
|
|
type: "player",
|
|
id: "player-" + element.key + "-" + element.team,
|
|
team: element.team,
|
|
role: element.key,
|
|
pos,
|
|
ballState: BallState.NONE,
|
|
path: null,
|
|
actions: [],
|
|
frozen: false,
|
|
}
|
|
}
|
|
|
|
export function placeObjectAt(
|
|
refBounds: DOMRect,
|
|
courtBounds: DOMRect,
|
|
rackedObject: RackedCourtObject,
|
|
content: StepContent,
|
|
): StepContent {
|
|
const pos = ratioWithinBase(refBounds, courtBounds)
|
|
|
|
let courtObject: CourtObject
|
|
|
|
switch (rackedObject.key) {
|
|
case BALL_TYPE: {
|
|
const playerCollidedIdx = getComponentCollided(
|
|
refBounds,
|
|
content.components,
|
|
BALL_ID,
|
|
)
|
|
if (playerCollidedIdx != -1) {
|
|
return dropBallOnComponent(playerCollidedIdx, content, true)
|
|
}
|
|
|
|
courtObject = {
|
|
type: BALL_TYPE,
|
|
id: BALL_ID,
|
|
pos,
|
|
actions: [],
|
|
frozen: false,
|
|
}
|
|
break
|
|
}
|
|
default:
|
|
throw new Error("unknown court object " + rackedObject.key)
|
|
}
|
|
|
|
return {
|
|
...content,
|
|
components: [...content.components, courtObject],
|
|
}
|
|
}
|
|
|
|
export function dropBallOnComponent(
|
|
targetedComponentIdx: number,
|
|
content: StepContent,
|
|
setAsOrigin: boolean,
|
|
): StepContent {
|
|
const component = content.components[targetedComponentIdx]
|
|
|
|
if (component.type === "player" || component.type === "phantom") {
|
|
const newState =
|
|
setAsOrigin ||
|
|
component.ballState === BallState.PASSED_ORIGIN ||
|
|
component.ballState === BallState.HOLDS_ORIGIN
|
|
? BallState.HOLDS_ORIGIN
|
|
: BallState.HOLDS_BY_PASS
|
|
|
|
content =
|
|
spreadNewStateFromOriginStateChange(component, newState, content) ??
|
|
content
|
|
}
|
|
|
|
return removeBall(content)
|
|
}
|
|
|
|
export function removeBall(content: StepContent): StepContent {
|
|
const ballObjIdx = content.components.findIndex((o) => o.type == "ball")
|
|
|
|
if (ballObjIdx == -1) {
|
|
return content
|
|
}
|
|
|
|
return {
|
|
...content,
|
|
components: content.components.toSpliced(ballObjIdx, 1),
|
|
}
|
|
}
|
|
|
|
export function placeBallAt(
|
|
refBounds: DOMRect,
|
|
courtBounds: DOMRect,
|
|
content: StepContent,
|
|
): StepContent {
|
|
if (!overlaps(courtBounds, refBounds)) {
|
|
return removeBall(content)
|
|
}
|
|
const playerCollidedIdx = getComponentCollided(
|
|
refBounds,
|
|
content.components,
|
|
BALL_ID,
|
|
)
|
|
|
|
if (playerCollidedIdx != -1) {
|
|
return dropBallOnComponent(playerCollidedIdx, content, true)
|
|
}
|
|
|
|
const ballIdx = content.components.findIndex((o) => o.type == "ball")
|
|
|
|
const pos = ratioWithinBase(refBounds, courtBounds)
|
|
|
|
const ball: Ball = {
|
|
type: BALL_TYPE,
|
|
id: BALL_ID,
|
|
pos,
|
|
actions: [],
|
|
frozen: false,
|
|
}
|
|
|
|
let components = content.components
|
|
|
|
if (ballIdx != -1) {
|
|
components = components.toSpliced(ballIdx, 1, ball)
|
|
} else {
|
|
components = components.concat(ball)
|
|
}
|
|
|
|
return {
|
|
...content,
|
|
components,
|
|
}
|
|
}
|
|
|
|
export function moveComponent(
|
|
newPos: Pos,
|
|
component: TacticComponent,
|
|
info: PlayerInfo,
|
|
courtBounds: DOMRect,
|
|
content: StepContent,
|
|
removed: (content: StepContent) => StepContent,
|
|
): StepContent {
|
|
const playerBounds = document
|
|
.getElementById(info.id)!
|
|
.getBoundingClientRect()
|
|
|
|
// if the piece is no longer on the court, remove it
|
|
if (!overlaps(playerBounds, courtBounds)) {
|
|
return removed(content)
|
|
}
|
|
|
|
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,
|
|
pos: isPhantom
|
|
? {
|
|
type: "fixed",
|
|
...newPos,
|
|
}
|
|
: newPos,
|
|
},
|
|
content,
|
|
)
|
|
return content
|
|
}
|
|
|
|
export function removeComponent(
|
|
componentId: ComponentId,
|
|
content: StepContent,
|
|
): StepContent {
|
|
return {
|
|
...content,
|
|
components: content.components.filter((c) => c.id !== componentId),
|
|
}
|
|
}
|
|
|
|
export function updateComponent(
|
|
component: TacticComponent,
|
|
content: StepContent,
|
|
): StepContent {
|
|
return {
|
|
...content,
|
|
components: content.components.map((c) =>
|
|
c.id === component.id ? component : c,
|
|
),
|
|
}
|
|
}
|
|
|
|
export function getComponentCollided(
|
|
bounds: DOMRect,
|
|
components: TacticComponent[],
|
|
ignore?: ComponentId,
|
|
): number | -1 {
|
|
for (let i = 0; i < components.length; i++) {
|
|
const component = components[i]
|
|
|
|
if (component.id == ignore) continue
|
|
|
|
const playerBounds = document
|
|
.getElementById(component.id)!
|
|
.getBoundingClientRect()
|
|
|
|
if (overlaps(playerBounds, bounds)) {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
export function getRackPlayers(
|
|
team: PlayerTeam,
|
|
components: TacticComponent[],
|
|
): RackedPlayer[] {
|
|
return ["1", "2", "3", "4", "5"]
|
|
.filter(
|
|
(role) =>
|
|
components.findIndex(
|
|
(c) =>
|
|
c.type == "player" && c.team == team && c.role == role,
|
|
) == -1,
|
|
)
|
|
.map((key) => ({ team, key }))
|
|
}
|
|
|
|
/**
|
|
* Returns a step content that only contains the terminal state of each components inside the given content
|
|
* @param content
|
|
* @param computedPositions
|
|
*/
|
|
export function computeTerminalState(
|
|
content: StepContent,
|
|
computedPositions: Map<string, Pos>,
|
|
): StepContent {
|
|
const nonPhantomComponents: (Player | CourtObject)[] =
|
|
content.components.filter(
|
|
(c): c is Exclude<TacticComponent, PlayerPhantom> =>
|
|
c.type !== "phantom",
|
|
)
|
|
|
|
const componentsTargetedState = nonPhantomComponents.map((comp) =>
|
|
comp.type === "player"
|
|
? getPlayerTerminalState(comp, content, computedPositions)
|
|
: {
|
|
...comp,
|
|
frozen: true,
|
|
},
|
|
)
|
|
|
|
return {
|
|
components: componentsTargetedState,
|
|
}
|
|
}
|
|
|
|
function getPlayerTerminalState(
|
|
player: Player,
|
|
content: StepContent,
|
|
computedPositions: Map<string, Pos>,
|
|
): Player {
|
|
function stateAfter(state: BallState): BallState {
|
|
switch (state) {
|
|
case BallState.HOLDS_ORIGIN:
|
|
return BallState.HOLDS_ORIGIN
|
|
case BallState.PASSED_ORIGIN:
|
|
case BallState.PASSED:
|
|
return BallState.NONE
|
|
case BallState.HOLDS_BY_PASS:
|
|
return BallState.HOLDS_ORIGIN
|
|
case BallState.NONE:
|
|
return BallState.NONE
|
|
}
|
|
}
|
|
|
|
function getTerminalPos(component: PlayerLike): Pos {
|
|
if (component.type === "phantom") {
|
|
const pos = getPrecomputedPosition(component, computedPositions)
|
|
if (!pos)
|
|
throw new Error(
|
|
`Attempted to get the terminal state of a step content with missing position for phantom ${component.id}`,
|
|
)
|
|
return pos
|
|
}
|
|
return component.pos
|
|
}
|
|
|
|
const phantoms = player.path?.items
|
|
if (!phantoms || phantoms.length === 0) {
|
|
const pos = getTerminalPos(player)
|
|
|
|
return {
|
|
...player,
|
|
ballState: stateAfter(player.ballState),
|
|
actions: [],
|
|
pos,
|
|
frozen: true,
|
|
}
|
|
}
|
|
const lastPhantomId = phantoms[phantoms.length - 1]
|
|
const lastPhantom = content.components.find(
|
|
(c) => c.id === lastPhantomId,
|
|
)! as PlayerPhantom
|
|
|
|
const pos = getTerminalPos(lastPhantom)
|
|
|
|
return {
|
|
type: "player",
|
|
path: { items: [] },
|
|
role: player.role,
|
|
team: player.team,
|
|
|
|
actions: [],
|
|
ballState: stateAfter(lastPhantom.ballState),
|
|
id: player.id,
|
|
pos,
|
|
frozen: true,
|
|
}
|
|
}
|
|
|
|
export function drainTerminalStateOnChildContent(
|
|
parentTerminalState: StepContent,
|
|
childContent: StepContent,
|
|
): StepContent | null {
|
|
let gotUpdated = false
|
|
|
|
for (const parentComponent of parentTerminalState.components) {
|
|
let childComponent = tryGetComponent(
|
|
parentComponent.id,
|
|
childContent.components,
|
|
)
|
|
|
|
if (!childComponent) {
|
|
//if the child does not contain the parent's component, add it to the children's content.
|
|
childContent = {
|
|
...childContent,
|
|
components: [...childContent.components, parentComponent],
|
|
}
|
|
gotUpdated = true
|
|
continue
|
|
}
|
|
|
|
if (childComponent.type !== parentComponent.type)
|
|
throw Error("child and parent components are not of the same type.")
|
|
|
|
if (childComponent.type === "ball" && parentComponent.type === "ball") {
|
|
gotUpdated = true
|
|
childContent = updateComponent(
|
|
{
|
|
...childComponent,
|
|
frozen: true,
|
|
pos: parentComponent.pos,
|
|
},
|
|
childContent,
|
|
)
|
|
}
|
|
|
|
// ensure that the component is a player
|
|
if (
|
|
parentComponent.type !== "player" ||
|
|
childComponent.type !== "player"
|
|
) {
|
|
continue
|
|
}
|
|
|
|
const newContentResult = spreadNewStateFromOriginStateChange(
|
|
childComponent,
|
|
parentComponent.ballState,
|
|
childContent,
|
|
)
|
|
if (newContentResult) {
|
|
gotUpdated = true
|
|
childContent = newContentResult
|
|
childComponent = getComponent<Player>(
|
|
childComponent.id,
|
|
newContentResult?.components,
|
|
)
|
|
}
|
|
// update the position of the component if it has been moved
|
|
// also force update if the child component is not frozen (the component was introduced previously by the child step but the parent added it afterward)
|
|
if (
|
|
!childComponent.frozen ||
|
|
!equals(childComponent.pos, parentComponent.pos)
|
|
) {
|
|
gotUpdated = true
|
|
childContent = updateComponent(
|
|
{
|
|
...childComponent,
|
|
frozen: true,
|
|
pos: parentComponent.pos,
|
|
},
|
|
childContent,
|
|
)
|
|
}
|
|
}
|
|
|
|
const initialChildCompsCount = childContent.components.length
|
|
|
|
//remove players if they are not present on the parent's anymore
|
|
for (const component of childContent.components) {
|
|
if (
|
|
component.type !== "phantom" &&
|
|
component.frozen &&
|
|
!tryGetComponent(component.id, parentTerminalState.components)
|
|
) {
|
|
if (component.type === "player")
|
|
childContent = removePlayer(component, childContent)
|
|
else
|
|
childContent = {
|
|
...childContent,
|
|
components: childContent.components.filter(
|
|
(c) => c.id !== component.id,
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
gotUpdated ||= childContent.components.length !== initialChildCompsCount
|
|
|
|
return gotUpdated ? childContent : null
|
|
}
|
|
|
|
export function mapToParentContent(content: StepContent): StepContent {
|
|
return mapIdentifiers(content, (id) => id + "-parent")
|
|
}
|
|
|
|
export function mapIdentifiers(
|
|
content: StepContent,
|
|
f: (id: string) => string,
|
|
): StepContent {
|
|
function mapToParentActions(actions: Action[]): Action[] {
|
|
return actions.map((a) => ({
|
|
...a,
|
|
target: typeof a.target === "string" ? f(a.target) : a.target,
|
|
segments: a.segments.map((s) => ({
|
|
...s,
|
|
next: typeof s.next === "string" ? f(s.next) : s.next,
|
|
})),
|
|
}))
|
|
}
|
|
|
|
return {
|
|
...content,
|
|
components: content.components.map((p) => {
|
|
if (p.type == "ball") return p
|
|
if (p.type == "player") {
|
|
return {
|
|
...p,
|
|
id: f(p.id),
|
|
actions: mapToParentActions(p.actions),
|
|
path: p.path && {
|
|
items: p.path.items.map(f),
|
|
},
|
|
}
|
|
}
|
|
return {
|
|
...p,
|
|
pos:
|
|
p.pos.type == "follows"
|
|
? { ...p.pos, attach: f(p.pos.attach) }
|
|
: p.pos,
|
|
id: f(p.id),
|
|
originPlayerId: f(p.originPlayerId),
|
|
actions: mapToParentActions(p.actions),
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
export function selectContent(
|
|
id: string,
|
|
content: StepContent,
|
|
parentContent: StepContent | null,
|
|
): StepContent {
|
|
return parentContent && id.endsWith("-parent") ? parentContent : content
|
|
}
|