From 186a5e433f6ddf8673073e81f29012d147b02fe0 Mon Sep 17 00:00:00 2001 From: yet Date: Sat, 16 May 2026 18:45:13 +0400 Subject: [PATCH 1/6] Fix piece placement failing on valid cells after line clears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drag pipeline captured `model.grid` once at drag start (via pointerInput's awaitPointerEventScope closure). When lines cleared mid-drag, `canPlacePiece` kept checking against the stale grid and rejected drops on cells that had just been freed — the user had to exit to Home and Continue to recover. Wrap the drag callbacks in rememberUpdatedState so they always see the current grid. Also: - Restore pieceIdCounter on engine restore to prevent ID collisions between persisted pieces and newly refilled ones. - Track clear-animation nonce per cell so concurrent clears don't skip the animation when the parent's prevNonce updates first. - Center the dragged ghost on the finger horizontally for a more predictable snap target regardless of pickup point. Co-Authored-By: Claude Opus 4.7 --- .../yet3/blokblast/screen/game/DragDropState.kt | 8 ++++++-- .../blokblast/screen/game/DraggedPieceOverlay.kt | 6 ++---- .../ge/yet3/blokblast/screen/game/GameContent.kt | 2 -- .../ge/yet3/blokblast/screen/game/GameGrid.kt | 12 +++++++++--- .../ge/yet3/blokblast/screen/game/PieceTray.kt | 16 +++++++++++----- .../ge/yet/blokblast/domain/engine/GameEngine.kt | 3 +++ 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DragDropState.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DragDropState.kt index 6a08037..f07efd6 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DragDropState.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DragDropState.kt @@ -83,8 +83,12 @@ class DragDropState { val ghostH = piece.shape.height * ghostCellSizePx + (piece.shape.height - 1).coerceAtLeast(0) * ghostGapPx - val ghostTopLeftX = dragPosition.x - fingerOffset.x - ghostW / 2f - val ghostTopLeftY = dragPosition.y - fingerOffset.y - ghostH - verticalLiftPx + // The floating ghost is drawn with its center at [dragPosition] (virtual + // finger) horizontally, and entirely above the finger vertically. + // This is a "lifted" drag style that ensures the piece is never + // obscured by the user's thumb. + val ghostTopLeftX = dragPosition.x - ghostW / 2f + val ghostTopLeftY = dragPosition.y - ghostH - verticalLiftPx // Snap by rounding the ghost's top-left to the nearest grid cell // — rounding (not floor) so half-cell overlaps jump to the closer diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DraggedPieceOverlay.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DraggedPieceOverlay.kt index 4c0fff8..499b7a8 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DraggedPieceOverlay.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DraggedPieceOverlay.kt @@ -35,8 +35,6 @@ fun DraggedPieceOverlay( verticalLift: Dp, dragPositionX: Float, dragPositionY: Float, - fingerOffsetX: Float, - fingerOffsetY: Float, modifier: Modifier = Modifier, ) { val shadowCells = remember(piece) { @@ -51,8 +49,8 @@ fun DraggedPieceOverlay( val ghostH = cellSize.toPx() * piece.shape.height + gap.toPx() * (piece.shape.height - 1).coerceAtLeast(0) IntOffset( - x = (dragPositionX - fingerOffsetX - ghostW / 2f).toInt(), - y = (dragPositionY - fingerOffsetY - ghostH - verticalLift.toPx()).toInt(), + x = (dragPositionX - ghostW / 2f).toInt(), + y = (dragPositionY - ghostH - verticalLift.toPx()).toInt(), ) } .graphicsLayer { diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt index 7272a6d..79f2bc3 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt @@ -416,8 +416,6 @@ fun GameContent(component: GameComponent) { verticalLift = DRAG_GHOST_VERTICAL_LIFT, dragPositionX = dragDrop.dragPosition.x, dragPositionY = dragDrop.dragPosition.y, - fingerOffsetX = dragDrop.fingerOffset.x, - fingerOffsetY = dragDrop.fingerOffset.y, ) } 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 6598963..e581f91 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 @@ -166,11 +166,9 @@ fun GameGrid( ) var prevGrid by remember { mutableStateOf(grid) } - var prevNonce by remember { mutableIntStateOf(clearedEvent.nonce) } LaunchedEffect(grid, clearedEvent) { prevGrid = grid - prevNonce = clearedEvent.nonce } val saturation = animateFloatAsState( @@ -192,6 +190,11 @@ fun GameGrid( mutableIntStateOf(cellId) } + // Internal nonce tracker per cell to ensure clear animations + // never skip due to the parent GameGrid updating prevNonce + // before the cell's LaunchedEffect fires. + var cellPrevNonce by remember { mutableIntStateOf(clearedEvent.nonce) } + // Game-over board collapse: staggered "fall" of every filled // cell. Stagger from the bottom-up by row, with a small // column-based jitter so it feels physical. @@ -217,12 +220,15 @@ fun GameGrid( // Clear animation detection LaunchedEffect(clearedEvent) { - if (clearedEvent.nonce != prevNonce && clearedEvent.cells.any { it.x == x && it.y == y }) { + if (clearedEvent.nonce != cellPrevNonce && clearedEvent.cells.any { it.x == x && it.y == y }) { + cellPrevNonce = clearedEvent.nonce isClearing = true cellAnim.clear(delayMs = (x + y) * 30L) isClearing = false displayColor = -1 cellAnim.reset() + } else { + cellPrevNonce = clearedEvent.nonce } } 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 29fe70c..7e968a8 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 @@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -227,6 +228,11 @@ private fun TraySlot( var slotOriginInWindow by remember { mutableStateOf(Offset.Zero) } val touchSlop = LocalViewConfiguration.current.touchSlop + val currentOnDragStart by rememberUpdatedState(onDragStart) + val currentOnDragMove by rememberUpdatedState(onDragMove) + val currentOnDragEnd by rememberUpdatedState(onDragEnd) + val currentOnTap by rememberUpdatedState(onTap) + Box( modifier = modifier .padding(6.dp) @@ -286,23 +292,23 @@ private fun TraySlot( if (!dragging && delta.getDistance() > touchSlop) { dragging = true val startInWindow = slotOriginInWindow + downPos - onDragStart?.invoke(piece, startInWindow, downPos) + currentOnDragStart?.invoke(piece, startInWindow, downPos) } if (dragging) { change.consume() val posInWindow = slotOriginInWindow + change.position - onDragMove?.invoke(posInWindow) + currentOnDragMove?.invoke(posInWindow) } } if (event.type == PointerEventType.Release) { isPressed = false if (dragging) { - onDragEnd?.invoke() + currentOnDragEnd?.invoke() } else { // It was a tap — toggle selection - onTap() + currentOnTap() } break } @@ -311,7 +317,7 @@ private fun TraySlot( // If the pointer was cancelled if (isPressed) { isPressed = false - if (dragging) onDragEnd?.invoke() + if (dragging) currentOnDragEnd?.invoke() } } } diff --git a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/engine/GameEngine.kt b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/engine/GameEngine.kt index 2f5254c..42693dd 100644 --- a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/engine/GameEngine.kt +++ b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/engine/GameEngine.kt @@ -91,6 +91,9 @@ class GameEngine( */ fun restore(state: GameState) { _state.value = state + // Restore counter so new pieces don't collide with existing IDs. + pieceIdCounter = state.currentPieces.maxOfOrNull { it.pieceId } ?: 0 + if (!state.isGameOver && state.currentPieces.isNotEmpty()) { _events.tryEmit(GameEvent.GameStarted) } From f21aaa33f2ff4c3ae383e2213c240ff00931a776 Mon Sep 17 00:00:00 2001 From: yet Date: Sat, 16 May 2026 19:08:53 +0400 Subject: [PATCH 2/6] Reduce recompositions during drag and clear-animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameContent no longer recomposes on every drag tick. DraggedPieceOverlay now reads dragDropState.dragPosition inside its Modifier.offset lambda, so the State subscription stays in the layout phase and only the overlay relayouts as the finger moves. GameGrid applies the game-over saturation ColorFilter once on a parent graphicsLayer instead of per-cell, cutting 64 ColorMatrix applications to one. Cells are extracted into a private GridCell composable that receives stable primitive parameters (cellId: Int, clearedNonce: Int, isInClearedEvent: Boolean, tapEnabled: Boolean) instead of the full Grid/ClearEvent/Piece references, so Compose can actually skip cells whose inputs didn't change. DragDropState.updateDrag always recomputes isValidPlacement — the grid can change under a stationary finger when a line-clear finishes mid-drag, and gating the recompute on anchor-change reintroduced the "valid spot but won't place" bug from the previous commit. Structural equality on mutableStateOf already elides redundant emits. Co-Authored-By: Claude Opus 4.7 --- .../blokblast/screen/game/DragDropState.kt | 4 + .../screen/game/DraggedPieceOverlay.kt | 8 +- .../yet3/blokblast/screen/game/GameContent.kt | 3 +- .../ge/yet3/blokblast/screen/game/GameGrid.kt | 283 ++++++++++-------- 4 files changed, 159 insertions(+), 139 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DragDropState.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DragDropState.kt index f07efd6..9b10e4c 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DragDropState.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DragDropState.kt @@ -100,6 +100,10 @@ class DragDropState { val anchorX = kotlin.math.round(relX / step).toInt() val anchorY = kotlin.math.round(relY / step).toInt() + // Always recompute validity — the grid can change under a stationary + // finger (line-clear animation finishes mid-drag and frees cells). + // mutableStateOf's structural equality elides redundant emits, so + // there's no need to gate the writes manually. hoverAnchor = anchorX to anchorY isValidPlacement = canPlacePiece(piece.shape, anchorX, anchorY, grid) } diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DraggedPieceOverlay.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DraggedPieceOverlay.kt index 499b7a8..4f1fef7 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DraggedPieceOverlay.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DraggedPieceOverlay.kt @@ -33,8 +33,7 @@ fun DraggedPieceOverlay( cellSize: Dp, gap: Dp, verticalLift: Dp, - dragPositionX: Float, - dragPositionY: Float, + dragDropState: DragDropState, modifier: Modifier = Modifier, ) { val shadowCells = remember(piece) { @@ -44,13 +43,14 @@ fun DraggedPieceOverlay( Box( modifier = modifier .offset { + val dragPosition = dragDropState.dragPosition val ghostW = cellSize.toPx() * piece.shape.width + gap.toPx() * (piece.shape.width - 1).coerceAtLeast(0) val ghostH = cellSize.toPx() * piece.shape.height + gap.toPx() * (piece.shape.height - 1).coerceAtLeast(0) IntOffset( - x = (dragPositionX - ghostW / 2f).toInt(), - y = (dragPositionY - ghostH - verticalLift.toPx()).toInt(), + x = (dragPosition.x - ghostW / 2f).toInt(), + y = (dragPosition.y - ghostH - verticalLift.toPx()).toInt(), ) } .graphicsLayer { diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt index 79f2bc3..ed22285 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt @@ -414,8 +414,7 @@ fun GameContent(component: GameComponent) { cellSize = DRAG_GHOST_CELL_SIZE, gap = DRAG_GHOST_GAP, verticalLift = DRAG_GHOST_VERTICAL_LIFT, - dragPositionX = dragDrop.dragPosition.x, - dragPositionY = dragDrop.dragPosition.y, + dragDropState = dragDrop, ) } 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 e581f91..09891f0 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 @@ -9,6 +9,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.offset @@ -65,20 +66,15 @@ fun GameGrid( onGridMeasured: ((gridOriginX: Float, gridOriginY: Float, cellSizePx: Float, gapPx: Float) -> Unit)? = null, ) { val surfaceColor = MaterialTheme.colorScheme.surface - val emptyColor = MaterialTheme.colorScheme.surfaceVariant val density = LocalDensity.current val gapPx = with(density) { GAP_DP.toPx() } - // Danger ramp — fraction of the board filled. Drives the inner red - // vignette starting at 70% so the player feels the squeeze before - // game-over actually triggers. - val totalCells = COLS * COLS - val filledCells = remember(grid) { + // Danger ramp — fraction of the board filled. + val dangerLevel = remember(grid) { var n = 0 - for (v in grid.cells) if (v != ge.yet.blokblast.domain.model.Grid.EMPTY) n++ - n + for (v in grid.cells) if (v != Grid.EMPTY) n++ + n.toFloat() / (COLS * COLS).toFloat() } - val dangerLevel = filledCells.toFloat() / totalCells.toFloat() BoxWithConstraints( modifier = modifier @@ -86,12 +82,8 @@ fun GameGrid( .clip(RoundedCornerShape(16.dp)) .background(surfaceColor) .padding(GRID_PADDING_DP) - .then( - if (comboStripes != null) Modifier.comboStripes(comboStripes) else Modifier, - ) - .then( - if (particleBurst != null) Modifier.particleBurst(particleBurst) else Modifier, - ) + .then(if (comboStripes != null) Modifier.comboStripes(comboStripes) else Modifier) + .then(if (particleBurst != null) Modifier.particleBurst(particleBurst) else Modifier) .gridBorderGlow(comboLevel) .dangerVignette(dangerLevel) .onGloballyPositioned { coords -> @@ -103,19 +95,21 @@ fun GameGrid( ) { val cellSize: Dp = (maxWidth - (COLS - 1) * GAP_DP) / COLS - val hoverCells: Set> = if (dragDropState?.isDragging == true && dragDropState.hoverAnchor != null) { - val (ax, ay) = dragDropState.hoverAnchor!! - val piece = dragDropState.draggedPiece!! - piece.shape.cells.map { (ax + it.x) to (ay + it.y) }.toSet() - } else { - emptySet() + val hoverAnchor = dragDropState?.hoverAnchor + val draggedPiece = dragDropState?.draggedPiece + val isDragging = dragDropState?.isDragging == true + + val hoverCells = remember(hoverAnchor, draggedPiece, isDragging) { + if (isDragging && hoverAnchor != null && draggedPiece != null) { + draggedPiece.shape.cells.map { (hoverAnchor.first + it.x) to (hoverAnchor.second + it.y) }.toSet() + } else { + emptySet() + } } - val hoverColorId = dragDropState?.draggedPiece?.colorId + val hoverColorId = draggedPiece?.colorId val hoverValid = dragDropState?.isValidPlacement == true - // Predictive clear: if the current hover represents a valid placement, - // figure out which rows / cols would be fully filled after the drop, - // so the grid can preview the clear. + // Predictive clear preview val (predictedRows, predictedCols) = remember(grid, hoverCells, hoverValid) { if (!hoverValid || hoverCells.isEmpty()) { emptySet() to emptySet() @@ -125,15 +119,13 @@ fun GameGrid( for (i in 0 until COLS) { var rowFull = true for (j in 0 until COLS) { - val occupied = !grid.isEmpty(j, i) || (j to i) in hoverCells - if (!occupied) { rowFull = false; break } + if (grid.isEmpty(j, i) && (j to i) !in hoverCells) { rowFull = false; break } } if (rowFull) rows.add(i) var colFull = true for (j in 0 until COLS) { - val occupied = !grid.isEmpty(i, j) || (i to j) in hoverCells - if (!occupied) { colFull = false; break } + if (grid.isEmpty(i, j) && (i to j) !in hoverCells) { colFull = false; break } } if (colFull) cols.add(i) } @@ -141,6 +133,7 @@ fun GameGrid( } } val hasPrediction = predictedRows.isNotEmpty() || predictedCols.isNotEmpty() + val predictPulse = rememberInfiniteTransition(label = "predictPulse") val predictAlpha by predictPulse.animateFloat( initialValue = 0.35f, @@ -152,8 +145,6 @@ fun GameGrid( label = "predictAlpha", ) - // Pulsing alpha for the hover preview — gentle "breathing" of the - // target cells while the user holds the piece overhead. val hoverPulse = rememberInfiniteTransition(label = "hoverPulse") val hoverPulseAlpha by hoverPulse.animateFloat( initialValue = 0.55f, @@ -165,120 +156,146 @@ fun GameGrid( label = "hoverPulseAlpha", ) - var prevGrid by remember { mutableStateOf(grid) } - - LaunchedEffect(grid, clearedEvent) { - prevGrid = grid - } - - val saturation = animateFloatAsState( + val saturation by animateFloatAsState( targetValue = if (isGameOver) 0.2f else 1f, animationSpec = tween(400) - ).value - val colorMatrix = ColorMatrix().apply { setToSaturation(saturation) } - - for (row in 0 until COLS) { - for (col in 0 until COLS) { - val x = col - val y = row - - val cellAnim = remember { CellAnimState() } - - var isClearing by remember { mutableStateOf(false) } - val cellId = grid.colorAt(x, y) - var displayColor by remember(cellId) { - mutableIntStateOf(cellId) - } + ) + val colorFilter = remember(saturation) { + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(saturation) }) + } - // Internal nonce tracker per cell to ensure clear animations - // never skip due to the parent GameGrid updating prevNonce - // before the cell's LaunchedEffect fires. - var cellPrevNonce by remember { mutableIntStateOf(clearedEvent.nonce) } + // Membership lookup for cleared cells — built once per event so each + // cell can receive a primitive Boolean (stable, skippable param) + // instead of the whole event list. + val clearedSet = remember(clearedEvent) { + buildSet { for (c in clearedEvent.cells) add(c.x to c.y) } + } + val tapEnabled = selectedPiece != null - // Game-over board collapse: staggered "fall" of every filled - // cell. Stagger from the bottom-up by row, with a small - // column-based jitter so it feels physical. - LaunchedEffect(isGameOver) { - if (isGameOver && cellId != -1) { - val rowStagger = (COLS - 1 - y) * 35L - val colJitter = x * 12L - val falls = with(density) { 240.dp.toPx() } - cellAnim.fall(delayMs = rowStagger + colJitter, distancePx = falls) - } else if (!isGameOver) { - cellAnim.reset() - } + // Wrap cells in a layer to apply saturation effect once to the whole grid + Box(modifier = Modifier.graphicsLayer { this.colorFilter = colorFilter }) { + for (row in 0 until COLS) { + for (col in 0 until COLS) { + GridCell( + x = col, + y = row, + cellId = grid.colorAt(col, row), + cellSize = cellSize, + clearedNonce = clearedEvent.nonce, + isInClearedEvent = (col to row) in clearedSet, + isGameOver = isGameOver, + isHoverGhost = (col to row) in hoverCells, + hoverColorId = hoverColorId, + hoverValid = hoverValid, + hoverPulseAlpha = hoverPulseAlpha, + inPredictedLine = hasPrediction && (row in predictedRows || col in predictedCols), + predictAlpha = predictAlpha, + tapEnabled = tapEnabled, + onCellTapped = onCellTapped, + ) } + } + } + } +} - // Placement animation detection - LaunchedEffect(cellId) { - val currentId = cellId - val prevId = prevGrid.colorAt(x, y) - if (currentId != -1 && prevId == -1) { - cellAnim.popIn(delayMs = (x + y) * 25L) - } - } +@Composable +private fun GridCell( + x: Int, + y: Int, + cellId: Int, + cellSize: Dp, + clearedNonce: Int, + isInClearedEvent: Boolean, + isGameOver: Boolean, + isHoverGhost: Boolean, + hoverColorId: Int?, + hoverValid: Boolean, + hoverPulseAlpha: Float, + inPredictedLine: Boolean, + predictAlpha: Float, + tapEnabled: Boolean, + onCellTapped: (x: Int, y: Int) -> Unit, +) { + val density = LocalDensity.current + val emptyColor = MaterialTheme.colorScheme.surfaceVariant + val cellAnim = remember { CellAnimState() } - // Clear animation detection - LaunchedEffect(clearedEvent) { - if (clearedEvent.nonce != cellPrevNonce && clearedEvent.cells.any { it.x == x && it.y == y }) { - cellPrevNonce = clearedEvent.nonce - isClearing = true - cellAnim.clear(delayMs = (x + y) * 30L) - isClearing = false - displayColor = -1 - cellAnim.reset() - } else { - cellPrevNonce = clearedEvent.nonce - } - } + var displayColor by remember(cellId) { mutableIntStateOf(cellId) } + var cellPrevNonce by remember { mutableIntStateOf(clearedNonce) } + var isClearing by remember { mutableStateOf(false) } - if (!isClearing) { - displayColor = cellId - } + // Game-over board collapse + LaunchedEffect(isGameOver) { + if (isGameOver && cellId != -1) { + val rowStagger = (COLS - 1 - y) * 35L + val colJitter = x * 12L + val falls = with(density) { 240.dp.toPx() } + cellAnim.fall(delayMs = rowStagger + colJitter, distancePx = falls) + } else if (!isGameOver) { + cellAnim.reset() + } + } - val isFilled = displayColor != -1 - val isHoverGhost = (x to y) in hoverCells + // Placement animation detection + var lastSeenCellId by remember { mutableIntStateOf(cellId) } + LaunchedEffect(cellId) { + if (cellId != -1 && lastSeenCellId == -1) { + cellAnim.popIn(delayMs = (x + y) * 25L) + } + lastSeenCellId = cellId + } - val cellColor = when { - isFilled -> pieceColor(displayColor) - isHoverGhost && hoverColorId != null -> { - val base = pieceColorPreview(hoverColorId) - if (hoverValid) base.copy(alpha = base.alpha * hoverPulseAlpha) - else base.copy(alpha = 0.15f) - } - selectedPiece != null && previewHit(selectedPiece, x, y, grid) -> - pieceColorPreview(selectedPiece.colorId) - else -> emptyColor - } + // Clear animation detection + LaunchedEffect(clearedNonce) { + if (clearedNonce != cellPrevNonce && isInClearedEvent) { + cellPrevNonce = clearedNonce + isClearing = true + cellAnim.clear(delayMs = (x + y) * 30L) + isClearing = false + displayColor = -1 + cellAnim.reset() + } else { + cellPrevNonce = clearedNonce + } + } - val inPredictedLine = hasPrediction && (y in predictedRows || x in predictedCols) - val predictGlow = if (inPredictedLine) predictAlpha else 0f + if (!isClearing) { + displayColor = cellId + } - BlockPiece( - color = cellColor, - cellSize = cellSize, - filled = isFilled || (isHoverGhost && hoverValid), - scale = cellAnim.scale.value, - scaleX = cellAnim.scaleX.value, - scaleY = cellAnim.scaleY.value, - alpha = cellAnim.alpha.value, - flashAlpha = cellAnim.flashAlpha.value, - rotationDeg = cellAnim.rotation.value, - translateYPx = cellAnim.translateY.value, - predictGlowAlpha = predictGlow, - modifier = Modifier - .offset( - x = col * (cellSize + GAP_DP), - y = row * (cellSize + GAP_DP), - ) - .graphicsLayer { colorFilter = ColorFilter.colorMatrix(colorMatrix) } - .clickable(enabled = selectedPiece != null) { - onCellTapped(x, y) - }, - ) - } + val isFilled = displayColor != -1 + val cellColor = when { + isFilled -> pieceColor(displayColor) + isHoverGhost && hoverColorId != null -> { + val base = pieceColorPreview(hoverColorId) + if (hoverValid) base.copy(alpha = base.alpha * hoverPulseAlpha) + else base.copy(alpha = 0.15f) } + else -> emptyColor } -} -private fun previewHit(piece: Piece, x: Int, y: Int, grid: Grid): Boolean = false + val predictGlow = if (inPredictedLine) predictAlpha else 0f + + BlockPiece( + color = cellColor, + cellSize = cellSize, + filled = isFilled || (isHoverGhost && hoverValid), + scale = cellAnim.scale.value, + scaleX = cellAnim.scaleX.value, + scaleY = cellAnim.scaleY.value, + alpha = cellAnim.alpha.value, + flashAlpha = cellAnim.flashAlpha.value, + rotationDeg = cellAnim.rotation.value, + translateYPx = cellAnim.translateY.value, + predictGlowAlpha = predictGlow, + modifier = Modifier + .offset( + x = x * (cellSize + GAP_DP), + y = y * (cellSize + GAP_DP), + ) + .clickable(enabled = tapEnabled) { + onCellTapped(x, y) + }, + ) +} From 6f6e0406555fbe535b7f32378629d8fc4657889a Mon Sep 17 00:00:00 2001 From: yet Date: Sat, 16 May 2026 19:24:41 +0400 Subject: [PATCH 3/6] Fix best-score loss on restore, harden save file, fix iOS music race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 — Best score wiped on restore (GameEngine.kt:92): restore() did a wholesale `_state.value = state`, dropping the bestScore previously seeded from settings. If autosave hadn't flushed before the prior process death, the HUD would silently downgrade for the rest of the round and bestAtRoundStart (used by the review-prompt qualifier) would be wrong. Merge max(seeded, restored) inside restore() so all callers are protected. Covered by two new tests in GameEngineTest.kt (restore_preserves_seeded_best_when_save_has_lower_best, restore_uses_save_best_when_higher_than_seeded). H2 — Save robustness (SettingsBackedGameSaveRepository.kt:34-66): wrap persisted JSON in a versioned envelope (SavedGame { version, state }, CURRENT_SAVE_VERSION = 1). On load, drop saves whose version doesn't match — and, critically, settings.remove(KEY_SAVE) when the blob is unparseable so we don't pay the parse cost every cold start and don't leave a permanent one-way trap if a future GameState rename invalidates existing saves. H3 — iOS music loop race (NativePlatformSoundPlayer.kt:46-118): after loadPlayer (which hops dispatchers) the loop could publish a new musicPlayer and call play() after stopMusic() had already cleared the field — leaking audio with no way to stop it. Add a monotonic musicGeneration captured at launch; check ensureActive() + generation before assigning/playing. stopMusic() increments the generation so any in-flight load on the previous generation is dropped on the floor. Nits: - systemVersionMoreOrEqualThan.kt:17 — remove stray println. - GameContent.kt:186-187 — replace magic `8` with Grid.SIZE. Co-Authored-By: Claude Opus 4.7 --- .../yet3/blokblast/screen/game/GameContent.kt | 5 ++-- .../SettingsBackedGameSaveRepository.kt | 24 ++++++++++++++++--- .../platform/NativePlatformSoundPlayer.kt | 13 ++++++++++ .../utils/systemVersionMoreOrEqualThan.kt | 1 - .../yet/blokblast/domain/engine/GameEngine.kt | 10 +++++++- .../blokblast/domain/engine/GameEngineTest.kt | 21 ++++++++++++++++ 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt index ed22285..d4efcde 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/GameContent.kt @@ -47,6 +47,7 @@ import ge.yet3.blokblast.component.overlay.rememberGameTutorialSteps import ge.yet3.blokblast.theme.LocalOnTutorialSeen import ge.yet3.blokblast.theme.LocalTutorialSeen import ge.yet3.blokblast.theme.LocalVibrationEnabled +import ge.yet.blokblast.domain.model.Grid import androidx.compose.ui.unit.dp import blockblast.composeapp.generated.resources.Res import blockblast.composeapp.generated.resources.best @@ -183,8 +184,8 @@ fun GameContent(component: GameComponent) { LaunchedEffect(model.lastPointsAwarded) { val points = model.lastPointsAwarded.points if (points > 0) { - val cx = gridOriginX + (8 * cellSizePx + 7 * gapPx) / 2f - val cy = gridOriginY + (8 * cellSizePx + 7 * gapPx) / 2f + val cx = gridOriginX + (Grid.SIZE * cellSizePx + (Grid.SIZE - 1) * gapPx) / 2f + val cy = gridOriginY + (Grid.SIZE * cellSizePx + (Grid.SIZE - 1) * gapPx) / 2f floatingScore.add(points, androidx.compose.ui.geometry.Offset(cx, cy)) } } diff --git a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedGameSaveRepository.kt b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedGameSaveRepository.kt index dd19df1..c368c81 100644 --- a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedGameSaveRepository.kt +++ b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedGameSaveRepository.kt @@ -10,8 +10,19 @@ import ge.yet.blokblast.domain.repository.GameSaveRepository import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +/** + * Versioned save envelope. Bump [CURRENT_SAVE_VERSION] on any incompatible + * change to [GameState] (or its transitively serialized types). On load we + * drop saves whose version doesn't match — no migration framework yet. + */ +@Serializable +private data class SavedGame(val version: Int, val state: GameState) + +private const val CURRENT_SAVE_VERSION = 1 + /** * Disk-backed save store: serializes [GameState] as JSON into the shared * multiplatform-settings store. Survives process death. @@ -34,8 +45,9 @@ internal class SettingsBackedGameSaveRepository( override suspend fun save(state: GameState) { mutex.withLock { cached = state + val envelope = SavedGame(version = CURRENT_SAVE_VERSION, state = state) withContext(dispatchers.io) { - settings.putString(KEY_SAVE, json.encodeToString(GameState.serializer(), state)) + settings.putString(KEY_SAVE, json.encodeToString(SavedGame.serializer(), envelope)) } } } @@ -43,8 +55,14 @@ internal class SettingsBackedGameSaveRepository( override suspend fun load(): GameState? = mutex.withLock { if (loaded) return@withLock cached val raw = withContext(dispatchers.io) { settings.getStringOrNull(KEY_SAVE) } - cached = raw?.let { - runCatching { json.decodeFromString(GameState.serializer(), it) }.getOrNull() + val parsed = raw?.let { + runCatching { json.decodeFromString(SavedGame.serializer(), it) }.getOrNull() + } + cached = parsed?.takeIf { it.version == CURRENT_SAVE_VERSION }?.state + // Drop unreadable / wrong-version blobs so we don't pay the parse cost + // every cold start and don't keep a one-way trap for users. + if (raw != null && cached == null) { + withContext(dispatchers.io) { settings.remove(KEY_SAVE) } } loaded = true cached diff --git a/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt b/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt index b880823..3fd9b2f 100644 --- a/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt +++ b/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import platform.AVFAudio.AVAudioPlayer @@ -46,6 +47,10 @@ internal class NativePlatformSoundPlayer( private var musicPlayer: AVAudioPlayer? = null private var musicJob: Job? = null private var lastTrackIndex: Int = -1 + // Monotonically incremented each startMusic(); the loop captures its own + // generation and refuses to publish/play a track if it has been superseded + // (e.g., stopMusic ran while loadPlayer was on a background dispatcher). + private var musicGeneration: Long = 0L /** Known SFX filenames to preload eagerly at startup. */ private val knownSfx = listOf( @@ -80,12 +85,19 @@ internal class NativePlatformSoundPlayer( override fun startMusic() { if (musicJob?.isActive == true || musicPlayer?.playing == true) return + val generation = ++musicGeneration musicJob = scope.launch(Dispatchers.Main) { while (true) { val index = MusicPlaylist.nextIndex(lastTrackIndex) lastTrackIndex = index val filename = MusicPlaylist.TRACKS[index] val player = loadPlayer(filename) ?: return@launch + // After loadPlayer (which hops dispatchers), the loop may have + // been cancelled and/or superseded by another startMusic. In + // either case, drop this player on the floor — assigning it to + // musicPlayer or calling play() would leak audio past stopMusic. + coroutineContext.ensureActive() + if (generation != musicGeneration) return@launch player.numberOfLoops = 0 player.volume = MUSIC_VOLUME musicPlayer = player @@ -99,6 +111,7 @@ internal class NativePlatformSoundPlayer( } override fun stopMusic() { + musicGeneration++ musicJob?.cancel() musicJob = null musicPlayer?.stop() diff --git a/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/utils/systemVersionMoreOrEqualThan.kt b/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/utils/systemVersionMoreOrEqualThan.kt index ae62e4c..c07c5df 100644 --- a/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/utils/systemVersionMoreOrEqualThan.kt +++ b/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/utils/systemVersionMoreOrEqualThan.kt @@ -14,7 +14,6 @@ private fun String.versionToNumber() = split(".") .run { val appendedList = toMutableList() repeat(3 - size) { appendedList.add(0) } - println(appendedList) appendedList.foldIndexed(0) { index, acc, current -> acc + current * 1000.0.pow(2 - index).toInt() } diff --git a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/engine/GameEngine.kt b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/engine/GameEngine.kt index 42693dd..d3e22ce 100644 --- a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/engine/GameEngine.kt +++ b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/engine/GameEngine.kt @@ -90,7 +90,15 @@ class GameEngine( * resume their per-round work. */ fun restore(state: GameState) { - _state.value = state + // Merge best score: a freshly-seeded lifetime best from settings can be + // higher than the value persisted in the save blob if autosave hadn't + // flushed before the previous process death. Wholesale replacement + // would visibly downgrade the HUD this round. + val mergedBest = maxOf(_state.value.bestScore, state.bestScore) + _state.value = state.copy( + bestScore = mergedBest, + bestAtRoundStart = maxOf(state.bestAtRoundStart, mergedBest), + ) // Restore counter so new pieces don't collide with existing IDs. pieceIdCounter = state.currentPieces.maxOfOrNull { it.pieceId } ?: 0 diff --git a/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/GameEngineTest.kt b/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/GameEngineTest.kt index 6d590d0..b02470b 100644 --- a/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/GameEngineTest.kt +++ b/core/domain/src/commonTest/kotlin/ge/yet/blokblast/domain/engine/GameEngineTest.kt @@ -116,6 +116,27 @@ class GameEngineTest { assertEquals(100L, engine.state.value.bestScore) } + @Test + fun restore_preserves_seeded_best_when_save_has_lower_best() { + // Lifetime best from settings is seeded before a save is restored. + // If autosave hadn't flushed before the previous process died, the + // persisted save can carry a lower bestScore — restore must not + // visibly downgrade the HUD. + engine.seedBestScore(5000) + val stale = GameState(score = 42L, bestScore = 1000L, currentPieces = emptyList()) + engine.restore(stale) + assertEquals(5000L, engine.state.value.bestScore) + assertEquals(5000L, engine.state.value.bestAtRoundStart) + } + + @Test + fun restore_uses_save_best_when_higher_than_seeded() { + engine.seedBestScore(100) + val winning = GameState(score = 42L, bestScore = 9000L, currentPieces = emptyList()) + engine.restore(winning) + assertEquals(9000L, engine.state.value.bestScore) + } + @Test fun restore_emits_GameStarted_for_playable_state() = runTest { val playable = GameState( From 41e1ff3a98142c9581467ffe35b816a23bfcdbb2 Mon Sep 17 00:00:00 2001 From: yet Date: Sat, 16 May 2026 19:39:07 +0400 Subject: [PATCH 4/6] Stop animated hover/predict pulses from recomposing the whole grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameGrid read predictAlpha and hoverPulseAlpha (both InfiniteTransition floats) as State directly in the BoxWithConstraints scope and then passed the unwrapped Float to every GridCell. Two consequences: 1. The parent's content lambda re-ran ~60×/s, re-evaluating all 64 cell call-sites every frame. 2. Cells received a Float parameter that changed every frame, so even cells with stable inputs lost their skippability and recomposed. Pass each pulse as a `() -> Float` instead. The lambda identity is remembered, so it doesn't churn between recompositions, and the State read only happens inside the branches that actually use it — i.e. only in cells where isHoverGhost && hoverValid (hover pulse) or in cells where inPredictedLine (predict pulse). Cells outside those sets never read the State and stay skippable. iOS SFX: switch safePlayReturning to lazy-load on cache miss, matching the Android resolver. The knownSfx list was an enumeration requirement — if a key wasn't preloaded, it was permanently silent. Failed lookups are cached in sfxMisses so missing assets don't keep paying the I/O cost on every call. Co-Authored-By: Claude Opus 4.7 --- .../ge/yet3/blokblast/screen/game/GameGrid.kt | 19 +++++++++++------ .../platform/NativePlatformSoundPlayer.kt | 21 ++++++++++++++++--- 2 files changed, 31 insertions(+), 9 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 09891f0..60c1cc2 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 @@ -134,8 +134,13 @@ fun GameGrid( } val hasPrediction = predictedRows.isNotEmpty() || predictedCols.isNotEmpty() + // Pulses are exposed as State and passed downward as () -> Float + // lambdas so the read happens inside each cell's body — and only inside + // the branches that actually use it. Cells that are neither hovered nor + // in a predicted line never read the State, so they stay skippable and + // don't recompose at the animation frame rate. val predictPulse = rememberInfiniteTransition(label = "predictPulse") - val predictAlpha by predictPulse.animateFloat( + val predictAlphaState = predictPulse.animateFloat( initialValue = 0.35f, targetValue = 0.75f, animationSpec = infiniteRepeatable( @@ -144,9 +149,10 @@ fun GameGrid( ), label = "predictAlpha", ) + val predictAlpha: () -> Float = remember(predictAlphaState) { { predictAlphaState.value } } val hoverPulse = rememberInfiniteTransition(label = "hoverPulse") - val hoverPulseAlpha by hoverPulse.animateFloat( + val hoverPulseAlphaState = hoverPulse.animateFloat( initialValue = 0.55f, targetValue = 1f, animationSpec = infiniteRepeatable( @@ -155,6 +161,7 @@ fun GameGrid( ), label = "hoverPulseAlpha", ) + val hoverPulseAlpha: () -> Float = remember(hoverPulseAlphaState) { { hoverPulseAlphaState.value } } val saturation by animateFloatAsState( targetValue = if (isGameOver) 0.2f else 1f, @@ -211,9 +218,9 @@ private fun GridCell( isHoverGhost: Boolean, hoverColorId: Int?, hoverValid: Boolean, - hoverPulseAlpha: Float, + hoverPulseAlpha: () -> Float, inPredictedLine: Boolean, - predictAlpha: Float, + predictAlpha: () -> Float, tapEnabled: Boolean, onCellTapped: (x: Int, y: Int) -> Unit, ) { @@ -269,13 +276,13 @@ private fun GridCell( isFilled -> pieceColor(displayColor) isHoverGhost && hoverColorId != null -> { val base = pieceColorPreview(hoverColorId) - if (hoverValid) base.copy(alpha = base.alpha * hoverPulseAlpha) + if (hoverValid) base.copy(alpha = base.alpha * hoverPulseAlpha()) else base.copy(alpha = 0.15f) } else -> emptyColor } - val predictGlow = if (inPredictedLine) predictAlpha else 0f + val predictGlow = if (inPredictedLine) predictAlpha() else 0f BlockPiece( color = cellColor, diff --git a/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt b/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt index 3fd9b2f..a268b1c 100644 --- a/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt +++ b/core/data/src/nativeMain/kotlin/ge/yet/blokblast/data/platform/NativePlatformSoundPlayer.kt @@ -44,6 +44,9 @@ internal class NativePlatformSoundPlayer( ) : PlatformSoundPlayer { private val sfxCache: MutableMap = mutableMapOf() + // Tracks keys we've already tried and failed to load, so missing assets + // don't keep paying the I/O + decode cost on every play call. + private val sfxMisses: MutableSet = mutableSetOf() private var musicPlayer: AVAudioPlayer? = null private var musicJob: Job? = null private var lastTrackIndex: Int = -1 @@ -129,9 +132,21 @@ internal class NativePlatformSoundPlayer( private fun safePlay(key: String) { safePlayReturning(key) } private fun safePlayReturning(key: String): Boolean { - val player = sfxCache[key] ?: return false - player.currentTime = 0.0 - return player.play() + val player = sfxCache[key] + if (player != null) { + player.currentTime = 0.0 + return player.play() + } + // Lazy-load on miss: matches Android behaviour and avoids the + // requirement to enumerate every SFX in [knownSfx]. The current call + // can't play (load is async), but subsequent calls will hit the cache. + if (key !in sfxMisses) { + scope.launch(Dispatchers.Main) { + val loaded = loadPlayer("$key.mp3") + if (loaded != null) sfxCache[key] = loaded else sfxMisses += key + } + } + return false } /** From a19ef4965a71b6400ff79d63748a66b31fb45449 Mon Sep 17 00:00:00 2001 From: yet Date: Sat, 16 May 2026 20:04:01 +0400 Subject: [PATCH 5/6] Fix crash on cancelled predictive back gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decompose 3.5.0's default predictiveBackAnimation crashes with IllegalArgumentException: "Corner size in Px can't be negative" when the user starts a back swipe and releases it before crossing the commit threshold. Its built-in animatable runs the screen's RoundedCornerShape radius back to 0 via a spring, which overshoots below zero; RoundedCornerShape.createOutline then rejects the negative value and there's no catch in the layer pipeline. Replace it with a Cupertino-style animation that only translates the layers (no animated Shape anywhere). Any spring overshoot now shows up as a few pixels of extra horizontal drift, which is invisible — and the dim overlay's alpha is explicitly coerced to [0, 1]. Visually closer to native iOS swipe-back too. Co-Authored-By: Claude Opus 4.7 --- .../utils/CupertinoPredictiveBack.kt | 152 ++++++++++++++++++ .../yet3/blokblast/screen/root/RootContent.kt | 9 +- 2 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/utils/CupertinoPredictiveBack.kt diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/utils/CupertinoPredictiveBack.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/utils/CupertinoPredictiveBack.kt new file mode 100644 index 0000000..43ad61d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/utils/CupertinoPredictiveBack.kt @@ -0,0 +1,152 @@ +package ge.yet3.blokblast.component.utils + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.animation.Direction +import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimation +import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimator +import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.PredictiveBackAnimatable +import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimator +import com.arkivanov.essenty.backhandler.BackEvent +import com.arkivanov.essenty.backhandler.BackHandler + +/** + * iOS-style predictive back animation. + * + * Replacement for Decompose 3.5.0's default [predictiveBackAnimation], which + * crashes on cancelled back gestures with + * `IllegalArgumentException: Corner size in Px can't be negative`. + * + * The default animatable animates a RoundedCornerShape's corner radius via a + * spring that overshoots below 0 on cancel, then hands that negative value to + * RoundedCornerShape.createOutline which throws. This implementation uses only + * translation (and an alpha-coerced dim layer) — no animated shape, so any + * spring overshoot is harmless. + */ +@OptIn(ExperimentalDecomposeApi::class) +@Composable +fun cupertinoPredictiveBackAnimation( + backHandler: BackHandler, + onBack: () -> Unit, + fallbackAnimation: StackAnimation? = stackAnimation( + animator = cupertinoStackAnimator(), + disableInputDuringAnimation = true, + ), +): StackAnimation = predictiveBackAnimation( + backHandler = backHandler, + fallbackAnimation = fallbackAnimation, + onBack = onBack, + selector = { initialBackEvent, _, _ -> + cupertinoPredictiveBackAnimatable(initialBackEvent = initialBackEvent) + }, +) + +@ExperimentalDecomposeApi +private fun cupertinoPredictiveBackAnimatable( + initialBackEvent: BackEvent, +): PredictiveBackAnimatable = CupertinoPredictiveBackAnimatable(initialBackEvent) + +@OptIn(ExperimentalDecomposeApi::class) +private class CupertinoPredictiveBackAnimatable( + initialBackEvent: BackEvent, +) : PredictiveBackAnimatable { + + private val progressAnimatable = Animatable(initialValue = initialBackEvent.progress) + + @Suppress("unused") + private var swipeEdge by mutableStateOf(initialBackEvent.swipeEdge) + + override val exitModifier: Modifier = Modifier + .cupertinoPredictiveExit { progressAnimatable.value } + + override val enterModifier: Modifier + get() = Modifier.cupertinoPredictiveEnter { progressAnimatable.value } + + override suspend fun animate(event: BackEvent) { + swipeEdge = event.swipeEdge + progressAnimatable.snapTo(targetValue = event.progress) + } + + override suspend fun cancel() { + progressAnimatable.animateTo(targetValue = 0f) + } + + override suspend fun finish() { + progressAnimatable.animateTo(targetValue = 1f) + } +} + +private fun cupertinoStackAnimator( + animationSpec: FiniteAnimationSpec = cupertinoTween(durationMillis = 500), +): StackAnimator = stackAnimator( + animationSpec = animationSpec, +) { factor, direction, content -> + content( + Modifier.composed { + val layoutDirection = LocalLayoutDirection.current + graphicsLayer { + translationX = size.width * when (direction) { + Direction.ENTER_FRONT, + Direction.EXIT_FRONT -> factor + + else -> factor * SlideFactor + } + if (layoutDirection == LayoutDirection.Rtl) { + translationX = -translationX + } + } + }, + ) +} + +private fun Modifier.cupertinoPredictiveEnter(progress: () -> Float): Modifier = composed { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + graphicsLayer { + translationX = (progress() - 1f) * SlideFactor * size.width + if (isRtl) translationX = -translationX + }.drawWithContent { + drawContent() + drawRect(Color.Black, alpha = ((1f - progress()) * SlideFactor).coerceIn(0f, 1f)) + } +} + +private fun Modifier.cupertinoPredictiveExit(progress: () -> Float): Modifier = composed { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + graphicsLayer { + translationX = progress() * size.width + if (isRtl) translationX = -translationX + } +} + +private const val SlideFactor = 0.25f + +private fun cupertinoTween( + durationMillis: Int = CupertinoTransitionDuration, + delayMillis: Int = 0, + easing: Easing = CupertinoEasing, +): TweenSpec = tween( + durationMillis = durationMillis, + easing = easing, + delayMillis = delayMillis, +) + +private val CupertinoEasing = CubicBezierEasing(0.2833f, 0.99f, 0.31833f, 0.99f) +private const val CupertinoTransitionDuration = 400 diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/root/RootContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/root/RootContent.kt index e654b90..662d299 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/root/RootContent.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/root/RootContent.kt @@ -6,13 +6,9 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.stack.Children -import com.arkivanov.decompose.extensions.compose.stack.animation.fade -import com.arkivanov.decompose.extensions.compose.stack.animation.plus -import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation -import com.arkivanov.decompose.extensions.compose.stack.animation.scale -import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.extensions.compose.subscribeAsState import ge.yet.blockblast.feature.root.RootComponent +import ge.yet3.blokblast.component.utils.cupertinoPredictiveBackAnimation import ge.yet3.blokblast.screen.game.GameContent import ge.yet3.blokblast.screen.home.HomeContent @@ -27,9 +23,8 @@ fun RootContent( Children( modifier = modifier, stack = childStack, - animation = predictiveBackAnimation( + animation = cupertinoPredictiveBackAnimation( backHandler = component.backHandler, - fallbackAnimation = stackAnimation(fade() + scale(frontFactor = 0.75f, backFactor = 0.95f)), onBack = component::onBackClicked, ), ) { child -> From 24e30d3d27637903d928e0c46e90cb05e2691ed4 Mon Sep 17 00:00:00 2001 From: yet Date: Sat, 16 May 2026 20:08:05 +0400 Subject: [PATCH 6/6] Bump version to 1.4.6 (versionCode 10) Co-Authored-By: Claude Opus 4.7 --- fastlane/metadata/android/en-US/changelogs/10.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/10.txt diff --git a/fastlane/metadata/android/en-US/changelogs/10.txt b/fastlane/metadata/android/en-US/changelogs/10.txt new file mode 100644 index 0000000..99c040b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/10.txt @@ -0,0 +1 @@ +Stability sprint! 🩹 Fixed a sneaky bug where pieces refused to drop on valid cells right after a line clear, squashed a crash on the iOS back-swipe gesture, and stopped the high-score from sliding back when continuing a saved game. Smoother drags, fewer dropped frames, and tighter iOS sound. Back to the puzzles! 🧩✨ diff --git a/gradle.properties b/gradle.properties index bf51369..d71b2d1 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.0 -appVersionCode=9 \ No newline at end of file +appVersionName=1.4.6 +appVersionCode=10 \ No newline at end of file diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 8f95183..360c98e 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=9 -MARKETING_VERSION=1.4.0 \ No newline at end of file +CURRENT_PROJECT_VERSION=10 +MARKETING_VERSION=1.4.6 \ No newline at end of file