compute the terminal state of a step and apply it on newly created children

maxime 1 year ago
parent 5cc25772ff
commit 293d21c162

@ -1,26 +1,25 @@
import "../../style/steps_tree.css"
import { StepInfoNode } from "../../model/tactic/Tactic"
import BendableArrow from "../arrows/BendableArrow"
import { useRef, useState } from "react"
import { useRef } from "react"
import AddSvg from "../../assets/icon/add.svg?react"
import RemoveSvg from "../../assets/icon/remove.svg?react"
export interface StepsTreeProps {
root: StepInfoNode
selectedStepId: number
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
}
export default function StepsTree({
root,
onAddChildren,
onRemoveNode,
onStepSelected,
}: StepsTreeProps) {
const [selectedStepId, setSelectedStepId] = useState(root.id)
root,
selectedStepId,
onAddChildren,
onRemoveNode,
onStepSelected,
}: StepsTreeProps) {
return (
<div className="steps-tree">
<StepsTreeNode
@ -29,10 +28,7 @@ export default function StepsTree({
selectedStepId={selectedStepId}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
onStepSelected={(step) => {
setSelectedStepId(step.id)
onStepSelected(step)
}}
onStepSelected={onStepSelected}
/>
</div>
)
@ -41,34 +37,31 @@ export default function StepsTree({
interface StepsTreeContentProps {
node: StepInfoNode
isNodeRoot: boolean
selectedStepId: number,
selectedStepId: number
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
onStepSelected: (node: StepInfoNode) => void
}
function StepsTreeNode({
node,
isNodeRoot,
selectedStepId,
onAddChildren,
onRemoveNode,
onStepSelected,
}: StepsTreeContentProps) {
node,
isNodeRoot,
selectedStepId,
onAddChildren,
onRemoveNode,
onStepSelected,
}: StepsTreeContentProps) {
const ref = useRef<HTMLDivElement>(null)
return (
<div ref={ref}
className={"step-group"}>
<div ref={ref} className={"step-group"}>
{node.children.map((child) => (
<BendableArrow
key={child.id}
area={ref}
startPos={"step-piece-" + node.id}
segments={[{next: "step-piece-" + child.id}]}
onSegmentsChanges={() => {
}}
segments={[{ next: "step-piece-" + child.id }]}
onSegmentsChanges={() => {}}
forceStraight={true}
wavy={false}
//TODO remove magic constants
@ -80,7 +73,9 @@ function StepsTreeNode({
id={node.id}
isSelected={selectedStepId === node.id}
onAddButtonClicked={() => onAddChildren(node)}
onRemoveButtonClicked={isNodeRoot ? undefined : () => onRemoveNode(node)}
onRemoveButtonClicked={
isNodeRoot ? undefined : () => onRemoveNode(node)
}
onSelected={() => onStepSelected(node)}
/>
<div className={"step-children"}>
@ -109,17 +104,19 @@ interface StepPieceProps {
}
function StepPiece({
id,
isSelected,
onAddButtonClicked,
onRemoveButtonClicked,
onSelected,
}: StepPieceProps) {
id,
isSelected,
onAddButtonClicked,
onRemoveButtonClicked,
onSelected,
}: StepPieceProps) {
return (
<div
id={"step-piece-" + id}
tabIndex={1}
className={"step-piece " + (isSelected ? "step-piece-selected" : "")}
className={
"step-piece " + (isSelected ? "step-piece-selected" : "")
}
onClick={onSelected}>
<div className="step-piece-actions">
{onAddButtonClicked && (

@ -40,13 +40,16 @@ export function removeStepNode(
* @param root
*/
export function getAvailableId(root: StepInfoNode): number {
const acc = (root: StepInfoNode): number => Math.max(root.id, ...root.children.map(acc))
const acc = (root: StepInfoNode): number =>
Math.max(root.id, ...root.children.map(acc))
return acc(root) + 1
}
export function getParent(root: StepInfoNode, node: StepInfoNode): StepInfoNode | null {
if (root.children.find(n => n.id === node.id))
return root
export function getParent(
root: StepInfoNode,
node: StepInfoNode,
): StepInfoNode | null {
if (root.children.find((n) => n.id === node.id)) return root
for (const child of root.children) {
const result = getParent(child, node)
@ -55,4 +58,4 @@ export function getParent(root: StepInfoNode, node: StepInfoNode): StepInfoNode
}
}
return null
}
}

@ -5,6 +5,7 @@ import {
Player,
PlayerInfo,
PlayerLike,
PlayerPhantom,
PlayerTeam,
} from "../model/tactic/Player"
import {
@ -15,13 +16,18 @@ import {
} from "../model/tactic/CourtObjects"
import {
ComponentId,
TacticComponent,
StepContent,
TacticComponent,
} from "../model/tactic/Tactic"
import { overlaps } from "../geo/Box"
import { RackedCourtObject, RackedPlayer } from "./RackedItems"
import { changePlayerBallState, getComponent, getOrigin } from "./PlayerDomains"
import {
changePlayerBallState,
computePhantomPositioning,
getComponent,
getOrigin,
} from "./PlayerDomains"
import { ActionKind } from "../model/tactic/Action.ts"
export function placePlayerAt(
@ -289,3 +295,85 @@ export function getRackPlayers(
)
.map((key) => ({ team, key }))
}
/**
* Returns a step content that only contains the terminal state of each components inside the given content
* @param content
* @param courtArea
*/
export function getTerminalState(
content: StepContent,
courtArea: DOMRect,
): StepContent {
const nonPhantomComponents: (Player | CourtObject)[] =
content.components.filter((c) => c.type !== "phantom") as (
| Player
| CourtObject
)[]
const componentsTargetedState = nonPhantomComponents.map((comp) =>
comp.type === "player"
? getPlayerTerminalState(comp, content, courtArea)
: comp,
)
return {
components: componentsTargetedState,
}
}
function getPlayerTerminalState(
player: Player,
content: StepContent,
area: DOMRect,
): 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 {
return component.type === "phantom"
? computePhantomPositioning(component, content, area)
: component.pos
}
const phantoms = player.path?.items
if (!phantoms || phantoms.length === 0) {
const pos = getTerminalPos(player)
return {
...player,
ballState: stateAfter(player.ballState),
actions: [],
pos,
}
}
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,
}
}

@ -42,6 +42,7 @@ import {
dropBallOnComponent,
getComponentCollided,
getRackPlayers,
getTerminalState,
moveComponent,
placeBallAt,
placeObjectAt,
@ -79,7 +80,12 @@ import {
import { CourtBall } from "../components/editor/CourtBall"
import { useNavigate, useParams } from "react-router-dom"
import StepsTree from "../components/editor/StepsTree"
import { addStepNode, getAvailableId, getParent, removeStepNode } from "../editor/StepsDomain"
import {
addStepNode,
getAvailableId,
getParent,
removeStepNode,
} from "../editor/StepsDomain"
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
@ -96,7 +102,6 @@ interface TacticDto {
id: number
name: string
courtType: CourtType
content: { components: TacticComponent[] }
root: StepInfoNode
}
@ -108,78 +113,124 @@ export default function Editor({ guestMode }: EditorPageProps) {
return <EditorPortal guestMode={guestMode} />
}
function EditorPortal({ guestMode }: EditorPageProps) {
return guestMode ? <GuestModeEditor /> : <UserModeEditor />
}
function GuestModeEditor() {
const storageContent = localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0")
const storageContent = localStorage.getItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + "0",
)
const stepInitialContent = ({
...(storageContent == null ? { components: [] } : JSON.parse(storageContent)),
const stepInitialContent = {
...(storageContent == null
? { components: [] }
: JSON.parse(storageContent)),
stepId: 0,
})
}
// initialize local storage if we launch in guest mode
if (storageContent == null) {
localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify({ id: 0, children: [] }))
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify({ id: 0, children: [] }),
)
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepInitialContent.stepId,
JSON.stringify(stepInitialContent),
)
}
const [stepId, setStepId] = useState(DEFAULT_STEP_ID)
const [stepContent, setStepContent, saveState] = useContentState(
stepInitialContent,
SaveStates.Guest,
useMemo(() => debounceAsync(async (content: StepContent) => {
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId,
JSON.stringify(content),
)
return SaveStates.Guest
}, 250), [stepId]),
useMemo(
() =>
debounceAsync(async (content: StepContent) => {
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + stepId,
JSON.stringify(content),
)
return SaveStates.Guest
}, 250),
[stepId],
),
)
return <EditorPage
tactic={({
id: -1,
rootStepNode: JSON.parse(localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!),
name: localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ?? "Nouvelle Tactique",
courtType: "PLAIN",
})}
currentStepContent={stepContent}
setCurrentStepContent={(content) => setStepContent(content, true)}
saveState={saveState}
currentStepId={stepId}
onNameChange={useCallback(async name => {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
return true //simulate that the name has been changed
}, [])}
selectStep={useCallback(step => {
setStepId(step)
setStepContent({ ...JSON.parse(localStorage.getItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step)!) }, false)
return
}, [setStepContent])}
onAddStep={useCallback(async (parent, content) => {
const root: StepInfoNode = JSON.parse(localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!)
const nodeId = getAvailableId(root)
const node = { id: nodeId, children: [] }
localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify(addStepNode(root, parent, node)))
localStorage.setItem(GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id, JSON.stringify(content))
return node
}, [])}
onRemoveStep={useCallback(async step => {
const root: StepInfoNode = JSON.parse(localStorage.getItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY)!)
localStorage.setItem(GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, JSON.stringify(removeStepNode(root, step)))
return true
}, [])}
/>
return (
<EditorPage
tactic={{
id: -1,
rootStepNode: JSON.parse(
localStorage.getItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
)!,
),
name:
localStorage.getItem(GUEST_MODE_TITLE_STORAGE_KEY) ??
"Nouvelle Tactique",
courtType: "PLAIN",
}}
currentStepContent={stepContent}
setCurrentStepContent={(content) => setStepContent(content, true)}
saveState={saveState}
currentStepId={stepId}
onNameChange={useCallback(async (name) => {
localStorage.setItem(GUEST_MODE_TITLE_STORAGE_KEY, name)
return true //simulate that the name has been changed
}, [])}
selectStep={useCallback(
(step) => {
setStepId(step)
setStepContent(
{
...JSON.parse(
localStorage.getItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + step,
)!,
),
},
false,
)
return
},
[setStepContent],
)}
onAddStep={useCallback(async (parent, content) => {
const root: StepInfoNode = JSON.parse(
localStorage.getItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
)!,
)
const nodeId = getAvailableId(root)
const node = { id: nodeId, children: [] }
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(addStepNode(root, parent, node)),
)
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + node.id,
JSON.stringify(content),
)
return node
}, [])}
onRemoveStep={useCallback(async (step) => {
const root: StepInfoNode = JSON.parse(
localStorage.getItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
)!,
)
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify(removeStepNode(root, step)),
)
return true
}, [])}
/>
)
}
function UserModeEditor() {
@ -188,12 +239,36 @@ function UserModeEditor() {
const id = parseInt(idStr!)
const navigation = useNavigate()
useEffect(() => {
const [stepId, setStepId] = useState(1)
const [stepContent, setStepContent, saveState] =
useContentState<StepContent>(
{ components: [] },
SaveStates.Ok,
useMemo(
() =>
debounceAsync(async (content: StepContent) => {
const response = await fetchAPI(
`tactics/${id}/steps/${stepId}`,
{
content: {
components: content.components,
},
},
"PUT",
)
return response.ok ? SaveStates.Ok : SaveStates.Err
}, 250),
[id, stepId],
),
)
useEffect(() => {
async function initialize() {
const infoResponsePromise = fetchAPIGet(`tactics/${id}`)
const treeResponsePromise = fetchAPIGet(`tactics/${id}/tree`)
const contentResponsePromise = fetchAPIGet(`tactics/${id}/steps/${DEFAULT_STEP_ID}`)
const contentResponsePromise = fetchAPIGet(
`tactics/${id}/steps/${DEFAULT_STEP_ID}`,
)
const infoResponse = await infoResponsePromise
const treeResponse = await treeResponsePromise
@ -212,82 +287,70 @@ function UserModeEditor() {
const content = await contentResponse.json()
const { root } = await treeResponse.json()
setTactic({ id, name, courtType, content, root })
setTactic({ id, name, courtType, root })
setStepContent(content, false)
}
initialize()
}, [id, idStr, navigation])
const onNameChange = useCallback(
(name: string) =>
fetchAPI(`tactics/${id}/name`, { name }, "PUT").then((r) => r.ok),
[id],
)
const [stepId, setStepId] = useState(1)
const [stepContent, setStepContent, saveState] = useContentState(
tactic?.content ?? { components: [] },
SaveStates.Ok,
useMemo(() => debounceAsync(async (content: StepContent) => {
const response = await fetchAPI(
`tactics/${id}/steps/${stepId}`,
{
content: {
components: content.components,
},
},
"PUT",
)
return response.ok ? SaveStates.Ok : SaveStates.Err
}, 250), [id, stepId]),
const selectStep = useCallback(
async (step: number) => {
const response = await fetchAPIGet(`tactics/${id}/steps/${step}`)
if (!response.ok) return
setStepId(step)
setStepContent({ ...(await response.json()) }, false)
},
[id, setStepContent],
)
const onAddStep = useCallback(
async (parent: StepInfoNode, content: StepContent) => {
const response = await fetchAPI(`tactics/${id}/steps`, {
parentId: parent.id,
content,
})
if (!response.ok) return null
const { stepId } = await response.json()
return { id: stepId, children: [] }
},
[id],
)
const onRemoveStep = useCallback(
(step: StepInfoNode) =>
fetchAPI(`tactics/${id}/steps/${step.id}`, {}, "DELETE").then(
(r) => r.ok,
),
[id],
)
if (!tactic) return <EditorLoadingScreen />
return (
<EditorPage
tactic={{
id,
name: tactic?.name ?? "",
rootStepNode: tactic?.root ?? { id: stepId, children: [] },
courtType: tactic?.courtType,
}}
currentStepId={stepId}
currentStepContent={stepContent}
setCurrentStepContent={(content) => setStepContent(content, true)}
saveState={saveState}
onNameChange={onNameChange}
selectStep={selectStep}
onAddStep={onAddStep}
onRemoveStep={onRemoveStep}
/>
)
const onNameChange = useCallback((name: string) =>
fetchAPI(`tactics/${id}/name`, { name }, "PUT")
.then((r) => r.ok)
, [id])
const selectStep = useCallback(async (step: number) => {
const response = await fetchAPIGet(`tactics/${id}/steps/${step}`)
if (!response.ok)
return
setStepId(step)
setStepContent({ ...await response.json() }, false)
}, [id, setStepContent])
const onAddStep = useCallback(async (parent: StepInfoNode, content: StepContent) => {
const response = await fetchAPI(`tactics/${id}/steps`, {
parentId: parent.id,
content
})
if (!response.ok)
return null
const { stepId } = await response.json()
return { id: stepId, children: [] }
}, [id])
const onRemoveStep = useCallback((step: StepInfoNode) =>
fetchAPI(
`tactics/${id}/steps/${step.id}`,
{},
"DELETE",
).then(r => r.ok)
, [id])
if (!tactic)
return <EditorLoadingScreen />
return <EditorPage
tactic={({
id,
name: tactic?.name ?? "",
rootStepNode: tactic?.root ?? { id: stepId, children: [] },
courtType: tactic?.courtType,
})}
currentStepId={stepId}
currentStepContent={stepContent}
setCurrentStepContent={(content) => setStepContent(content, true)}
saveState={saveState}
onNameChange={onNameChange}
selectStep={selectStep}
onAddStep={onAddStep}
onRemoveStep={onRemoveStep}
/>
}
function EditorLoadingScreen() {
@ -304,26 +367,23 @@ export interface EditorViewProps {
selectStep: (stepId: number) => void
onNameChange: (name: string) => Promise<boolean>
onRemoveStep: (step: StepInfoNode) => Promise<boolean>
onAddStep: (parent: StepInfoNode, content: StepContent) => Promise<StepInfoNode | null>
onAddStep: (
parent: StepInfoNode,
content: StepContent,
) => Promise<StepInfoNode | null>
}
function EditorPage({
tactic: {
name,
rootStepNode: initialStepsNode,
courtType,
},
setCurrentStepContent: setContent,
currentStepContent: content,
saveState,
onNameChange,
selectStep,
onRemoveStep,
onAddStep,
}: EditorViewProps) {
tactic: { name, rootStepNode: initialStepsNode, courtType },
currentStepId,
setCurrentStepContent: setContent,
currentStepContent: content,
saveState,
onNameChange,
selectStep,
onRemoveStep,
onAddStep,
}: EditorViewProps) {
const [titleStyle, setTitleStyle] = useState<CSSProperties>({})
const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode)
@ -693,29 +753,35 @@ function EditorPage({
</div>
<EditorStepsTree
isVisible={isStepsTreeVisible}
selectedStepId={currentStepId}
root={rootStepsNode}
onAddChildren={useCallback(
async (parent) => {
const addedNode = await onAddStep(parent, content)
const addedNode = await onAddStep(
parent,
getTerminalState(content, courtBounds()),
)
if (addedNode == null) {
console.error(
"could not add step : onAddStep returned null node",
)
return
}
selectStep(addedNode.id)
setRootStepsNode((root) =>
addStepNode(root, parent, addedNode),
)
},
[content, onAddStep],
[content, courtBounds, onAddStep, selectStep],
)}
onRemoveNode={useCallback(
async (removed) => {
const isOk = await onRemoveStep(removed)
selectStep(getParent(rootStepsNode, removed)!.id)
if (isOk) setRootStepsNode(
(root) => removeStepNode(root, removed)!,
)
if (isOk)
setRootStepsNode(
(root) => removeStepNode(root, removed)!,
)
},
[rootStepsNode, onRemoveStep, selectStep],
)}
@ -731,6 +797,7 @@ function EditorPage({
interface EditorStepsTreeProps {
isVisible: boolean
selectedStepId: number
root: StepInfoNode
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
@ -739,6 +806,7 @@ interface EditorStepsTreeProps {
function EditorStepsTree({
isVisible,
selectedStepId,
root,
onAddChildren,
onRemoveNode,
@ -752,6 +820,7 @@ function EditorStepsTree({
}}>
<StepsTree
root={root}
selectedStepId={selectedStepId}
onStepSelected={onStepSelected}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
@ -869,7 +938,7 @@ function CourtPlayerArrowAction({
}))
}}
onHeadPicked={(headPos) => {
(document.activeElement as HTMLElement).blur()
;(document.activeElement as HTMLElement).blur()
setPreviewAction({
origin: playerInfo.id,

@ -32,7 +32,9 @@
background-color: var(--editor-tree-step-piece-hovered);
}
.step-piece-selected .step-piece-actions, .step-piece:hover .step-piece-actions, .step-piece:focus-within .step-piece-actions {
.step-piece-selected .step-piece-actions,
.step-piece:hover .step-piece-actions,
.step-piece:focus-within .step-piece-actions {
visibility: visible;
}

Loading…
Cancel
Save