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/game/DragDropState.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/game/DragDropState.kt index 6a08037..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 @@ -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 @@ -96,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 4c0fff8..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,10 +33,7 @@ fun DraggedPieceOverlay( cellSize: Dp, gap: Dp, verticalLift: Dp, - dragPositionX: Float, - dragPositionY: Float, - fingerOffsetX: Float, - fingerOffsetY: Float, + dragDropState: DragDropState, modifier: Modifier = Modifier, ) { val shadowCells = remember(piece) { @@ -46,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 - fingerOffsetX - ghostW / 2f).toInt(), - y = (dragPositionY - fingerOffsetY - 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 7272a6d..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)) } } @@ -414,10 +415,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, - fingerOffsetX = dragDrop.fingerOffset.x, - fingerOffsetY = dragDrop.fingerOffset.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 6598963..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 @@ -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,8 +133,14 @@ 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( @@ -151,11 +149,10 @@ fun GameGrid( ), label = "predictAlpha", ) + val predictAlpha: () -> Float = remember(predictAlphaState) { { predictAlphaState.value } } - // 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( + val hoverPulseAlphaState = hoverPulse.animateFloat( initialValue = 0.55f, targetValue = 1f, animationSpec = infiniteRepeatable( @@ -164,115 +161,148 @@ fun GameGrid( ), label = "hoverPulseAlpha", ) + val hoverPulseAlpha: () -> Float = remember(hoverPulseAlphaState) { { hoverPulseAlphaState.value } } - var prevGrid by remember { mutableStateOf(grid) } - var prevNonce by remember { mutableIntStateOf(clearedEvent.nonce) } - - LaunchedEffect(grid, clearedEvent) { - prevGrid = grid - prevNonce = clearedEvent.nonce - } - - val saturation = animateFloatAsState( + val saturation by animateFloatAsState( targetValue = if (isGameOver) 0.2f else 1f, animationSpec = tween(400) - ).value - val colorMatrix = ColorMatrix().apply { setToSaturation(saturation) } + ) + val colorFilter = remember(saturation) { + ColorFilter.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) - } + // 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 != prevNonce && clearedEvent.cells.any { it.x == x && it.y == y }) { - isClearing = true - cellAnim.clear(delayMs = (x + y) * 30L) - isClearing = false - displayColor = -1 - cellAnim.reset() - } - } + 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) + }, + ) +} 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/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 -> 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..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 @@ -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 @@ -43,9 +44,16 @@ 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 + // 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 +88,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 +114,7 @@ internal class NativePlatformSoundPlayer( } override fun stopMusic() { + musicGeneration++ musicJob?.cancel() musicJob = null musicPlayer?.stop() @@ -116,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 } /** 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 2f5254c..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,18 @@ 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 + if (!state.isGameOver && state.currentPieces.isNotEmpty()) { _events.tryEmit(GameEvent.GameStarted) } 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( 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