add conventional wavy arrow style for dribbles
continuous-integration/drone/push Build is passing Details

pull/82/head
maxime.batista 1 year ago
parent 26e99a3a03
commit 768eae92ad

@ -59,16 +59,3 @@ export function MoveToHead() {
</svg> </svg>
) )
} }
export function ShootHead() {
return (
<svg viewBox={"0 0 50 50"} width={15} height={15} overflow={"visible"}>
<path
d={"M0 0 L50 50 M0 50 L50 0"}
stroke="#000"
strokeWidth={10}
fill={"transparent"}
/>
</svg>
)
}

@ -4,6 +4,7 @@ import {
RefObject, RefObject,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useRef, useRef,
useState, useState,
} from "react" } from "react"
@ -18,6 +19,8 @@ import {
Pos, Pos,
posWithinBase, posWithinBase,
ratioWithinBase, ratioWithinBase,
relativeTo,
size,
} from "./Pos" } from "./Pos"
import "../../style/bendable_arrows.css" import "../../style/bendable_arrows.css"
@ -29,6 +32,7 @@ export interface BendableArrowProps {
segments: Segment[] segments: Segment[]
onSegmentsChanges: (edges: Segment[]) => void onSegmentsChanges: (edges: Segment[]) => void
forceStraight: boolean forceStraight: boolean
wavy: boolean
startRadius?: number startRadius?: number
endRadius?: number endRadius?: number
@ -45,8 +49,8 @@ export interface ArrowStyle {
tail?: () => ReactElement tail?: () => ReactElement
} }
const ArrowStyleDefaults = { const ArrowStyleDefaults: ArrowStyle = {
width: 4, width: 3,
} }
export interface Segment { export interface Segment {
@ -54,22 +58,51 @@ export interface Segment {
controlPoint?: Pos controlPoint?: Pos
} }
function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos { /**
const theta = angle(pos, from) * Given a circle shaped by a central position, and a radius, return
* a position that is constrained on its perimeter, pointing to the direction
* between the circle's center and the reference position.
* @param center circle's center.
* @param reference a reference point used to create the angle where the returned position
* will point to on the circle's perimeter
* @param radius circle's radius.
*/
function constraintInCircle(center: Pos, reference: Pos, radius: number): Pos {
const theta = angle(center, reference)
return { return {
x: pos.x - Math.sin(theta) * radius, x: center.x - Math.sin(theta) * radius,
y: pos.y - Math.cos(theta) * radius, y: center.y - Math.cos(theta) * radius,
} }
} }
/**
* An arrow that follows a bézier curve built from given segments that can be edited, added or removed by the user
* The arrow only works with relative positions within a given area.
* All position handled by the arrow must be positions where x and y are a percentage within the area's surface
* (0.5, 0.5) is a position at the middle of the area
* (1, 0.75) means that the position is at 100percent to the right of given area, and 75 percent to the bottom
* @param area
* @param startPos
* @param segments
* @param onSegmentsChanges
* @param wavy
* @param forceStraight
* @param style
* @param startRadius
* @param endRadius
* @param onDeleteRequested
* @constructor
*/
export default function BendableArrow({ export default function BendableArrow({
area, area,
startPos, startPos,
segments, segments,
onSegmentsChanges, onSegmentsChanges,
forceStraight, forceStraight,
wavy,
style, style,
startRadius = 0, startRadius = 0,
@ -82,18 +115,46 @@ export default function BendableArrow({
const styleWidth = style?.width ?? ArrowStyleDefaults.width const styleWidth = style?.width ?? ArrowStyleDefaults.width
useEffect(() => { // Cache the segments so that when the user is changing the segments (it moves an ArrowPoint),
setInternalSegments(segments) // it does not unwind to this arrow's component parent until validated.
}, [segments]) // The changes are validated (meaning that onSegmentsChanges is called) when the
// user releases an ArrowPoint.
const [internalSegments, setInternalSegments] = useState(segments) const [internalSegments, setInternalSegments] = useState<FullSegment[]>(
() => computeInternalSegments(segments),
)
// If the (original) segments changes, overwrite the current ones.
useLayoutEffect(() => {
setInternalSegments(computeInternalSegments(segments))
}, [startPos, segments])
const [isSelected, setIsSelected] = useState(false) const [isSelected, setIsSelected] = useState(false)
const headRef = useRef<HTMLDivElement>(null) const headRef = useRef<HTMLDivElement>(null)
const tailRef = useRef<HTMLDivElement>(null) const tailRef = useRef<HTMLDivElement>(null)
function computeControlPoints(parentBase: DOMRect) { function computeInternalSegments(segments: Segment[]): FullSegment[] {
return segments.map((segment, idx) => {
if (idx == 0) {
return {
start: startPos,
controlPoint: segment.controlPoint,
end: segment.next,
}
}
const start = segments[idx - 1].next
return {
start,
controlPoint: segment.controlPoint,
end: segment.next,
}
})
}
/**
* Computes and return the segments edition points
* @param parentBase
*/
function computePoints(parentBase: DOMRect) {
return segments.flatMap(({ next, controlPoint }, i) => { return segments.flatMap(({ next, controlPoint }, i) => {
const prev = i == 0 ? startPos : segments[i - 1].next const prev = i == 0 ? startPos : segments[i - 1].next
@ -137,6 +198,7 @@ export default function BendableArrow({
//next pos point (only if this is not the last segment) //next pos point (only if this is not the last segment)
i != segments.length - 1 && ( i != segments.length - 1 && (
<ArrowPoint <ArrowPoint
key={i + "-2"}
className={"arrow-point-next"} className={"arrow-point-next"}
posRatio={next} posRatio={next}
parentBase={parentBase} parentBase={parentBase}
@ -153,12 +215,20 @@ export default function BendableArrow({
segments.toSpliced(Math.max(i - 1, 0), 1), segments.toSpliced(Math.max(i - 1, 0), 1),
) )
}} }}
onMoves={(next) => { onMoves={(end) => {
setInternalSegments((is) => { setInternalSegments((is) => {
return is.toSpliced(i, 1, { return is.toSpliced(
i,
2,
{
...is[i], ...is[i],
next, end,
}) },
{
...is[i + 1],
start: end,
},
)
}) })
}} }}
/> />
@ -167,33 +237,30 @@ export default function BendableArrow({
}) })
} }
/**
* Updates the states based on given parameters, which causes the arrow to re-render.
*/
const update = useCallback(() => { const update = useCallback(() => {
const parentBase = area.current!.getBoundingClientRect() const parentBase = area.current!.getBoundingClientRect()
const firstSegment = internalSegments[0] ?? null const segment = internalSegments[0] ?? null
if (firstSegment == null) if (segment == null) throw new Error("segments might not be empty.")
throw new Error("segments might not be empty.")
const lastSegment = internalSegments[internalSegments.length - 1] const lastSegment = internalSegments[internalSegments.length - 1]
const startRelative = posWithinBase(startPos, parentBase) const startRelative = posWithinBase(startPos, parentBase)
const endRelative = posWithinBase(lastSegment.next, parentBase) const endRelative = posWithinBase(lastSegment.end, parentBase)
const startNext = const startNext =
firstSegment.controlPoint && !forceStraight segment.controlPoint && !forceStraight
? posWithinBase(firstSegment.controlPoint, parentBase) ? posWithinBase(segment.controlPoint, parentBase)
: posWithinBase(firstSegment.next, parentBase) : posWithinBase(segment.end, parentBase)
const endPrevious = forceStraight const endPrevious = forceStraight
? startRelative ? startRelative
: lastSegment.controlPoint : lastSegment.controlPoint
? posWithinBase(lastSegment.controlPoint, parentBase) ? posWithinBase(lastSegment.controlPoint, parentBase)
: internalSegments[internalSegments.length - 2] : posWithinBase(lastSegment.start, parentBase)
? posWithinBase(
internalSegments[internalSegments.length - 2].next,
parentBase,
)
: startRelative
const tailPos = constraintInCircle( const tailPos = constraintInCircle(
startRelative, startRelative,
@ -229,52 +296,95 @@ export default function BendableArrow({
} }
const segmentsRelatives = ( const segmentsRelatives = (
forceStraight ? internalSegments.slice(-1) : internalSegments forceStraight
).map(({ next, controlPoint }, idx) => { ? [
const nextPos = posWithinBase(next, parentBase) {
return { start: startPos,
next: nextPos, controlPoint: undefined,
cp: end: lastSegment.end,
},
]
: internalSegments
).map(({ start, controlPoint, end }, idx) => {
const svgPosRelativeToBase = { x: left, y: top }
const nextRelative = relativeTo(
posWithinBase(end, parentBase),
svgPosRelativeToBase,
)
const startRelative = relativeTo(
posWithinBase(start, parentBase),
svgPosRelativeToBase,
)
const controlPointRelative =
controlPoint && !forceStraight controlPoint && !forceStraight
? posWithinBase(controlPoint, parentBase) ? relativeTo(
: between( posWithinBase(controlPoint, parentBase),
idx == 0 svgPosRelativeToBase,
? startRelative )
: posWithinBase( : between(startRelative, nextRelative)
internalSegments[idx - 1].next,
parentBase, return {
), start: startRelative,
nextPos, end: nextRelative,
), cp: controlPointRelative,
} }
}) })
const computedSegments = segmentsRelatives const computedSegments = segmentsRelatives
.map(({ next: n, cp }, idx) => { .map(({ start, cp, end: e }, idx) => {
let next = n let end = e
if (idx == segmentsRelatives.length - 1) {
if (idx == internalSegments.length - 1) {
//if it is the last element //if it is the last element
next = constraintInCircle(next, cp, endRadius!) end = constraintInCircle(end, cp, endRadius!)
}
const previousSegment =
idx != 0 ? segmentsRelatives[idx - 1] : undefined
const previousSegmentCpAndCurrentPosVector = minus(
start,
previousSegment?.cp ?? between(start, end),
)
const smoothCp = previousSegment
? add(start, previousSegmentCpAndCurrentPosVector)
: cp
if (wavy) {
return wavyBezier(start, smoothCp, cp, end, 10, 10)
}
if (forceStraight) {
return `L${end.x} ${end.y}`
} }
return `C${cp.x - left} ${cp.y - top}, ${cp.x - left} ${ return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}`
cp.y - top
}, ${next.x - left} ${next.y - top}`
}) })
.join(" ") .join(" ")
const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments
pathRef.current!.setAttribute("d", d) pathRef.current!.setAttribute("d", d)
Object.assign(svgRef.current!.style, svgStyle) Object.assign(svgRef.current!.style, svgStyle)
}, [startPos, internalSegments, forceStraight]) }, [
startPos,
internalSegments,
forceStraight,
startRadius,
endRadius,
style,
])
// Will update the arrow when the props change
useEffect(update, [update]) useEffect(update, [update])
// Adds a selection handler
// Also force an update when the window is resized
useEffect(() => { useEffect(() => {
const selectionHandler = (e: MouseEvent) => { const selectionHandler = (e: MouseEvent) => {
if (!(e.target instanceof Node)) return if (!(e.target instanceof Node)) return
// The arrow is selected if the mouse clicks on an element that belongs to the current arrow
const isSelected = containerRef.current!.contains(e.target) const isSelected = containerRef.current!.contains(e.target)
setIsSelected(isSelected) setIsSelected(isSelected)
} }
@ -288,6 +398,7 @@ export default function BendableArrow({
} }
}, [update, containerRef]) }, [update, containerRef])
// Inserts a segment where the mouse double clicks on the arrow
useEffect(() => { useEffect(() => {
if (forceStraight) return if (forceStraight) return
@ -304,16 +415,30 @@ export default function BendableArrow({
let segmentInsertionIsOnRightOfCP = false let segmentInsertionIsOnRightOfCP = false
for (let i = 0; i < segments.length; i++) { for (let i = 0; i < segments.length; i++) {
const segment = segments[i] const segment = segments[i]
const beforeSegment = i != 0 ? segments[i - 1] : undefined
const beforeSegmentPos = i > 1 ? segments[i - 2].next : startPos
let currentPos = i == 0 ? startPos : segments[i - 1].next const currentPos = beforeSegment ? beforeSegment.next : startPos
let nextPos = segment.next const nextPos = segment.next
let controlPointPos = segment.controlPoint const segmentCp = segment.controlPoint
? segment.controlPoint ? segment.controlPoint
: between(currentPos, nextPos) : between(currentPos, nextPos)
const smoothCp = beforeSegment
? add(
currentPos,
minus(
currentPos,
beforeSegment.controlPoint ??
between(beforeSegmentPos, currentPos),
),
)
: segmentCp
const result = searchOnSegment( const result = searchOnSegment(
currentPos, currentPos,
controlPointPos, smoothCp,
segmentCp,
nextPos, nextPos,
clickPosBaseRatio, clickPosBaseRatio,
0.05, 0.05,
@ -373,7 +498,9 @@ export default function BendableArrow({
ref={pathRef} ref={pathRef}
stroke={"#000"} stroke={"#000"}
strokeWidth={styleWidth} strokeWidth={styleWidth}
strokeDasharray={style?.dashArray} strokeDasharray={
style?.dashArray ?? ArrowStyleDefaults.dashArray
}
fill="none" fill="none"
tabIndex={0} tabIndex={0}
onKeyUp={(e) => { onKeyUp={(e) => {
@ -399,7 +526,7 @@ export default function BendableArrow({
{!forceStraight && {!forceStraight &&
isSelected && isSelected &&
computeControlPoints(area.current!.getBoundingClientRect())} computePoints(area.current!.getBoundingClientRect())}
</div> </div>
) )
} }
@ -420,32 +547,154 @@ enum PointSegmentSearchResult {
NOT_FOUND, NOT_FOUND,
} }
interface FullSegment {
start: Pos
controlPoint: Pos | undefined
end: Pos
}
/**
* returns a path delimiter that follows a given cubic béziers curve, but with additional waves on it, of the given
* density and amplitude.
* @param start
* @param cp1
* @param cp2
* @param end
* @param wavesPer100px
* @param amplitude
*/
function wavyBezier(
start: Pos,
cp1: Pos,
cp2: Pos,
end: Pos,
wavesPer100px: number,
amplitude: number,
): string {
function getVerticalAmplification(t: number): Pos {
const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t)
const velocityLength = size(velocity)
//rotate the velocity by 90 deg
const projection = { x: velocity.y, y: -velocity.x }
return {
x: (projection.x / velocityLength) * amplitude,
y: (projection.y / velocityLength) * amplitude,
}
}
let result: string = ""
const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end)
// we need two phases in order to complete a wave
const waveLength = (dist / 100) * wavesPer100px * 2
const step = 1 / waveLength
// 0 : middle to up
// 1 : up to middle
// 2 : middle to down
// 3 : down to middle
let phase = 0
for (let t = step; t <= 1; ) {
const pos = cubicBeziers(start, cp1, cp2, end, t)
const amplification = getVerticalAmplification(t)
let nextPos
if (phase == 1 || phase == 3) {
nextPos = pos
} else {
if (phase == 0) {
nextPos = add(pos, amplification)
} else {
nextPos = minus(pos, amplification)
}
}
const controlPointBase: Pos = cubicBeziers(
start,
cp1,
cp2,
end,
t - step / 2,
)
const controlPoint: Pos =
phase == 0 || phase == 1
? add(controlPointBase, amplification)
: minus(controlPointBase, amplification)
result += `Q${controlPoint.x} ${controlPoint.y} ${nextPos.x} ${nextPos.y}`
phase = (phase + 1) % 4
t += step
if (t < 1 && t > 1 - step) t = 1
}
return result
}
function cubicBeziersDerivative(
start: Pos,
cp1: Pos,
cp2: Pos,
end: Pos,
t: number,
): Pos {
return add(
add(
mul(minus(cp1, start), 3 * (1 - t) ** 2),
mul(minus(cp2, cp1), 6 * (1 - t) * t),
),
mul(minus(end, cp2), 3 * t ** 2),
)
}
function cubicBeziers(
start: Pos,
cp1: Pos,
cp2: Pos,
end: Pos,
t: number,
): Pos {
return add(
add(
add(mul(start, (1 - t) ** 3), mul(cp1, 3 * t * (1 - t) ** 2)),
mul(cp2, 3 * t ** 2 * (1 - t)),
),
mul(end, t ** 3),
)
}
/**
* Given a quadratic bézier curve (start position, end position and a middle control point position)
* search if the given `point` lies on the curve, within a minimum acceptance distance.
* @param start
* @param cp1
* @param cp2
* @param end
* @param point
* @param minDistance
*/
function searchOnSegment( function searchOnSegment(
startPos: Pos, start: Pos,
controlPoint: Pos, cp1: Pos,
endPos: Pos, cp2: Pos,
end: Pos,
point: Pos, point: Pos,
minDistance: number, minDistance: number,
): PointSegmentSearchResult { ): PointSegmentSearchResult {
const step = const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end)
1 / const step = 1 / (dist / minDistance)
((distance(startPos, controlPoint) + distance(controlPoint, endPos)) /
minDistance)
const p0MinusP1 = minus(startPos, controlPoint)
const p2MinusP1 = minus(endPos, controlPoint)
function getDistanceAt(t: number): number { function getDistanceAt(t: number): number {
// apply the bezier function return distance(cubicBeziers(start, cp1, cp2, end, t), point)
const pos = add(
add(controlPoint, mul(p0MinusP1, (1 - t) ** 2)),
mul(p2MinusP1, t ** 2),
)
return distance(pos, point)
} }
for (let t = 0; t < 1; t += step) { for (let t = 0; t < 1; t += step) {
if (getDistanceAt(t) <= minDistance) const distance = getDistanceAt(t)
if (distance <= minDistance)
return t >= 0.5 return t >= 0.5
? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT ? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT
: PointSegmentSearchResult.LEFT_TO_CONTROL_POINT : PointSegmentSearchResult.LEFT_TO_CONTROL_POINT
@ -454,14 +703,17 @@ function searchOnSegment(
return PointSegmentSearchResult.NOT_FOUND return PointSegmentSearchResult.NOT_FOUND
} }
let t = 0 /**
let slice = 0.5 * An arrow point, that can be moved.
for (let i = 0; i < 100; i++) { * @param className
t += slice * @param posRatio
slice /= 2 * @param parentBase
// console.log(t) * @param onMoves
} * @param onPosValidated
* @param onRemove
* @param radius
* @constructor
*/
function ArrowPoint({ function ArrowPoint({
className, className,
posRatio, posRatio,

@ -38,6 +38,15 @@ export function distance(a: Pos, b: Pos): number {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
} }
export function size(vector: Pos): number {
return distance(NULL_POS, vector)
}
/**
* Returns the angle in radian between the two points
* @param a
* @param b
*/
export function angle(a: Pos, b: Pos): number { export function angle(a: Pos, b: Pos): number {
const r = relativeTo(a, b) const r = relativeTo(a, b)
return Math.atan2(r.x, r.y) return Math.atan2(r.x, r.y)
@ -63,3 +72,10 @@ export function between(a: Pos, b: Pos): Pos {
y: a.y / 2 + b.y / 2, y: a.y / 2 + b.y / 2,
} }
} }
export function rotate(vec: Pos, deg: number): Pos {
return {
x: Math.cos(deg * vec.x) - Math.sin(deg * vec.y),
y: Math.sin(deg * vec.x) + Math.cos(deg * vec.y),
}
}

@ -157,7 +157,7 @@ export function BasketCourt({
onDrag={() => updateActionsRelatedTo(player)} onDrag={() => updateActionsRelatedTo(player)}
onChange={onPlayerChange} onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)} onRemove={() => onPlayerRemove(player)}
parentRef={courtRef} courtRef={courtRef}
availableActions={(pieceRef) => [ availableActions={(pieceRef) => [
<ArrowAction <ArrowAction
key={1} key={1}

@ -3,14 +3,14 @@ import "../../style/player.css"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece" import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../tactic/Player" import { Player } from "../../tactic/Player"
import { ratioWithinBase } from "../arrows/Pos" import { NULL_POS, ratioWithinBase } from "../arrows/Pos"
export interface PlayerProps<A extends ReactNode> { export interface PlayerProps<A extends ReactNode> {
player: Player player: Player
onDrag: () => void onDrag: () => void
onChange: (p: Player) => void onChange: (p: Player) => void
onRemove: () => void onRemove: () => void
parentRef: RefObject<HTMLElement> courtRef: RefObject<HTMLElement>
availableActions: (ro: HTMLElement) => A[] availableActions: (ro: HTMLElement) => A[]
} }
@ -22,24 +22,24 @@ export default function CourtPlayer<A extends ReactNode>({
onDrag, onDrag,
onChange, onChange,
onRemove, onRemove,
parentRef, courtRef,
availableActions, availableActions,
}: PlayerProps<A>) { }: PlayerProps<A>) {
const hasBall = player.hasBall
const x = player.rightRatio const x = player.rightRatio
const y = player.bottomRatio const y = player.bottomRatio
const hasBall = player.hasBall
const pieceRef = useRef<HTMLDivElement>(null) const pieceRef = useRef<HTMLDivElement>(null)
return ( return (
<Draggable <Draggable
handle=".player-piece" handle=".player-piece"
nodeRef={pieceRef} nodeRef={pieceRef}
position={{ x, y }}
onDrag={onDrag} onDrag={onDrag}
//The piece is positioned using top/bottom style attributes instead
position={NULL_POS}
onStop={() => { onStop={() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect() const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = parentRef.current!.getBoundingClientRect() const parentBounds = courtRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(pieceBounds, parentBounds) const { x, y } = ratioWithinBase(pieceBounds, parentBounds)

@ -93,6 +93,7 @@
} }
#court-image-div { #court-image-div {
position: relative;
background-color: white; background-color: white;
height: 100%; height: 100%;
width: 100%; width: 100%;

@ -1,11 +1,7 @@
import { Action, ActionKind } from "../../tactic/Action" import { Action, ActionKind } from "../../tactic/Action"
import BendableArrow from "../../components/arrows/BendableArrow" import BendableArrow from "../../components/arrows/BendableArrow"
import { RefObject } from "react" import { RefObject } from "react"
import { import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction"
MoveToHead,
ScreenHead,
ShootHead,
} from "../../components/actions/ArrowAction"
export interface CourtActionProps { export interface CourtActionProps {
action: Action action: Action
@ -24,13 +20,12 @@ export function CourtAction({
switch (action.type) { switch (action.type) {
case ActionKind.DRIBBLE: case ActionKind.DRIBBLE:
case ActionKind.MOVE: case ActionKind.MOVE:
case ActionKind.SHOOT:
head = () => <MoveToHead /> head = () => <MoveToHead />
break break
case ActionKind.SCREEN: case ActionKind.SCREEN:
head = () => <ScreenHead /> head = () => <ScreenHead />
break break
case ActionKind.SHOOT:
head = () => <ShootHead />
} }
let dashArray let dashArray
@ -38,8 +33,6 @@ export function CourtAction({
case ActionKind.SHOOT: case ActionKind.SHOOT:
dashArray = "10 5" dashArray = "10 5"
break break
case ActionKind.DRIBBLE:
dashArray = "4"
} }
return ( return (
@ -51,6 +44,7 @@ export function CourtAction({
onSegmentsChanges={(edges) => { onSegmentsChanges={(edges) => {
onActionChanges({ ...action, segments: edges }) onActionChanges({ ...action, segments: edges })
}} }}
wavy={action.type == ActionKind.DRIBBLE}
//TODO place those magic values in constants //TODO place those magic values in constants
endRadius={action.toPlayerId ? 26 : 17} endRadius={action.toPlayerId ? 26 : 17}
startRadius={0} startRadius={0}

Loading…
Cancel
Save