parent
2c721de548
commit
f32278246c
@ -0,0 +1,330 @@
|
|||||||
|
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 in segments.indices) {
|
||||||
|
val segment = segments[i]
|
||||||
|
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
|
package com.iqball.app.geo
|
||||||
|
|
||||||
import androidx.compose.ui.BiasAlignment
|
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
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
typealias Pos = Vector
|
typealias Pos = Vector
|
||||||
|
|
||||||
data class Vector(val x: Double, val y: Double) {
|
data class Vector(val x: Float, val y: Float) {
|
||||||
fun toBiasAlignment(): BiasAlignment =
|
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 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 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)))
|
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 toOffset() = Offset(x, y)
|
||||||
fun ratioWithinArea(area: Area) =
|
|
||||||
Vector((x - area.pos.x) * area.width, (y - area.pos.y) * area.height)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val NullVector = Vector(.0, .0)
|
val NullVector = Vector(0F, 0F)
|
||||||
|
|
||||||
|
fun Offset.toVector() = Vector(x, y)
|
Loading…
Reference in new issue