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.
Application-Web/src/domains/TacticContentDomains.ts

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
}