Improvements to target tap loader game.

This commit is contained in:
Koen J 2025-07-05 17:32:31 +02:00
commit 08e98b089c

View file

@ -8,115 +8,104 @@ import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.OvershootInterpolator
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.toColorInt
import com.futo.platformplayer.UIDialogs
import kotlin.math.*
import kotlin.random.Random
import com.futo.platformplayer.UIDialogs
class TargetTapLoaderView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private val primaryColor = "#2D63ED".toColorInt()
private val inactiveGlobalAlpha = 110
private val streakAccelerationStep = .3f
private val minSpawnDelay = 200L
private val idleSpeedMultiplier = .015f
private val baseDeterministicDelay = 700L
private val baseIndeterminateDelay = 1400L
private val overshootInterpolator = OvershootInterpolator(2f)
private val initialSpawnFactor = 2f
private val floatAccel = .03f
private val idleMaxSpeed = .35f
private val idleInitialTargets = 10
private val idleHintText = "Waiting for media to become available"
private var expectedDurationMs: Long? = null
private var startTime: Long = 0L
private var loadStartTime = 0L
private var playStartTime = 0L
private var loaderFinished = false
private var forceIndeterminate = false
private var spinnerShader: SweepGradient? = null
private var forceIndeterminate= false
private var lastFrameTime = System.currentTimeMillis()
private val bounceInterpolator = android.view.animation.OvershootInterpolator(2f)
private val isIndeterminate: Boolean
get() = forceIndeterminate || expectedDurationMs == null || expectedDurationMs == 0L
private var streak = 0
private var score = 0
private var isPlaying = false
private val targets = mutableListOf<Target>()
private val particles = mutableListOf<Particle>()
private var score = 0
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP, 18f, resources.displayMetrics
)
textAlign = Paint.Align.CENTER
textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 18f, resources.displayMetrics)
textAlign = Paint.Align.LEFT
setShadowLayer(4f, 0f, 0f, Color.BLACK)
typeface = Typeface.DEFAULT_BOLD
}
private val progressBarPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = "#2D63ED".toColorInt()
}
private val progressBarPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = primaryColor }
private val spinnerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = "#2D63ED".toColorInt()
strokeWidth = 12f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
private val outerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
}
private val middleRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
style = Paint.Style.FILL
}
private val centerDotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.YELLOW
style = Paint.Style.FILL
}
private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(50, 0, 0, 0)
color = primaryColor; strokeWidth = 12f
style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND
}
private val outerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val middleRingPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val centerDotPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(50, 0, 0, 0) }
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val particlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.YELLOW
}
private val particlePaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val backgroundPaint = Paint()
private var spinnerShader: SweepGradient? = null
private var spinnerAngle = 0f
private var lastSpawnDelayMs: Long = baseDeterministicDelay
private var currentSpawnDelayMs = baseDeterministicDelay.toFloat()
private val DELAY_SMOOTHING = 0.7f
private val MISS_PENALTY = 1
private val frameRunnable = object : Runnable {
override fun run() {
invalidate()
if (!loaderFinished) postDelayed(this, 16L)
}
override fun run() { invalidate(); if (!loaderFinished) postDelayed(this, 16L) }
}
init {
setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
handleTap(event.x, event.y)
}
true
}
}
init { setOnTouchListener { _, e -> if (e.action == MotionEvent.ACTION_DOWN) handleTap(e.x, e.y); true } }
fun startLoader(durationMs: Long? = null) {
val isAlreadyRunning = !loaderFinished
val newDuration = durationMs?.takeIf { it > 0L }
if (isAlreadyRunning && newDuration == null) {
val alreadyRunning = !loaderFinished
if (alreadyRunning && durationMs == null) {
expectedDurationMs = null
forceIndeterminate = true
startTime = System.currentTimeMillis()
return
}
expectedDurationMs = newDuration
expectedDurationMs = durationMs?.takeIf { it > 0 }
forceIndeterminate = expectedDurationMs == null
loaderFinished = false
startTime = System.currentTimeMillis()
isPlaying = false
score = 0
targets.clear()
streak = 0
particles.clear()
post { if (targets.isEmpty()) prepopulateIdleTargets() }
loadStartTime = System.currentTimeMillis()
playStartTime = 0
removeCallbacks(frameRunnable)
post(frameRunnable)
post { spawnTarget() }
if (!isIndeterminate) {
postDelayed({
if (!loaderFinished) {
forceIndeterminate = true
startTime = System.currentTimeMillis()
spawnTarget()
expectedDurationMs = null
}
}, expectedDurationMs!!)
}
@ -125,73 +114,115 @@ class TargetTapLoaderView @JvmOverloads constructor(
fun finishLoader() {
loaderFinished = true
particles.clear()
isPlaying = false
invalidate()
}
fun stopAndResetLoader() {
if (score > 0) {
val now = System.currentTimeMillis()
val dt = (now - startTime) / 1000.0
UIDialogs.toast("Nice! score was $score, ${"%.${1}f".format(score / dt).toDouble()} (per second)")
score = 0
val elapsed = (System.currentTimeMillis() - (if (playStartTime > 0) playStartTime else loadStartTime)) / 1000.0
UIDialogs.toast("Nice! score $score | ${"%.1f".format(score / elapsed)} / s")
}
loaderFinished = true
isPlaying = false
targets.clear()
particles.clear()
removeCallbacks(frameRunnable)
invalidate()
}
private val isIndeterminate get() = forceIndeterminate || expectedDurationMs == null || expectedDurationMs == 0L
private fun handleTap(x: Float, y: Float) {
val now = System.currentTimeMillis()
val hitIndex = targets.indexOfFirst { t -> !t.hit && hypot(x - t.x, y - t.y) <= t.radius }
if (hitIndex >= 0) {
val idx = targets.indexOfFirst { !it.hit && hypot(x - it.x, y - it.y) <= it.radius }
if (idx >= 0) {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
val target = targets[hitIndex]
if (!target.hit) {
target.hit = true
target.hitTime = now
score += if (!isIndeterminate) 10 else 5
spawnParticles(target.x, target.y, target.radius)
val t = targets[idx]
t.hit = true; t.hitTime = System.currentTimeMillis()
streak++
score += if (!isIndeterminate) 10 else 5
spawnParticles(t.x, t.y, t.radius)
if (!isPlaying) {
isPlaying = true
playStartTime = System.currentTimeMillis()
score = 0
streak = 0
targets.retainAll { it === t }
spawnTarget()
}
}
} else if (isPlaying) applyMissPenalty()
}
private fun applyMissPenalty() { streak = max(0, streak - MISS_PENALTY) }
private fun spawnTarget() {
if (loaderFinished) return
if (width <= 0 || height <= 0) {
post { spawnTarget() }
return
if (loaderFinished || width == 0 || height == 0) {
postDelayed({ spawnTarget() }, 200L); return
}
val radius = Random.nextInt(40, 80).toFloat()
val x = Random.nextFloat() * (width - 2 * radius) + radius
val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius
targets.add(Target(x, y, radius, System.currentTimeMillis()))
if (!isPlaying) { postDelayed({ spawnTarget() }, 500L); return }
val delay = if (isIndeterminate) 1400L else 700L
val radius = Random.nextInt(40, 80).toFloat()
val x = Random.nextFloat() * (width - 2 * radius) + radius
val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius
val baseSpeed = Random.nextFloat() + .1f
val speed = baseSpeed
val angle = Random.nextFloat() * TAU
val vx = cos(angle) * speed
val vy = sin(angle) * speed
val alpha = Random.nextInt(150, 255)
targets += Target(x, y, radius, System.currentTimeMillis(), baseAlpha = alpha, vx = vx, vy = vy)
val delayBase = if (isIndeterminate) baseIndeterminateDelay else baseDeterministicDelay
val streakBoost = 1f + streak * streakAccelerationStep
val baseFactor = if (streak == 0) initialSpawnFactor else 1f
val targetDelay = max(minSpawnDelay.toFloat(), delayBase * baseFactor / streakBoost)
currentSpawnDelayMs = currentSpawnDelayMs * DELAY_SMOOTHING + targetDelay * (1 - DELAY_SMOOTHING)
val delay = currentSpawnDelayMs.roundToLong()
lastSpawnDelayMs = delay
postDelayed({ spawnTarget() }, delay)
}
private fun prepopulateIdleTargets() {
if (width == 0 || height == 0) {
post { prepopulateIdleTargets() }
return
}
repeat(idleInitialTargets) {
val radius = Random.nextInt(40, 80).toFloat()
val x = Random.nextFloat() * (width - 2 * radius) + radius
val y = Random.nextFloat() * (height - 2 * radius - 60f) + radius
val angle = Random.nextFloat() * TAU
val speed = (Random.nextFloat() * .3f + .05f) * idleSpeedMultiplier
val vx = cos(angle) * speed
val vy = sin(angle) * speed
val alpha = Random.nextInt(60, 110)
targets += Target(x, y, radius, System.currentTimeMillis(), baseAlpha = alpha, vx = vx, vy = vy)
}
}
private fun spawnParticles(cx: Float, cy: Float, radius: Float) {
repeat(12) {
val angle = Random.nextFloat() * 2f * PI.toFloat()
val angle = Random.nextFloat() * TAU
val speed = Random.nextFloat() * 5f + 2f
val vx = cos(angle) * speed
val vy = sin(angle) * speed
particles.add(Particle(cx, cy, vx, vy, System.currentTimeMillis()))
val col = ColorUtils.setAlphaComponent(primaryColor, Random.nextInt(120, 255))
particles += Particle(cx, cy, vx, vy, System.currentTimeMillis(), col)
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawBackground(canvas)
val now = System.currentTimeMillis()
val now = System.currentTimeMillis()
val deltaMs = now - lastFrameTime
lastFrameTime = now
drawBackground(canvas)
drawTargets(canvas, now)
drawParticles(canvas, now)
@ -200,140 +231,137 @@ class TargetTapLoaderView @JvmOverloads constructor(
else drawDeterministicProgressBar(canvas, now)
}
canvas.drawText("Score: $score", width / 2f, height - 80f, textPaint)
if (isPlaying) {
val margin = 24f
val scoreTxt = "Score $score"
val speed = 1000f / lastSpawnDelayMs
val speedTxt = "Speed ${"%.2f".format(speed)}/s"
val maxWidth = width - margin
val needRight = max(textPaint.measureText(scoreTxt), textPaint.measureText(speedTxt)) > maxWidth
if (loaderFinished) {
canvas.drawText("Loading Complete!", width / 2f, height / 2f, textPaint)
val alignX = if (needRight) (width - margin) else margin
textPaint.textAlign = if (needRight) Paint.Align.RIGHT else Paint.Align.LEFT
canvas.drawText(scoreTxt, alignX, textPaint.textSize + margin, textPaint)
canvas.drawText(speedTxt, alignX, 2*textPaint.textSize + margin + 4f, textPaint)
textPaint.textAlign = Paint.Align.LEFT
}
else if (loaderFinished)
canvas.drawText("Loading Complete!", width/2f, height/2f, textPaint.apply { textAlign = Paint.Align.CENTER })
else {
textPaint.textAlign = Paint.Align.CENTER
canvas.drawText(idleHintText, width / 2f, height - 48f, textPaint)
textPaint.textAlign = Paint.Align.LEFT
}
}
private fun drawBackground(canvas: Canvas) {
val gradient = LinearGradient(
backgroundPaint.shader = LinearGradient(
0f, 0f, 0f, height.toFloat(),
Color.rgb(20, 20, 40), Color.BLACK,
Shader.TileMode.CLAMP
Color.rgb(20, 20, 40), Color.BLACK, Shader.TileMode.CLAMP
)
backgroundPaint.shader = gradient
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint)
}
private fun drawTargets(canvas: Canvas, now: Long) {
val expireMs = if (isIndeterminate) 2500L else 1500L
targets.removeAll { it.hit && now - it.hitTime > 300L }
targets.removeAll { !it.hit && now - it.spawnTime > expireMs }
for (t in targets) {
val scale = when {
t.hit -> {
1f - ((now - t.hitTime) / 300f).coerceIn(0f, 1f)
}
else -> {
val spawnElapsed = now - t.spawnAnimationStartTime
if (spawnElapsed < 300L) {
val animProgress = spawnElapsed / 300f
bounceInterpolator.getInterpolation(animProgress)
} else {
val pulseTime = ((now - t.spawnAnimationStartTime) / 1000f) * 2f * PI.toFloat() + t.idlePulseOffset
1f + 0.02f * sin(pulseTime)
}
}
val expireMsActive = if (isIndeterminate) 2500L else 1500L
val it = targets.iterator()
while (it.hasNext()) {
val t = it.next()
if (t.hit && now - t.hitTime > 300L) { it.remove(); continue }
if (isPlaying && !t.hit && now - t.spawnTime > expireMsActive) {
it.remove(); applyMissPenalty(); continue
}
t.x += t.vx; t.y += t.vy
t.vx += (Random.nextFloat() - .5f) * floatAccel
t.vy += (Random.nextFloat() - .5f) * floatAccel
val speedCap = if (isPlaying) Float.MAX_VALUE else idleMaxSpeed
val mag = hypot(t.vx, t.vy)
if (mag > speedCap) {
val s = speedCap / mag
t.vx *= s; t.vy *= s
}
if (t.x - t.radius < 0 || t.x + t.radius > width) t.vx *= -1
if (t.y - t.radius < 0 || t.y + t.radius > height) t.vy *= -1
val scale = if (t.hit) 1f - ((now - t.hitTime) / 300f).coerceIn(0f,1f)
else {
val e = now - t.spawnAnimStart
if (e < 300L) overshootInterpolator.getInterpolation(e/300f)
else 1f + .02f * sin(((now - t.spawnAnimStart)/1000f)*TAU + t.pulseOffset)
}
val animAlpha = if (t.hit) ((1f - scale)*255).toInt() else 255
val globalAlpha = if (isPlaying) 255 else inactiveGlobalAlpha
val alpha = (animAlpha * t.baseAlpha /255f * globalAlpha/255f).toInt().coerceAtMost(255)
val r = max(1f, t.radius*scale)
val outerCol = ColorUtils.setAlphaComponent(primaryColor, alpha)
val midCol = ColorUtils.setAlphaComponent(primaryColor, (alpha*.7f).toInt())
val innerCol = ColorUtils.setAlphaComponent(primaryColor, (alpha*.4f).toInt())
outerRingPaint.color = outerCol; middleRingPaint.color = midCol; centerDotPaint.color = innerCol
val alpha = if (t.hit) ((1f - scale) * 255).toInt().coerceAtMost(255) else 255
val safeRadius = (t.radius * scale).coerceAtLeast(1f)
glowPaint.shader = RadialGradient(t.x, t.y, r, outerCol, Color.TRANSPARENT, Shader.TileMode.CLAMP)
glowPaint.shader = RadialGradient(
t.x, t.y, safeRadius,
Color.YELLOW, Color.TRANSPARENT,
Shader.TileMode.CLAMP
)
canvas.drawCircle(t.x, t.y, safeRadius * 1.2f, glowPaint)
canvas.drawCircle(t.x + 4f, t.y + 4f, safeRadius, shadowPaint)
outerRingPaint.alpha = alpha
middleRingPaint.alpha = alpha
centerDotPaint.alpha = alpha
canvas.drawCircle(t.x, t.y, safeRadius, outerRingPaint)
canvas.drawCircle(t.x, t.y, safeRadius * 0.66f, middleRingPaint)
canvas.drawCircle(t.x, t.y, safeRadius * 0.33f, centerDotPaint)
canvas.drawCircle(t.x, t.y, r*1.2f, glowPaint)
canvas.drawCircle(t.x+4f, t.y+4f, r, shadowPaint)
canvas.drawCircle(t.x, t.y, r, outerRingPaint)
canvas.drawCircle(t.x, t.y, r*.66f, middleRingPaint)
canvas.drawCircle(t.x, t.y, r*.33f, centerDotPaint)
}
}
private fun drawParticles(canvas: Canvas, now: Long) {
val lifespan = 400L
val iterator = particles.iterator()
while (iterator.hasNext()) {
val p = iterator.next()
val it = particles.iterator()
while (it.hasNext()) {
val p = it.next()
val age = now - p.startTime
if (age > lifespan) {
iterator.remove()
continue
}
val alpha = ((1f - (age / lifespan.toFloat())) * 255).toInt()
p.x += p.vx
p.y += p.vy
particlePaint.alpha = alpha
if (age > lifespan) { it.remove(); continue }
val a = ((1f - age/lifespan.toFloat())*255).toInt()
particlePaint.color = ColorUtils.setAlphaComponent(p.baseColor, a)
p.x += p.vx; p.y += p.vy
canvas.drawCircle(p.x, p.y, 6f, particlePaint)
}
}
private fun drawDeterministicProgressBar(canvas: Canvas, now: Long) {
val duration = expectedDurationMs ?: return
val rawProgress = ((now - startTime).toFloat() / duration).coerceIn(0f, 1f)
val easedProgress = AccelerateDecelerateInterpolator().getInterpolation(rawProgress)
val barHeight = 20f
val barRadius = 10f
val barWidth = width * easedProgress
val rect = RectF(0f, height - barHeight, barWidth, height.toFloat())
canvas.drawRoundRect(rect, barRadius, barRadius, progressBarPaint)
val dur = expectedDurationMs ?: return
val prog = ((now - loadStartTime) / dur.toFloat()).coerceIn(0f, 1f)
val eased = AccelerateDecelerateInterpolator().getInterpolation(prog)
val h = 20f; val r=10f
canvas.drawRoundRect(RectF(0f, height-h, width*eased, height.toFloat()), r, r, progressBarPaint)
}
private fun drawIndeterminateSpinner(canvas: Canvas, deltaMs: Long) {
val cx = width / 2f
val cy = height / 2f
val radius = min(width, height) / 6f
val sweepAngle = 270f
spinnerAngle = (spinnerAngle + 0.25f * deltaMs) % 360f
if (spinnerShader == null) {
spinnerShader = SweepGradient(
cx, cy,
intArrayOf(Color.TRANSPARENT, Color.WHITE, Color.TRANSPARENT),
floatArrayOf(0f, 0.5f, 1f)
)
}
private fun drawIndeterminateSpinner(canvas: Canvas, dt: Long) {
val cx=width/2f; val cy=height/2f; val r=min(width,height)/6f
spinnerAngle = (spinnerAngle + .25f*dt)%360f
if(spinnerShader == null) spinnerShader = SweepGradient(cx,cy,intArrayOf(Color.TRANSPARENT,Color.WHITE,Color.TRANSPARENT),floatArrayOf(0f,.5f,1f))
spinnerPaint.shader = spinnerShader
val glowPaint = Paint(spinnerPaint).apply {
maskFilter = BlurMaskFilter(15f, BlurMaskFilter.Blur.SOLID)
}
canvas.drawArc(cx - radius, cy - radius, cx + radius, cy + radius, spinnerAngle, sweepAngle, false, glowPaint)
canvas.drawArc(cx - radius, cy - radius, cx + radius, cy + radius, spinnerAngle, sweepAngle, false, spinnerPaint)
val glow = Paint(spinnerPaint).apply{ maskFilter = BlurMaskFilter(15f,BlurMaskFilter.Blur.SOLID) }
val sweep = 270f
canvas.drawArc(cx-r,cy-r,cx+r,cy+r,spinnerAngle,sweep,false,glow)
canvas.drawArc(cx-r,cy-r,cx+r,cy+r,spinnerAngle,sweep,false,spinnerPaint)
}
private data class Target(
val x: Float,
val y: Float,
var x: Float,
var y: Float,
val radius: Float,
val spawnTime: Long,
var hit: Boolean = false,
var hitTime: Long = 0L,
val spawnAnimationStartTime: Long = System.currentTimeMillis(),
val idlePulseOffset: Float = Random.nextFloat() * 2f * PI.toFloat()
val baseAlpha: Int = 255,
var vx: Float=0f,
var vy:Float=0f,
val spawnAnimStart: Long = System.currentTimeMillis(),
val pulseOffset: Float = Random.nextFloat() * TAU
)
private data class Particle(
var x:Float,
var y:Float,
val vx:Float,
val vy:Float,
val startTime:Long,
val baseColor:Int
)
private data class Particle(
var x: Float,
var y: Float,
val vx: Float,
val vy: Float,
val startTime: Long
)
private companion object { private const val TAU = (2 * Math.PI).toFloat() }
}