parent
efe0540295
commit
a387d4e13f
@ -0,0 +1,329 @@
|
||||
package com.iqball.app.component
|
||||
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import arrow.core.Either
|
||||
import com.iqball.app.geo.Vector
|
||||
import com.iqball.app.model.tactic.Action
|
||||
import com.iqball.app.model.tactic.ActionType
|
||||
import com.iqball.app.model.tactic.ComponentId
|
||||
import com.iqball.app.model.tactic.Segment
|
||||
import com.iqball.app.model.tactic.StepContent
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
|
||||
private const val ArrowWidthPx = 5F
|
||||
private const val ArrowHeadHeightPx = 35F
|
||||
|
||||
fun drawActions(
|
||||
drawer: ContentDrawScope,
|
||||
content: StepContent,
|
||||
offsets: Map<String, Vector>,
|
||||
area: Rect,
|
||||
playersPixelsRadius: Float
|
||||
) {
|
||||
for (component in content.components) {
|
||||
val originPos = offsets[component.id]!!
|
||||
for (action in component.actions) {
|
||||
|
||||
val type = action.type
|
||||
|
||||
val forceStraight = type == ActionType.Shoot
|
||||
|
||||
val strokeStyle = when (type) {
|
||||
ActionType.Screen, ActionType.Move, ActionType.Dribble -> Stroke(width = ArrowWidthPx)
|
||||
ActionType.Shoot -> Stroke(
|
||||
width = ArrowWidthPx,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(15F, 15F))
|
||||
)
|
||||
}
|
||||
|
||||
// draw the arrow body
|
||||
if (forceStraight) {
|
||||
val targetOffset = when (val value = action.target) {
|
||||
is Either.Left -> offsets[value.value]!!
|
||||
is Either.Right -> value.value.posWithinArea(area)
|
||||
}
|
||||
val path = Path()
|
||||
val pathStart = constraintInCircle(
|
||||
originPos,
|
||||
getOrientationPointFromSegmentBounds(
|
||||
action.segments,
|
||||
originPos,
|
||||
offsets,
|
||||
area,
|
||||
false
|
||||
),
|
||||
playersPixelsRadius
|
||||
)
|
||||
val pathEnd = constraintInCircle(
|
||||
targetOffset,
|
||||
getOrientationPointFromSegmentBounds(
|
||||
action.segments,
|
||||
originPos,
|
||||
offsets,
|
||||
area,
|
||||
true
|
||||
),
|
||||
playersPixelsRadius + ArrowHeadHeightPx
|
||||
)
|
||||
path.moveTo(pathStart.x, pathStart.y)
|
||||
path.lineTo(pathEnd.x, pathEnd.y)
|
||||
drawer.drawPath(
|
||||
path = path,
|
||||
color = Color.Black,
|
||||
style = strokeStyle
|
||||
)
|
||||
} else {
|
||||
drawer.drawPath(
|
||||
path = computeSegmentsToPath(
|
||||
originPos,
|
||||
action.segments,
|
||||
offsets,
|
||||
area,
|
||||
type == ActionType.Dribble,
|
||||
playersPixelsRadius
|
||||
),
|
||||
color = Color.Black,
|
||||
style = strokeStyle
|
||||
)
|
||||
}
|
||||
|
||||
drawArrowHead(drawer, originPos, action, offsets, area, playersPixelsRadius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawArrowHead(
|
||||
drawer: DrawScope,
|
||||
originPos: Vector,
|
||||
action: Action,
|
||||
offsets: Map<ComponentId, Vector>,
|
||||
area: Rect,
|
||||
playersPixelsRadius: Float
|
||||
) {
|
||||
val segments = action.segments
|
||||
val lastSegment = segments.last()
|
||||
val target = lastSegment.next
|
||||
var targetPos = extractPos(target, offsets, area)
|
||||
val segmentOrientationPoint =
|
||||
getOrientationPointFromSegmentBounds(segments, originPos, offsets, area, true)
|
||||
|
||||
targetPos = constraintInCircle(targetPos, segmentOrientationPoint, playersPixelsRadius)
|
||||
|
||||
val pathAngleToTarget = -targetPos.angleDegWith(segmentOrientationPoint)
|
||||
|
||||
drawer.rotate(pathAngleToTarget, pivot = targetPos.toOffset()) {
|
||||
|
||||
val path =
|
||||
if (action.type == ActionType.Screen) getRectangleHeadPath(targetPos) else getTriangleHeadPath(
|
||||
targetPos
|
||||
)
|
||||
drawPath(path = path, color = Color.Black, style = Fill)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTriangleHeadPath(start: Vector): Path {
|
||||
val path = Path()
|
||||
path.moveTo(start.x, start.y)
|
||||
path.relativeLineTo(-ArrowHeadHeightPx, -ArrowHeadHeightPx)
|
||||
path.relativeLineTo(ArrowHeadHeightPx * 2, 0F)
|
||||
return path
|
||||
}
|
||||
|
||||
private fun getRectangleHeadPath(start: Vector): Path {
|
||||
val path = Path()
|
||||
path.moveTo(start.x, start.y - ArrowHeadHeightPx / 2F)
|
||||
path.relativeLineTo(-ArrowHeadHeightPx, 0F)
|
||||
path.relativeLineTo(0F, -ArrowHeadHeightPx / 2F)
|
||||
path.relativeLineTo(ArrowHeadHeightPx * 2, 0F)
|
||||
path.relativeLineTo(0F, ArrowHeadHeightPx / 2F)
|
||||
return path
|
||||
}
|
||||
|
||||
private fun getOrientationPointFromSegmentBounds(
|
||||
segments: List<Segment>,
|
||||
originPos: Vector,
|
||||
offsets: Map<ComponentId, Vector>,
|
||||
area: Rect,
|
||||
head: Boolean
|
||||
): Vector {
|
||||
val boundSegment = if (head) segments.last() else segments.first()
|
||||
return boundSegment
|
||||
.controlPoint?.posWithinArea(area)
|
||||
?: if (segments.size == 1)
|
||||
if (head) originPos else extractPos(segments.last().next, offsets, area)
|
||||
else run {
|
||||
val referenceSegment = if (head) segments[segments.size - 2] else segments[1]
|
||||
extractPos(referenceSegment.next, offsets, area)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeSegmentsToPath(
|
||||
originPos: Vector,
|
||||
segments: List<Segment>,
|
||||
offsets: Map<ComponentId, Vector>,
|
||||
area: Rect,
|
||||
wavy: Boolean,
|
||||
playersPixelsRadius: Float
|
||||
): Path {
|
||||
val path = Path()
|
||||
|
||||
|
||||
val firstSegment = segments.first()
|
||||
|
||||
var segmentStart = constraintInCircle(
|
||||
originPos,
|
||||
getOrientationPointFromSegmentBounds(segments, originPos, offsets, area, false),
|
||||
playersPixelsRadius
|
||||
)
|
||||
|
||||
var lastSegmentCp =
|
||||
firstSegment.controlPoint?.posWithinArea(area) ?: (segmentStart + extractPos(
|
||||
firstSegment.next, offsets, area
|
||||
) / 2F)
|
||||
|
||||
path.moveTo(segmentStart.x, segmentStart.y)
|
||||
|
||||
|
||||
for ((i, segment) in segments.withIndex()) {
|
||||
var nextPos = extractPos(segment.next, offsets, area)
|
||||
|
||||
if (i == segments.size - 1) {
|
||||
// if it is the last segment, the next position must be constrained to the player's radius
|
||||
nextPos = constraintInCircle(
|
||||
nextPos,
|
||||
getOrientationPointFromSegmentBounds(
|
||||
segments,
|
||||
originPos,
|
||||
offsets,
|
||||
area,
|
||||
true
|
||||
),
|
||||
playersPixelsRadius + ArrowHeadHeightPx
|
||||
)
|
||||
}
|
||||
|
||||
val segmentCp = segment.controlPoint?.posWithinArea(area) ?: ((segmentStart + nextPos) / 2F)
|
||||
val castedCp =
|
||||
if (i == 0) segmentCp else segmentStart + (segmentStart - lastSegmentCp)
|
||||
|
||||
if (wavy) {
|
||||
wavyBezier(segmentStart, castedCp, segmentCp, nextPos, 7, 15, path)
|
||||
} else {
|
||||
path.cubicTo(castedCp.x, castedCp.y, segmentCp.x, segmentCp.y, nextPos.x, nextPos.y)
|
||||
}
|
||||
|
||||
lastSegmentCp = segmentCp
|
||||
segmentStart = nextPos
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
private fun cubicBeziersDerivative(
|
||||
start: Vector,
|
||||
cp1: Vector,
|
||||
cp2: Vector,
|
||||
end: Vector,
|
||||
t: Float
|
||||
): Vector = ((cp1 - start) * 3F * (1 - t).pow(2)) +
|
||||
((cp2 - cp1) * 6F * (1 - t) * t) +
|
||||
((end - cp2) * 3F * t.pow(2))
|
||||
|
||||
|
||||
private fun cubicBeziers(
|
||||
start: Vector,
|
||||
cp1: Vector,
|
||||
cp2: Vector,
|
||||
end: Vector,
|
||||
t: Float
|
||||
): Vector = (start * (1 - t).pow(3)) +
|
||||
(cp1 * 3F * t * (1 - t).pow(2)) +
|
||||
(cp2 * 3F * t.pow(2) * (1 - t)) +
|
||||
(end * t.pow(3))
|
||||
|
||||
private fun wavyBezier(
|
||||
start: Vector,
|
||||
cp1: Vector,
|
||||
cp2: Vector,
|
||||
end: Vector,
|
||||
wavesPer100Px: Int,
|
||||
amplitude: Int,
|
||||
path: Path
|
||||
) {
|
||||
|
||||
fun getVerticalDerivativeProjectionAmplification(t: Float): Vector {
|
||||
val velocity = cubicBeziersDerivative(start, cp1, cp2, end, t)
|
||||
val velocityLength = velocity.norm()
|
||||
val projection = Vector(velocity.y, -velocity.x)
|
||||
|
||||
return (projection / velocityLength) * amplitude
|
||||
}
|
||||
|
||||
|
||||
val dist = start.distanceFrom(cp1) + cp1.distanceFrom(cp2) + cp2.distanceFrom(end)
|
||||
|
||||
val waveLength = (dist / 100) * wavesPer100Px * 2
|
||||
val step = 1F / waveLength
|
||||
|
||||
// 0 : middle to up
|
||||
// 1 : up to middle
|
||||
// 2 : middle to down
|
||||
// 3 : down to middle
|
||||
var phase = 0
|
||||
|
||||
var t = step
|
||||
while (t <= 1) {
|
||||
val pos = cubicBeziers(start, cp1, cp2, end, t)
|
||||
val amplification = getVerticalDerivativeProjectionAmplification(t)
|
||||
|
||||
val nextPos = when (phase) {
|
||||
1, 3 -> pos
|
||||
0 -> pos + amplification
|
||||
else -> pos - amplification
|
||||
}
|
||||
|
||||
val controlPointBase = cubicBeziers(start, cp1, cp2, end, t - step / 2)
|
||||
val controlPoint =
|
||||
if (phase == 0 || phase == 1) controlPointBase + amplification else controlPointBase - amplification
|
||||
path.quadraticBezierTo(controlPoint.x, controlPoint.y, nextPos.x, nextPos.y)
|
||||
phase = (phase + 1) % 4
|
||||
t += step
|
||||
if (t < 1 && t > 1 - step) t = 1F
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private fun constraintInCircle(center: Vector, reference: Vector, radius: Float): Vector {
|
||||
val theta = center.angleRadWith(reference)
|
||||
return Vector(
|
||||
x = center.x - sin(theta) * radius,
|
||||
y = center.y - cos(theta) * radius
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractPos(
|
||||
next: Either<ComponentId, Vector>,
|
||||
offsets: Map<ComponentId, Vector>,
|
||||
area: Rect
|
||||
) = when (next) {
|
||||
is Either.Left -> offsets[next.value]!!
|
||||
is Either.Right -> next.value.posWithinArea(area)
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
package com.iqball.app.geo
|
||||
|
||||
data class Area(val pos: Vector, val width: Float, val height: Float)
|
@ -1,25 +1,45 @@
|
||||
package com.iqball.app.geo
|
||||
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sqrt
|
||||
|
||||
typealias Pos = Vector
|
||||
|
||||
data class Vector(val x: Double, val y: Double) {
|
||||
data class Vector(val x: Float, val y: Float) {
|
||||
fun toBiasAlignment(): BiasAlignment =
|
||||
BiasAlignment((x * 2 - 1).toFloat(), (y * 2 - 1).toFloat())
|
||||
BiasAlignment((x * 2 - 1), (y * 2 - 1))
|
||||
|
||||
infix operator fun minus(other: Vector) = Vector(x - other.x, y - other.y)
|
||||
infix operator fun plus(other: Vector) = Vector(x + other.x, y + other.y)
|
||||
infix operator fun div(other: Vector) = Vector(x / other.x, y / other.y)
|
||||
infix operator fun div(n: Float) = Vector(x / n, y / n)
|
||||
|
||||
fun distanceWith(other: Vector) =
|
||||
infix operator fun times(other: Vector) = Vector(x * other.x, y * other.y)
|
||||
infix operator fun times(n: Float) = Vector(x * n, y * n)
|
||||
infix operator fun times(n: Int) = Vector(x * n, y * n)
|
||||
|
||||
|
||||
fun angleRadWith(other: Vector): Float {
|
||||
val (x, y) = this - other
|
||||
return atan2(x, y)
|
||||
}
|
||||
|
||||
fun angleDegWith(other: Vector): Float = (angleRadWith(other) * (180 / Math.PI)).toFloat()
|
||||
|
||||
|
||||
fun distanceFrom(other: Vector) =
|
||||
sqrt(((x - other.x) * (x - other.x) + (y - other.y) * (y - other.y)))
|
||||
|
||||
fun norm() = NullVector.distanceWith(this)
|
||||
fun norm() = NullVector.distanceFrom(this)
|
||||
|
||||
fun posWithinArea(area: Rect) = Vector(x * area.width, y * area.height)
|
||||
|
||||
fun posWithinArea(area: Area) = Vector(x * area.width, y * area.height)
|
||||
fun ratioWithinArea(area: Area) =
|
||||
Vector((x - area.pos.x) * area.width, (y - area.pos.y) * area.height)
|
||||
fun toOffset() = Offset(x, y)
|
||||
}
|
||||
|
||||
val NullVector = Vector(.0, .0)
|
||||
val NullVector = Vector(0F, 0F)
|
||||
|
||||
fun Offset.toVector() = Vector(x, y)
|
Loading…
Reference in new issue