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( { ...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, ): StepContent { const nonPhantomComponents: (Player | CourtObject)[] = content.components.filter( (c): c is Exclude => 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, ): 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( 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 }