From 08e98b089c28e629b629e6144a16e75ca69e96be Mon Sep 17 00:00:00 2001 From: Koen J Date: Sat, 5 Jul 2025 17:32:31 +0200 Subject: [PATCH] Improvements to target tap loader game. --- .../views/TargetTapLoaderView.kt | 406 ++++++++++-------- 1 file changed, 217 insertions(+), 189 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt b/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt index 21ce31b5..6319cf73 100644 --- a/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/TargetTapLoaderView.kt @@ -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() private val particles = mutableListOf() - 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() } }