Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
Expand All @@ -36,43 +38,49 @@ fun AnimatedCounter(
color: Color = LocalContentColor.current,
durationMillis: Int = 350,
) {
val formatted = value.formatScore()
val formatted = remember(value) { value.formatScore() }

Row(
modifier = modifier,
verticalAlignment = Alignment.Bottom,
) {
formatted.forEachIndexed { index, char ->
// Key by *position from the right*, so left-side new digits don't
// re-key the whole row when the number gains a digit.
// Key by *position from the right* so AnimatedContent slots stay
// aligned to digit places when the number gains or loses a digit
// (e.g. 999 → 1,000). Without this key, positional matching makes
// a ones-place '9' animate into a thousands-place '1' and a tens
// '9' into a separator ',' — visually jarring and it resets every
// AnimatedContent's internal Transition.
val positionFromRight = formatted.length - index
AnimatedContent(
targetState = char,
transitionSpec = {
val rollingUp = targetState.digitOrNull() != null &&
initialState.digitOrNull() != null &&
(targetState.digitOrNull()!! > initialState.digitOrNull()!!)
val direction = if (rollingUp) 1 else -1
val enter = slideInVertically(
animationSpec = tween(durationMillis),
) { fullHeight -> direction * fullHeight } + fadeIn(tween(durationMillis))
val exit = slideOutVertically(
animationSpec = tween(durationMillis),
) { fullHeight -> -direction * fullHeight } + fadeOut(tween(durationMillis))
ContentTransform(
targetContentEnter = enter,
initialContentExit = exit,
sizeTransform = SizeTransform(clip = false),
key(positionFromRight) {
AnimatedContent(
targetState = char,
transitionSpec = {
val rollingUp = targetState.digitOrNull() != null &&
initialState.digitOrNull() != null &&
(targetState.digitOrNull()!! > initialState.digitOrNull()!!)
val direction = if (rollingUp) 1 else -1
val enter = slideInVertically(
animationSpec = tween(durationMillis),
) { fullHeight -> direction * fullHeight } + fadeIn(tween(durationMillis))
val exit = slideOutVertically(
animationSpec = tween(durationMillis),
) { fullHeight -> -direction * fullHeight } + fadeOut(tween(durationMillis))
ContentTransform(
targetContentEnter = enter,
initialContentExit = exit,
sizeTransform = SizeTransform(clip = false),
)
},
label = "digit-$positionFromRight",
) { displayedChar ->
Text(
text = displayedChar.toString(),
style = style,
color = color,
maxLines = 1,
)
},
label = "digit-$positionFromRight",
) { displayedChar ->
Text(
text = displayedChar.toString(),
style = style,
color = color,
maxLines = 1,
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
package ge.yet3.blokblast.component.score

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
Expand All @@ -11,8 +8,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
Expand All @@ -24,6 +19,14 @@ import ge.yet3.blokblast.component.modifier.whisperShadow
/**
* Compact pill that pairs a small caption label with an animated number.
* Used in the Game top bar for live score / best-score readouts.
*
* The score value is passed straight through to [AnimatedCounter], which
* already animates each digit on change via per-position AnimatedContent.
* A previous version layered an Animatable<Float> tally-rollup on top of
* that, which forced ScoreChip to recompose ~60×/s for the full duration
* of each score change — cascading into AnimatedCounter and re-allocating
* 4-6 AnimatedContent slots per chip per frame. Removing the outer rollup
* cuts the recomposition count per score event from ~500 to ~1.
*/
@Composable
fun ScoreChip(
Expand All @@ -40,21 +43,6 @@ fun ScoreChip(
MaterialTheme.colorScheme.onSurface
}

// Tally rollup — interpolate from the previous score to the new one so
// intermediate digits actually flow through (5000 → 5100 → 5200 → ... →
// 5400) instead of swapping leftmost digits in one step. Duration scales
// with the size of the jump but is capped so big bonuses don't crawl.
val animated = remember { Animatable(value.toFloat()) }
LaunchedEffect(value) {
val delta = kotlin.math.abs(value - animated.value.toLong())
val durationMs = (200 + delta.toInt() * 2).coerceIn(200, 800)
animated.animateTo(
targetValue = value.toFloat(),
animationSpec = tween(durationMs, easing = FastOutSlowInEasing),
)
}
val displayValue = animated.value.toLong()

Column(
modifier = modifier
.whisperShadow(shape = shape)
Expand All @@ -76,7 +64,7 @@ fun ScoreChip(
color = labelColor,
)
AnimatedCounter(
value = displayValue,
value = value,
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Medium,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ private fun GridCell(
var lastSeenCellId by remember { mutableIntStateOf(cellId) }
LaunchedEffect(cellId) {
if (cellId != -1 && lastSeenCellId == -1) {
cellAnim.popIn(delayMs = (x + y) * 25L)
cellAnim.popIn(delayMs = 0L)
}
lastSeenCellId = cellId
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
Expand Down Expand Up @@ -202,16 +203,20 @@ private fun TraySlot(
val applyBreath = canFit && !isPressed && !isSelected
val applyWiggle = !canFit && piece != null

// Dim/desaturate when this piece can't be placed anywhere.
val pieceAlpha by animateFloatAsState(
// Dim/desaturate when this piece can't be placed anywhere. NOTE: keep this
// as State<Float> (no `by` delegate) so reads happen in the draw phase
// inside graphicsLayer — otherwise every animation tick recomposes the
// whole TraySlot (~290 recomp/session, observed in Layout Inspector).
val pieceAlphaState = animateFloatAsState(
targetValue = if (canFit) 1f else 0.45f,
animationSpec = tween(220),
label = "pieceAlpha",
)

val pColor = piece?.let { pieceColor(it.colorId) }

val slotBg by animateColorAsState(
// Same rule: State<Color> read inside drawBehind (draw phase), not here.
val slotBgState = animateColorAsState(
targetValue = when {
isHighlighted && pColor != null -> pColor.copy(alpha = 0.18f)
else -> MaterialTheme.colorScheme.surfaceVariant
Expand Down Expand Up @@ -254,7 +259,7 @@ private fun TraySlot(
} else 0f
}
.clip(RoundedCornerShape(14.dp))
.background(slotBg)
.drawBehind { drawRect(slotBgState.value) }
.then(
if (isHighlighted) Modifier.border(
width = 2.dp,
Expand Down Expand Up @@ -328,13 +333,16 @@ private fun TraySlot(
) {
if (piece != null) {
val baseColor = pieceColor(piece.colorId)
val visibleColor = (if (isHighlighted) baseColor else baseColor.copy(alpha = 0.6f))
.copy(alpha = baseColor.alpha * pieceAlpha)
MiniPiece(
shape = piece.shape,
color = visibleColor,
shimmerKey = piece.pieceId,
)
val visibleColor = if (isHighlighted) baseColor else baseColor.copy(alpha = 0.6f)
// Apply the animated dim via graphicsLayer so its per-frame ticks
// invalidate only this MiniPiece's draw, not TraySlot composition.
Box(modifier = Modifier.graphicsLayer { alpha = pieceAlphaState.value }) {
MiniPiece(
shape = piece.shape,
color = visibleColor,
shimmerKey = piece.pieceId,
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,41 @@ class CellAnimState {
val rotation = Animatable(0f)
val translateY = Animatable(0f)

/** Drop/snap bounce with squash — pancake-flat → tall recoil → settle. */
/**
* Drop/snap with a soft squash — barely-flat → very mild recoil → settle.
*
* Tuned for visual smoothness rather than punch: amplitudes are kept
* small enough that the cell never visibly leaves its grid slot, easing
* is FastOutSlowInEasing throughout (no linear "snap" feel), durations
* are stretched to ~250ms total, and the recoil springs use a high
* damping ratio so they settle without ringing.
*
* Previous more aggressive version (scaleX 1.25 / scaleY 0.65, recoil
* 1.18) made adjacent cells of multi-cell pieces overlap each other
* mid-animation — looked like ghost cells appearing.
*
* The per-cell (x+y) * 25L wave delay used at the call site was also
* dropped: staggering the squash across cells of a single piece made
* the visual collision much worse.
*/
suspend fun popIn(delayMs: Long) {
scale.snapTo(0.85f)
scaleX.snapTo(1.25f)
scaleY.snapTo(0.65f)
scale.snapTo(0.92f)
scaleX.snapTo(1.05f)
scaleY.snapTo(0.95f)
alpha.snapTo(1f)
rotation.snapTo(0f)
delay(delayMs)
coroutineScope {
launch {
scale.animateTo(1f, tween(140, easing = LinearOutSlowInEasing))
scale.animateTo(1f, tween(220, easing = FastOutSlowInEasing))
}
launch {
// Squash → recoil → settle (parallel, slightly offset on rebound).
scaleX.animateTo(0.92f, tween(110, easing = LinearOutSlowInEasing))
scaleX.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = 700f))
scaleX.animateTo(0.98f, tween(140, easing = FastOutSlowInEasing))
scaleX.animateTo(1f, spring(dampingRatio = 0.8f, stiffness = 450f))
}
launch {
scaleY.animateTo(1.18f, tween(110, easing = LinearOutSlowInEasing))
scaleY.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = 700f))
scaleY.animateTo(1.02f, tween(140, easing = FastOutSlowInEasing))
scaleY.animateTo(1f, spring(dampingRatio = 0.8f, stiffness = 450f))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,50 @@ import kotlinx.cinterop.ExperimentalForeignApi
import platform.UIKit.UIView

/**
* iOS banner slot. Height is 0dp until the ad successfully loads, at which
* point it expands to 50dp.
* iOS banner slot.
*
* Three-state visibility model:
* - Initial render: slot reserves 50dp so the underlying GADBannerView has
* non-zero bounds at `.load()` time. Without that, the iOS SDK rejects
* every request before the network with "Invalid ad width or height"
* and the slot would stay collapsed forever (chicken-and-egg).
* - Load succeeds: stays at 50dp.
* - Load fails: collapses to 0dp so an empty banner-sized hole isn't shown
* when offline / no-fill. If a later refresh succeeds, the slot expands
* back to 50dp on the `onLoaded` callback.
*
* Android does the same logical thing in its AdMobBanner, but can start at
* 0dp because Android's AdView falls back to its declared AdSize.BANNER
* intrinsic size when its container is collapsed. The iOS SDK reads the
* GADBannerView's bounds directly and is stricter.
*/
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun AdBanner(modifier: Modifier) {
var isAdLoaded by remember { mutableStateOf(false) }
var hasFailed by remember { mutableStateOf(false) }

// Reserve 50dp unless we've seen a failure with no successful load yet.
val visible = isAdLoaded || !hasFailed

Box(
modifier = modifier
.fillMaxWidth()
.height(if (isAdLoaded) 50.dp else 0.dp),
.height(if (visible) 50.dp else 0.dp),
contentAlignment = Alignment.Center,
) {
UIKitView(
factory = {
IosAdBridge.makeBannerView?.invoke(AppConfig.BANNER_UNIT_ID_IOS) {
isAdLoaded = true
} ?: UIView()
IosAdBridge.makeBannerView?.invoke(
AppConfig.BANNER_UNIT_ID_IOS,
{
isAdLoaded = true
hasFailed = false
},
{
hasFailed = true
},
) ?: UIView()
},
modifier = Modifier.fillMaxWidth().height(50.dp),
update = { _ -> },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ object IosAdBridge {
/**
* Factory that returns a GADBannerView configured for the given unit ID.
* Wrapped by Kotlin in a `UIKitView` for placement in Compose layouts.
* Invokes [onLoaded] when the first ad is successfully fetched.
* - [onLoaded] fires every time an ad is successfully fetched (initial
* load and any later refresh-retry that succeeds after a failure).
* - [onFailed] fires every time a load attempt fails, so Compose can
* collapse the slot instead of leaving a blank 50dp hole.
*/
var makeBannerView: ((adUnitId: String, onLoaded: () -> Unit) -> UIView)? = null
var makeBannerView: (
(adUnitId: String, onLoaded: () -> Unit, onFailed: () -> Unit) -> UIView
)? = null
}
1 change: 1 addition & 0 deletions fastlane/metadata/android/en-US/changelogs/11.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Polish pass! ✨ Piece-placement animation is smoother and no longer shows "ghost" cells on multi-block pieces. Big internal speed-up: score chip, tray slots, and the board recompose far less often, so drags and combos feel snappier on older devices. iOS sound + banner ads got their own round of fixes too. 🧩✨
4 changes: 2 additions & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ android.nonTransitiveRClass=true
android.useAndroidX=true

#App version (single source of truth; CI overrides via -PappVersionName / -PappVersionCode)
appVersionName=1.4.6
appVersionCode=10
appVersionName=1.4.7
appVersionCode=11
4 changes: 2 additions & 2 deletions iosApp/Configuration/Config.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ TEAM_ID=
PRODUCT_NAME=Logica
PRODUCT_BUNDLE_IDENTIFIER=ge.yet3.blokblast.BlockBlast$(TEAM_ID)

CURRENT_PROJECT_VERSION=10
MARKETING_VERSION=1.4.6
CURRENT_PROJECT_VERSION=11
MARKETING_VERSION=1.4.7
Loading
Loading