Attempt at a loader game.

This commit is contained in:
Koen J 2025-07-05 12:58:10 +02:00
commit cc247ce634
6 changed files with 302 additions and 71 deletions

View file

@ -32,7 +32,6 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStateAtLeast
import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.BuildConfig
@ -114,7 +113,6 @@ import java.io.PrintWriter
import java.io.StringWriter
import java.lang.reflect.InvocationTargetException
import java.util.LinkedList
import java.util.Queue
import java.util.UUID
import java.util.concurrent.ConcurrentLinkedQueue
@ -610,6 +608,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}, UIDialogs.ActionStyle.PRIMARY)
)
}
//startActivity(Intent(this, TestActivity::class.java))
}
/*

View file

@ -2,12 +2,24 @@ package com.futo.platformplayer.activities
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.views.TargetTapLoaderView
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class TestActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_test);
val view = findViewById<TargetTapLoaderView>(R.id.test_view)
view.startLoader(10000)
lifecycleScope.launch {
delay(5000)
view.startLoader()
}
}
companion object {

View file

@ -0,0 +1,273 @@
package com.futo.platformplayer.views
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.graphics.toColorInt
import kotlin.math.*
import kotlin.random.Random
class TargetTapLoaderView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private var expectedDurationMs: Long? = null
private var startTime: Long = 0L
private var loaderFinished = false
private var forceIndeterminate = false
private val isIndeterminate: Boolean
get() = forceIndeterminate || expectedDurationMs == null || expectedDurationMs == 0L
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 = 48f
textAlign = Paint.Align.CENTER
setShadowLayer(4f, 0f, 0f, Color.BLACK)
typeface = Typeface.DEFAULT_BOLD
}
private val progressBarPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = "#2D63ED".toColorInt()
}
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)
}
private val backgroundPaint = Paint()
private var spinnerAngle = 0f
private val frameRunnable = object : Runnable {
override fun run() {
invalidate()
if (!loaderFinished) postDelayed(this, 16L)
}
}
init {
setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
handleTap(event.x, event.y)
}
true
}
}
fun startLoader(durationMs: Long? = null) {
expectedDurationMs = durationMs?.takeIf { it > 0L }
forceIndeterminate = expectedDurationMs == null
loaderFinished = false
startTime = System.currentTimeMillis()
score = 0
targets.clear()
particles.clear()
removeCallbacks(frameRunnable)
post(frameRunnable)
post { spawnTarget() }
if (!isIndeterminate) {
postDelayed({
if (!loaderFinished) {
forceIndeterminate = true
startTime = System.currentTimeMillis()
spawnTarget()
}
}, expectedDurationMs!!)
}
}
fun finishLoader() {
loaderFinished = true
invalidate()
}
fun stopAndResetLoader() {
loaderFinished = true
targets.clear()
particles.clear()
removeCallbacks(frameRunnable)
invalidate()
}
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) {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
val target = targets[hitIndex]
target.hit = true
target.hitTime = now
score += if (!isIndeterminate) 10 else 5
spawnParticles(target.x, target.y, target.radius)
}
}
private fun spawnTarget() {
if (loaderFinished) 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()))
val delay = if (isIndeterminate) 1400L else 700L
postDelayed({ spawnTarget() }, delay)
}
private fun spawnParticles(cx: Float, cy: Float, radius: Float) {
repeat(12) {
val angle = Random.nextFloat() * 2f * PI.toFloat()
val speed = Random.nextFloat() * 5f + 2f
val vx = cos(angle) * speed
val vy = sin(angle) * speed
particles.add(Particle(cx, cy, vx, vy, 255, System.currentTimeMillis()))
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawBackground(canvas)
val now = System.currentTimeMillis()
drawTargets(canvas, now)
drawParticles(canvas, now)
if (!loaderFinished) {
if (isIndeterminate) drawIndeterminateSpinner(canvas)
else drawDeterministicProgressBar(canvas, now)
}
canvas.drawText("Score: $score", width / 2f, height - 80f, textPaint)
if (loaderFinished) {
canvas.drawText("Loading Complete!", width / 2f, height / 2f, textPaint)
}
}
private fun drawBackground(canvas: Canvas) {
val gradient = LinearGradient(
0f, 0f, 0f, height.toFloat(),
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 = if (t.hit) 1f - ((now - t.hitTime) / 300f).coerceIn(0f, 1f) else 1f
val alpha = if (t.hit) ((1f - scale) * 255).toInt().coerceAtMost(255) else 255
val safeRadius = (t.radius * scale).coerceAtLeast(1f)
val glowPaint = Paint().apply {
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)
}
}
private fun drawParticles(canvas: Canvas, now: Long) {
val lifespan = 400L
val iterator = particles.iterator()
while (iterator.hasNext()) {
val p = iterator.next()
val age = now - p.startTime
if (age > lifespan) {
iterator.remove()
continue
}
val alpha = ((1f - (age / lifespan.toFloat())) * 255).toInt()
val paint = Paint().apply {
color = Color.YELLOW
this.alpha = alpha
}
p.x += p.vx
p.y += p.vy
canvas.drawCircle(p.x, p.y, 6f, paint)
}
}
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)
}
private fun drawIndeterminateSpinner(canvas: Canvas) {
spinnerAngle = (spinnerAngle + 6f) % 360f
val cx = width / 2f
val cy = height / 2f
val radius = min(width, height) / 6f
val sweepAngle = 270f
val glowPaint = Paint(spinnerPaint).apply {
maskFilter = BlurMaskFilter(15f, BlurMaskFilter.Blur.SOLID)
}
val shader = SweepGradient(cx, cy, intArrayOf(Color.TRANSPARENT, Color.WHITE, Color.TRANSPARENT), floatArrayOf(0f, 0.5f, 1f))
spinnerPaint.shader = shader
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)
}
private data class Target(
val x: Float,
val y: Float,
val radius: Float,
val spawnTime: Long,
var hit: Boolean = false,
var hitTime: Long = 0L
)
private data class Particle(
var x: Float,
var y: Float,
val vx: Float,
val vy: Float,
var alpha: Int,
val startTime: Long
)
}

View file

@ -44,6 +44,7 @@ import com.futo.platformplayer.formatDuration
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView
import com.futo.platformplayer.views.others.ProgressBar
import kotlinx.coroutines.CoroutineScope
@ -154,10 +155,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
val onChapterClicked = Event1<IChapter>();
private val loaderOverlay: FrameLayout
private val loaderIndeterminate: android.widget.ProgressBar
private val loaderDeterminate: android.widget.ProgressBar
private var determinateAnimator: ValueAnimator? = null
private val _loaderGame: TargetTapLoaderView
@OptIn(UnstableApi::class)
constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) {
@ -199,13 +197,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration);
_control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause);
loaderOverlay = findViewById(R.id.loader_overlay)
loaderIndeterminate = findViewById(R.id.loader_indeterminate)
loaderDeterminate = findViewById(R.id.loader_determinate)
loaderOverlay.visibility = View.GONE
loaderIndeterminate.visibility = View.GONE
loaderDeterminate.visibility = View.GONE
_loaderGame = findViewById(R.id.loader_overlay)
_loaderGame.visibility = View.GONE
_control_chapter.setOnClickListener {
_currentChapter?.let {
@ -884,37 +877,17 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
}
override fun setLoading(isLoading: Boolean) {
determinateAnimator?.cancel()
if (isLoading) {
loaderOverlay.visibility = View.VISIBLE
loaderIndeterminate.visibility = View.VISIBLE
loaderDeterminate.visibility = View.GONE
_loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader()
} else {
loaderOverlay.visibility = View.GONE
loaderIndeterminate.visibility = View.GONE
loaderDeterminate.visibility = View.GONE
_loaderGame.visibility = View.GONE
_loaderGame.stopAndResetLoader()
}
}
override fun setLoading(expectedDurationMs: Int) {
determinateAnimator?.cancel()
loaderOverlay.visibility = View.VISIBLE
loaderIndeterminate.visibility = View.GONE
loaderDeterminate.visibility = View.VISIBLE
loaderDeterminate.max = expectedDurationMs
loaderDeterminate.progress = 0
determinateAnimator = ValueAnimator.ofInt(0, expectedDurationMs).apply {
duration = expectedDurationMs.toLong()
addUpdateListener { anim ->
loaderDeterminate.progress = anim.animatedValue as Int
if(loaderDeterminate.progress > loaderDeterminate.max - 10) {
setLoading(true);
}
}
start()
};
_loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader(expectedDurationMs.toLong())
}
}

View file

@ -5,9 +5,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="@color/black">
<com.futo.platformplayer.views.others.CircularProgressBar
<com.futo.platformplayer.views.TargetTapLoaderView
android:id="@+id/test_view"
android:layout_width="match_parent"
android:layout_height="200dp"
app:progress="0%"
app:strokeWidth="20dp" />
android:layout_height="240dp" />
</FrameLayout>

View file

@ -65,36 +65,10 @@
android:visibility="gone" />
</FrameLayout>
<FrameLayout
<com.futo.platformplayer.views.TargetTapLoaderView
android:id="@+id/loader_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="6dp"
android:background="@color/black"
android:clickable="true"
android:focusable="true"
android:visibility="gone">
<ProgressBar
android:id="@+id/loader_indeterminate"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="gone" />
<ProgressBar
android:id="@+id/loader_determinate"
style="@android:style/Widget.ProgressBar.Horizontal"
android:progressDrawable="@drawable/progress_bar"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="false"
android:max="100"
android:progress="0"
android:visibility="gone"/>
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>