From e65b6d6786029bff71969081f8517ea230445303 Mon Sep 17 00:00:00 2001 From: yet Date: Sun, 17 May 2026 16:37:00 +0400 Subject: [PATCH 1/5] Stop ScoreChip tally-rollup from cascading recompositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScoreChip layered an Animatable interpolation on top of AnimatedCounter's per-digit AnimatedContent. The outer Animatable's .value was read in ScoreChip's composition scope, so the rollup ticked the entire chip ~60×/s for the duration of every score change, which in turn re-allocated 4–6 AnimatedContent slots inside AnimatedCounter on every frame. A single score event burned roughly 500 recompositions across both chips. Pass the raw value straight through. AnimatedCounter's per-digit slide already provides the motion; the outer rollup was gilding the lily. One recomp per score change instead of ~250 per chip. While here, fix the AnimatedContent slot keying — the previous comment claimed positional keying but the code matched slots by list index, so on digit-count transitions (999 → 1,000) the ones- place '9' animated into the thousands-place '1' and a digit slot morphed into the ',' separator. Wrap each AnimatedContent in key(positionFromRight) so slots stay anchored to their digit place. Also memoize formatScore() by value so a parent recomp doesn't re-format unnecessarily. Co-Authored-By: Claude Opus 4.7 --- .../component/score/AnimatedCounter.kt | 66 +++++++++++-------- .../blokblast/component/score/ScoreChip.kt | 30 +++------ 2 files changed, 46 insertions(+), 50 deletions(-) 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, ), From 6b3e288a3d02455cff2fdf9930ec55b2d1388988 Mon Sep 17 00:00:00 2001 From: yet Date: Sun, 17 May 2026 17:57:16 +0400 Subject: [PATCH 2/5] Stop TraySlot from recomposing on every animation frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TraySlot read `pieceAlpha` and `slotBg` via `by` delegates on animateFloatAsState / animateColorAsState, so each frame of those animations re-ran the whole TraySlot composition — Layout Inspector showed 292 recomp / 0 skips over a normal play session, with three slots affected. Drop the `by` delegates, keep the State / State references, and read them in draw phase instead: - slotBg → drawBehind { drawRect(slotBgState.value) } replaces Modifier.background(slotBg). The color tick now invalidates only draw, not composition. - pieceAlpha → applied via a wrapping Modifier.graphicsLayer { alpha = pieceAlphaState.value } around MiniPiece, instead of being baked into the Color passed as a parameter. Same pattern the breath/wiggle animations in this file already used (see existing comment at line 163). After this, TraySlot drops to 36 / 0 (-87%) and BlockPiece skips 100% of its calls. Co-Authored-By: Claude Opus 4.7 --- .../yet3/blokblast/screen/game/PieceTray.kt | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) 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, + ) + } } } } From 9bf6f4ce4e3f5425d1fcf8bc80248384adac708a Mon Sep 17 00:00:00 2001 From: yet Date: Sun, 17 May 2026 18:19:54 +0400 Subject: [PATCH 3/5] Fix iOS AdMob banner never loading, collapse slot on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS banner slot reserved 0dp until the ad reported success, but the Google Mobile Ads SDK reads GADBannerView.bounds at .load() time and rejects every request whose container has zero width or height — before the request even reaches the network. Result: chicken-and-egg, no ad ever loaded on iOS. Logs were full of [AdCoordinator] banner failed to load: Error Domain=com.google.admob Code=0 "Invalid ad width or height." (Response ID: null) with the cascading WebContent / RBS noise that comes from WebKit spinning up an ad container, failing, and tearing down. Fix: reserve 50dp up-front so the BannerView has valid bounds at load time, then collapse to 0dp only after the SDK reports a real load failure (offline / no-fill). If a later refresh succeeds the slot expands back to 50dp. Matches the Android UX (no empty banner-sized hole when there's no ad to show) while accommodating the iOS SDK's stricter bounds check that Android's AdView quietly works around via its declared AdSize.BANNER intrinsic size. Wiring: - IosAdBridge.makeBannerView gains a third onFailed: () -> Unit param - AdCoordinator.makeBannerView forwards onFailed to BannerDelegate - BannerDelegate.didFailToReceiveAdWithError invokes it - AdBanner.ios.kt tracks isAdLoaded + hasFailed; visible iff loaded or not-yet-failed Co-Authored-By: Claude Opus 4.7 --- .../ge/yet3/blokblast/ads/AdBanner.ios.kt | 37 ++++++++++++++++--- .../ge/yet3/blokblast/ads/IosAdBridge.kt | 9 ++++- iosApp/iosApp/AdCoordinator.swift | 22 ++++++++--- 3 files changed, 55 insertions(+), 13 deletions(-) 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/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() } } From 5da72f8abd124048860ed766fbb28ef4b2a829e2 Mon Sep 17 00:00:00 2001 From: yet Date: Sun, 17 May 2026 18:53:17 +0400 Subject: [PATCH 4/5] Smooth piece-placement animation, drop cross-cell visual collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit popIn used an aggressive anisotropic squash (scaleX 1.25 / scaleY 0.65, recoil 1.18) plus a per-cell (x+y) * 25L wave delay. Both were tuned for a single isolated cell and backfired on multi-cell pieces: at peak squash each cell intruded ~12% past its grid bounds into the neighbour's slot (the inter-cell gap is only 2dp ≈ 8%), and the wave delay staggered that deformation so different cells of the same piece were at clashing scales at the same instant. Testers reported "ghost cells" and a jerky placement animation. Pull the squash amplitudes way down (1.05 / 0.95 / 1.02) so the cell stays inside its grid slot throughout the animation. Switch all eases to FastOutSlowInEasing and raise the recoil spring's damping ratio to 0.8 with stiffness 450 so the settle is critically-damped rather than ringing. Drop the wave delay at the call site so all cells of a placed piece animate in lockstep. Total duration is still ~250ms; the visible collision is gone and the motion reads as a gentle settle. Co-Authored-By: Claude Opus 4.7 --- .../ge/yet3/blokblast/screen/game/GameGrid.kt | 2 +- .../screen/game/effects/CellAnimationState.kt | 35 +++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) 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/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)) } } } From 0d09c3bd654116245bd7c74dc27aff026a4e7029 Mon Sep 17 00:00:00 2001 From: yet Date: Sun, 17 May 2026 18:54:49 +0400 Subject: [PATCH 5/5] Bump version to 1.4.7 (versionCode 11) Co-Authored-By: Claude Opus 4.7 --- fastlane/metadata/android/en-US/changelogs/11.txt | 1 + gradle.properties | 4 ++-- iosApp/Configuration/Config.xcconfig | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/11.txt 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