diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/AnimatedCounter.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/AnimatedCounter.kt index 0bebb8c..45a0f74 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/AnimatedCounter.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/AnimatedCounter.kt @@ -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 @@ -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, - ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/ScoreChip.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/ScoreChip.kt index cea4fc2..2fbac93 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/ScoreChip.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/score/ScoreChip.kt @@ -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 @@ -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 @@ -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 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( @@ -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) @@ -76,7 +64,7 @@ fun ScoreChip( color = labelColor, ) AnimatedCounter( - value = displayValue, + value = value, style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.Medium, ), diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameGrid.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameGrid.kt index 6ca71fc..731a09c 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameGrid.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameGrid.kt @@ -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 } diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/PieceTray.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/PieceTray.kt index 7e968a8..555ca41 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/PieceTray.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/PieceTray.kt @@ -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 @@ -202,8 +203,11 @@ 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 (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", @@ -211,7 +215,8 @@ private fun TraySlot( val pColor = piece?.let { pieceColor(it.colorId) } - val slotBg by animateColorAsState( + // Same rule: State read inside drawBehind (draw phase), not here. + val slotBgState = animateColorAsState( targetValue = when { isHighlighted && pColor != null -> pColor.copy(alpha = 0.18f) else -> MaterialTheme.colorScheme.surfaceVariant @@ -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, @@ -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, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/effects/CellAnimationState.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/effects/CellAnimationState.kt index 74475c0..335dff7 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/effects/CellAnimationState.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/effects/CellAnimationState.kt @@ -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)) } } } diff --git a/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/ads/AdBanner.ios.kt b/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/ads/AdBanner.ios.kt index 34a0537..e477e89 100644 --- a/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/ads/AdBanner.ios.kt +++ b/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/ads/AdBanner.ios.kt @@ -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 = { _ -> }, diff --git a/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/ads/IosAdBridge.kt b/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/ads/IosAdBridge.kt index ae3451e..caed3e9 100644 --- a/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/ads/IosAdBridge.kt +++ b/composeApp/src/iosMain/kotlin/ge/yet3/blokblast/ads/IosAdBridge.kt @@ -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 } diff --git a/fastlane/metadata/android/en-US/changelogs/11.txt b/fastlane/metadata/android/en-US/changelogs/11.txt new file mode 100644 index 0000000..4f0aa4b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/11.txt @@ -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. 🧩✨ diff --git a/gradle.properties b/gradle.properties index d71b2d1..16708ce 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 \ No newline at end of file +appVersionName=1.4.7 +appVersionCode=11 \ No newline at end of file diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 360c98e..d860f1f 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -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 \ No newline at end of file +CURRENT_PROJECT_VERSION=11 +MARKETING_VERSION=1.4.7 \ No newline at end of file diff --git a/iosApp/iosApp/AdCoordinator.swift b/iosApp/iosApp/AdCoordinator.swift index 4d44ee0..36a3ebb 100644 --- a/iosApp/iosApp/AdCoordinator.swift +++ b/iosApp/iosApp/AdCoordinator.swift @@ -46,9 +46,14 @@ final class AdCoordinator: NSObject, FullScreenContentDelegate { self?.showInterstitial(onDismiss: dismissAsVoid) } } - IosAdBridge.shared.makeBannerView = { [weak self] adUnitId, onLoaded in + IosAdBridge.shared.makeBannerView = { [weak self] adUnitId, onLoaded, onFailed in let onLoadedAsVoid: () -> Void = { _ = onLoaded() } - return self?.makeBannerView(adUnitId: adUnitId, onLoaded: onLoadedAsVoid) ?? UIView() + let onFailedAsVoid: () -> Void = { _ = onFailed() } + return self?.makeBannerView( + adUnitId: adUnitId, + onLoaded: onLoadedAsVoid, + onFailed: onFailedAsVoid, + ) ?? UIView() } } @@ -84,12 +89,16 @@ final class AdCoordinator: NSObject, FullScreenContentDelegate { // MARK: - Banner - func makeBannerView(adUnitId: String, onLoaded: @escaping () -> Void) -> UIView { + func makeBannerView( + adUnitId: String, + onLoaded: @escaping () -> Void, + onFailed: @escaping () -> Void, + ) -> UIView { let bannerView = BannerView(adSize: AdSizeBanner) bannerView.adUnitID = adUnitId bannerView.rootViewController = Self.rootViewController() - let delegate = BannerDelegate(onLoaded: onLoaded) + let delegate = BannerDelegate(onLoaded: onLoaded, onFailed: onFailed) bannerView.delegate = delegate objc_setAssociatedObject(bannerView, &bannerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) @@ -136,9 +145,11 @@ private var bannerDelegateKey: UInt8 = 0 final class BannerDelegate: NSObject, BannerViewDelegate { private let onLoaded: () -> Void + private let onFailed: () -> Void - init(onLoaded: @escaping () -> Void) { + init(onLoaded: @escaping () -> Void, onFailed: @escaping () -> Void) { self.onLoaded = onLoaded + self.onFailed = onFailed } func bannerViewDidReceiveAd(_ bannerView: BannerView) { @@ -147,5 +158,6 @@ final class BannerDelegate: NSObject, BannerViewDelegate { func bannerView(_ bannerView: BannerView, didFailToReceiveAdWithError error: Error) { print("[AdCoordinator] banner failed to load: \(error)") + onFailed() } }