From 0880b7cabf8790d480b4940ceb677883035e94b9 Mon Sep 17 00:00:00 2001 From: yet Date: Tue, 19 May 2026 18:40:00 +0400 Subject: [PATCH 1/7] Slide tray survivors to fill placed slot via Decompose components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine emits a compacted currentPieces list, so before this change the surviving piece would jump left into the placed slot in a single frame, and because the entrance animation was keyed on the joined ids of all three pieces every placement also re-played the fly-in for the survivors. Move tray ownership into a PieceTrayComponent/TraySlotComponent pair (per-piece, modelled on the Decompose cards sample). The parent reconciles each engine emission by keeping the same slot instance for any pieceId still alive, so survivors retain their UI animation state across placements. In the Composable, LookaheadScope + animateBounds turns each list shift into a fixed-distance leftward slide of one slot width, and the entrance Animatable keys on pieceId so only fresh pieces animate in. Selection state moves from a local Compose var in GameContent into the tray component (Value — wrapped because Decompose Value requires a non-null type argument). GameContent now reads the selected piece from the component and calls clearSelection() after tap-place, revive, and restart. Co-Authored-By: Claude Opus 4.7 --- .../yet3/blokblast/screen/game/GameContent.kt | 27 +- .../yet3/blokblast/screen/game/PieceTray.kt | 492 +++++++++--------- .../feature/game/DefaultGameComponent.kt | 8 + .../blockblast/feature/game/GameComponent.kt | 3 + .../game/tray/DefaultPieceTrayComponent.kt | 103 ++++ .../game/tray/DefaultTraySlotComponent.kt | 28 + .../feature/game/tray/PieceTrayComponent.kt | 35 ++ .../feature/game/tray/TraySlotComponent.kt | 30 ++ .../tray/DefaultPieceTrayComponentTest.kt | 93 ++++ .../feature/root/DefaultRootComponentTest.kt | 9 + 10 files changed, 572 insertions(+), 256 deletions(-) create mode 100644 feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponent.kt create mode 100644 feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultTraySlotComponent.kt create mode 100644 feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/PieceTrayComponent.kt create mode 100644 feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/TraySlotComponent.kt create mode 100644 feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponentTest.kt 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 d4efcde..808ef3b 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 @@ -104,7 +104,8 @@ private val DRAG_GHOST_VERTICAL_LIFT = 28.dp fun GameContent(component: GameComponent) { val uiModel by component.model.subscribeAsState() val model = uiModel.game - var selectedPieceId by remember { mutableStateOf(null) } + val traySelection by component.pieceTray.selection.subscribeAsState() + val selectedPiece = traySelection.piece // ── Effect states ──────────────────────────────────────────────────── val dragDrop = rememberDragDropState() @@ -304,12 +305,12 @@ fun GameContent(component: GameComponent) { GameGrid( grid = model.grid, - selectedPiece = model.currentPieces.firstOrNull { it.pieceId == selectedPieceId }, + selectedPiece = selectedPiece, onCellTapped = { x, y -> - val id = selectedPieceId - if (id != null) { - component.onCellClicked(id, x, y) - selectedPieceId = null + val piece = selectedPiece + if (piece != null) { + component.onCellClicked(piece.pieceId, x, y) + component.pieceTray.clearSelection() } }, modifier = Modifier @@ -339,21 +340,13 @@ fun GameContent(component: GameComponent) { Spacer(Modifier.height(24.dp)) PieceTray( - pieces = model.currentPieces, - selectedPieceId = selectedPieceId, - grid = model.grid, + tray = component.pieceTray, modifier = Modifier .widthIn(max = 500.dp) .padding(bottom = 8.dp) .onGloballyPositioned { trayBounds = it.boundsInRoot() }, - onPieceSelected = { id -> - if (!dragDrop.isDragging) { - selectedPieceId = if (selectedPieceId == id) null else id - } - }, onDragStart = { piece, startPos, offset -> if (!dragDrop.isDragging) { - selectedPieceId = null dragDrop.startDrag(piece, startPos, offset) haptic.vibrateIf(vibrationEnabled, HapticFeedbackType.LongPress) } @@ -456,14 +449,14 @@ fun GameContent(component: GameComponent) { canRevive = model.revivesUsed < 1, continueCountdownSeconds = continueCountdown, onReviveClicked = { - selectedPieceId = null + component.pieceTray.clearSelection() // Show interstitial; revive fires only after it's dismissed. interstitial.show { component.onReviveClicked() } }, onRestartClicked = { - selectedPieceId = null + component.pieceTray.clearSelection() component.onRestartClicked() }, onExitClicked = component::onExitClicked, 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 555ca41..133858c 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 @@ -1,5 +1,7 @@ package ge.yet3.blokblast.screen.game +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing @@ -15,20 +17,24 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,319 +48,332 @@ import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times -import ge.yet.blokblast.domain.model.Grid +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import ge.yet.blockblast.feature.game.tray.PieceTrayComponent +import ge.yet.blockblast.feature.game.tray.TraySlotComponent import ge.yet.blokblast.domain.model.Piece import ge.yet.blokblast.domain.model.Polyomino import ge.yet3.blokblast.theme.pieceColor +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +private typealias DragStart = (piece: Piece, startPosition: Offset, pieceOriginOffset: Offset) -> Unit +private typealias DragMove = (position: Offset) -> Unit +private typealias DragEnd = () -> Unit + +private const val SLOT_COUNT = 3 + /** * Bottom tray showing up to three selectable/draggable pieces. + * + * Slot identity is owned by [PieceTrayComponent] (keyed on `pieceId`), so + * placing a piece keeps every survivor's component alive while `animateBounds` + * slides the right-hand neighbours leftward to fill the freed slot. The + * entrance Animatable is keyed on `pieceId` too, so it fires only for + * newly-arrived pieces. */ +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun PieceTray( - pieces: List, - selectedPieceId: Long?, - onPieceSelected: (Long) -> Unit, + tray: PieceTrayComponent, modifier: Modifier = Modifier, - grid: Grid? = null, - onDragStart: ((piece: Piece, startPosition: Offset, pieceOriginOffset: Offset) -> Unit)? = null, - onDragMove: ((position: Offset) -> Unit)? = null, - onDragEnd: (() -> Unit)? = null, + onDragStart: DragStart? = null, + onDragMove: DragMove? = null, + onDragEnd: DragEnd? = null, ) { - Row( + val slots by tray.slots.subscribeAsState() + + BoxWithConstraints( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(20.dp)) .background(MaterialTheme.colorScheme.surface) .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, ) { - val trayKey = remember(pieces) { pieces.map { it.pieceId }.joinToString() } - - repeat(3) { index -> - val piece = pieces.getOrNull(index) - val isSelected = piece != null && piece.pieceId == selectedPieceId + // Each slot is exactly 1/3 of the tray, regardless of how many are + // present — combined with Arrangement.Start this turns "neighbour + // placed" into a fixed-distance leftward slide instead of a reflow. + val slotWidth: Dp = maxWidth / SLOT_COUNT - // Spring-overshoot entrance, staggered per slot. - // Slot 0 flies in from the left, slot 2 from the right, slot 1 - // from below — a "slot-merge" entrance that reads as 3 distinct - // pieces converging instead of one synchronized lift. - val entrance = remember(trayKey, index) { Animatable(0f) } - val (initialX, initialY) = when (index) { - 0 -> -160f to 30f - 2 -> 160f to 30f - else -> 0f to 80f - } - val translateX = remember(trayKey, index) { Animatable(initialX) } - val translateY = remember(trayKey, index) { Animatable(initialY) } - androidx.compose.runtime.LaunchedEffect(trayKey, index) { - kotlinx.coroutines.delay(index * 80L) - kotlinx.coroutines.coroutineScope { - launch { - entrance.animateTo( - 1f, - spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = 380f), + LookaheadScope { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + slots.forEach { slot -> + key(slot.piece.pieceId) { + TraySlot( + slot = slot, + onDragStart = { piece, startPos, originOffset -> + tray.clearSelection() + onDragStart?.invoke(piece, startPos, originOffset) + }, + onDragMove = onDragMove, + onDragEnd = onDragEnd, + modifier = Modifier + .width(slotWidth) + .animateBounds(this@LookaheadScope), ) } - launch { - translateX.animateTo(0f, spring(dampingRatio = 0.6f, stiffness = 320f)) - } - launch { - translateY.animateTo(0f, spring(dampingRatio = 0.55f, stiffness = 380f)) - } } } - - // Can this piece fit anywhere on the board? Drives dim-when-no-fit. - val canFit = remember(piece, grid) { - if (piece == null || grid == null) true - else canPlaceAnywhere(piece.shape, grid) - } - - Box( - modifier = Modifier - .weight(1f) - .graphicsLayer { - scaleX = entrance.value - scaleY = entrance.value - alpha = entrance.value - translationX = translateX.value - translationY = translateY.value - }, - contentAlignment = Alignment.Center, - ) { - TraySlot( - piece = piece, - isSelected = isSelected, - canFit = canFit, - onTap = { if (piece != null) onPieceSelected(piece.pieceId) }, - onDragStart = onDragStart, - onDragMove = onDragMove, - onDragEnd = onDragEnd, - modifier = Modifier.fillMaxWidth(), - ) - } } } } @Composable private fun TraySlot( - piece: Piece?, - isSelected: Boolean, - canFit: Boolean, - onTap: () -> Unit, - onDragStart: ((piece: Piece, startPosition: Offset, pieceOriginOffset: Offset) -> Unit)?, - onDragMove: ((position: Offset) -> Unit)?, - onDragEnd: (() -> Unit)?, + slot: TraySlotComponent, + onDragStart: DragStart?, + onDragMove: DragMove?, + onDragEnd: DragEnd?, modifier: Modifier = Modifier, ) { + val piece = slot.piece + val isSelected by slot.isSelected.subscribeAsState() + val canFit by slot.canFit.subscribeAsState() + + val entrance = rememberSlotEntrance(piece.pieceId, slot.spawnIndex) + val ambient = rememberAmbientLoops() + var isPressed by remember { mutableStateOf(false) } val isHighlighted = isSelected || isPressed - // Idle breathing & no-fit wiggle. Both are continuous animations and we - // *must not* read their values in composition scope — doing so makes the - // entire TraySlot recompose every frame (~60×/s × 3 slots). Instead we - // hold onto the State objects and read .value inside the - // graphicsLayer lambda below, which only invalidates the draw layer. - val breathing = rememberInfiniteTransition(label = "breath") - val breathScaleState = breathing.animateFloat( - initialValue = 1f, - targetValue = 1.04f, - animationSpec = infiniteRepeatable( - animation = tween(1400, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "breathScale", - ) - val wiggle = rememberInfiniteTransition(label = "wiggle") - val wiggleAngleState = wiggle.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(2400, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "wiggleAngle", - ) - - // Discrete-only target — flips between fixed states (pressed / selected / - // can-fit / no-fit), so the spring only runs on transitions, not every - // frame. Breathing is layered on top inside graphicsLayer. val targetScale = when { isPressed -> 1.08f isSelected -> 1.12f canFit -> 1f else -> 0.92f } - val scaleState = animateFloatAsState( - targetValue = targetScale, - animationSpec = spring(), - label = "pieceScale", - ) + val pieceScale = animateFloatAsState(targetScale, animationSpec = spring(), label = "pieceScale") val applyBreath = canFit && !isPressed && !isSelected - val applyWiggle = !canFit && piece != null + val applyWiggle = !canFit - // 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( + val pieceAlpha = animateFloatAsState( targetValue = if (canFit) 1f else 0.45f, animationSpec = tween(220), label = "pieceAlpha", ) - val pColor = piece?.let { pieceColor(it.colorId) } - - // 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 - }, + val pColor = pieceColor(piece.colorId) + val slotBg = animateColorAsState( + targetValue = if (isHighlighted) pColor.copy(alpha = 0.18f) + else MaterialTheme.colorScheme.surfaceVariant, animationSpec = tween(120), label = "slotBg", ) - - val borderColor = when { - isHighlighted && pColor != null -> pColor - else -> Color.Transparent - } - - 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) + val borderColor = if (isHighlighted) pColor else Color.Transparent Box( modifier = modifier .padding(6.dp) .aspectRatio(1f) - // Animated transforms read their State objects HERE (draw phase), - // not in composition scope, so frame ticks don't recompose this - // composable. + // First layer: entrance fly-in (keyed on pieceId, fires once per + // fresh piece). Second layer: idle/press transforms that read + // animated values inside the layer lambda so frame ticks don't + // recompose us — only the draw layer invalidates. + .graphicsLayer { + scaleX = entrance.scale.value + scaleY = entrance.scale.value + alpha = entrance.scale.value + translationX = entrance.translateX.value + translationY = entrance.translateY.value + } .graphicsLayer { - val s = scaleState.value - val breath = if (applyBreath) breathScaleState.value else 1f + val s = pieceScale.value + val breath = if (applyBreath) ambient.breathScale.value else 1f val combined = s * breath scaleX = combined scaleY = combined - rotationZ = if (applyWiggle) { - val a = wiggleAngleState.value - if (a < 0.05f) { - kotlin.math.sin(a / 0.05f * kotlin.math.PI.toFloat() * 4f) * 5f - } else 0f - } else 0f + rotationZ = if (applyWiggle) wiggleAngle(ambient.wigglePhase.value) else 0f } .clip(RoundedCornerShape(14.dp)) - .drawBehind { drawRect(slotBgState.value) } + .drawBehind { drawRect(slotBg.value) } .then( - if (isHighlighted) Modifier.border( - width = 2.dp, - color = borderColor, - shape = RoundedCornerShape(14.dp), - ) else Modifier, + if (isHighlighted) Modifier.border(2.dp, borderColor, RoundedCornerShape(14.dp)) + else Modifier, ) - .onGloballyPositioned { coords -> - slotOriginInWindow = coords.positionInWindow() - } - .then( - if (piece != null) { - Modifier.pointerInput(piece.pieceId) { - awaitPointerEventScope { - while (true) { - // Wait for finger down - val down = awaitPointerEvent() - if (down.type != PointerEventType.Press) continue - val downChange = down.changes.firstOrNull() ?: continue + .traySlotPointerInput( + piece = piece, + onPressedChange = { isPressed = it }, + onTap = slot::onTap, + onDragStart = onDragStart, + onDragMove = onDragMove, + onDragEnd = onDragEnd, + ), + contentAlignment = Alignment.Center, + ) { + val visibleColor = if (isHighlighted) pColor else pColor.copy(alpha = 0.6f) + Box(modifier = Modifier.graphicsLayer { alpha = pieceAlpha.value }) { + MiniPiece( + shape = piece.shape, + color = visibleColor, + shimmerKey = piece.pieceId, + ) + } + } +} - isPressed = true - val downPos = downChange.position - var dragging = false - var totalDrag = Offset.Zero +/* ────────────────────────────── Animation helpers ─────────────────────────── */ - // Track move / up - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull() ?: break +private class SlotEntrance( + val scale: Animatable, + val translateX: Animatable, + val translateY: Animatable, +) - if (event.type == PointerEventType.Move) { - val delta = change.position - downPos - totalDrag = delta +/** + * Spring-overshoot entrance, staggered by [spawnIndex]: slot 0 flies in from + * the left, slot 2 from the right, slot 1 from below. Keyed on [pieceId] so + * survivors of a partial placement keep their already-settled state. + */ +@Composable +private fun rememberSlotEntrance(pieceId: Long, spawnIndex: Int): SlotEntrance { + val (initialX, initialY) = when (spawnIndex) { + 0 -> -160f to 30f + 2 -> 160f to 30f + else -> 0f to 80f + } + val scale = remember(pieceId) { Animatable(0f) } + val translateX = remember(pieceId) { Animatable(initialX) } + val translateY = remember(pieceId) { Animatable(initialY) } + LaunchedEffect(pieceId) { + delay(spawnIndex * 80L) + launch { + scale.animateTo( + 1f, + spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = 380f), + ) + } + launch { translateX.animateTo(0f, spring(dampingRatio = 0.6f, stiffness = 320f)) } + launch { translateY.animateTo(0f, spring(dampingRatio = 0.55f, stiffness = 380f)) } + } + return remember(pieceId) { SlotEntrance(scale, translateX, translateY) } +} - if (!dragging && delta.getDistance() > touchSlop) { - dragging = true - val startInWindow = slotOriginInWindow + downPos - currentOnDragStart?.invoke(piece, startInWindow, downPos) - } +private class AmbientLoops( + val breathScale: androidx.compose.runtime.State, + val wigglePhase: androidx.compose.runtime.State, +) - if (dragging) { - change.consume() - val posInWindow = slotOriginInWindow + change.position - currentOnDragMove?.invoke(posInWindow) - } - } +/** + * Continuous breathing + wiggle phases. Returned as `State` (not `Float`) + * so callers must read them inside a draw-phase lambda — reading in composition + * scope would recompose the whole slot 60×/s. + */ +@Composable +private fun rememberAmbientLoops(): AmbientLoops { + val breath = rememberInfiniteTransition(label = "breath").animateFloat( + initialValue = 1f, + targetValue = 1.04f, + animationSpec = infiniteRepeatable( + tween(1400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "breathScale", + ) + val wiggle = rememberInfiniteTransition(label = "wiggle").animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + tween(2400, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "wigglePhase", + ) + return remember { AmbientLoops(breath, wiggle) } +} - if (event.type == PointerEventType.Release) { - isPressed = false - if (dragging) { - currentOnDragEnd?.invoke() - } else { - // It was a tap — toggle selection - currentOnTap() - } - break - } - } +/** Short bursts of rotation at the start of each wiggle cycle. */ +private fun wiggleAngle(phase: Float): Float = + if (phase < 0.05f) { + kotlin.math.sin(phase / 0.05f * kotlin.math.PI.toFloat() * 4f) * 5f + } else { + 0f + } + +/* ──────────────────────────────── Pointer input ───────────────────────────── */ + +/** + * Single-finger tap + long-press-drag handler. Drag starts after the pointer + * travels past `touchSlop`; a release without crossing slop is a tap. + */ +@Composable +private fun Modifier.traySlotPointerInput( + piece: Piece, + onPressedChange: (Boolean) -> Unit, + onTap: () -> Unit, + onDragStart: DragStart?, + onDragMove: DragMove?, + onDragEnd: DragEnd?, +): Modifier { + var slotOriginInWindow by remember { mutableStateOf(Offset.Zero) } + val touchSlop = LocalViewConfiguration.current.touchSlop + + val onDragStartLatest by rememberUpdatedState(onDragStart) + val onDragMoveLatest by rememberUpdatedState(onDragMove) + val onDragEndLatest by rememberUpdatedState(onDragEnd) + val onTapLatest by rememberUpdatedState(onTap) + val onPressedChangeLatest by rememberUpdatedState(onPressedChange) + + return this + .onGloballyPositioned { coords -> slotOriginInWindow = coords.positionInWindow() } + .pointerInput(piece.pieceId) { + awaitPointerEventScope { + while (true) { + val downEvent = awaitPointerEvent() + if (downEvent.type != PointerEventType.Press) continue + val downChange = downEvent.changes.firstOrNull() ?: continue + + onPressedChangeLatest(true) + val downPos = downChange.position + var dragging = false + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: break - // If the pointer was cancelled - if (isPressed) { - isPressed = false - if (dragging) currentOnDragEnd?.invoke() + when (event.type) { + PointerEventType.Move -> { + val delta = change.position - downPos + if (!dragging && delta.getDistance() > touchSlop) { + dragging = true + onDragStartLatest?.invoke( + piece, + slotOriginInWindow + downPos, + downPos, + ) } + if (dragging) { + change.consume() + onDragMoveLatest?.invoke(slotOriginInWindow + change.position) + } + } + PointerEventType.Release -> { + onPressedChangeLatest(false) + if (dragging) onDragEndLatest?.invoke() else onTapLatest() + break } } } - } else Modifier, - ), - contentAlignment = Alignment.Center, - ) { - if (piece != null) { - val baseColor = pieceColor(piece.colorId) - 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, - ) + + // Defensive: cancel paths skip the Release branch. + onPressedChangeLatest(false) + if (dragging) onDragEndLatest?.invoke() + } } } - } } -private fun canPlaceAnywhere(shape: Polyomino, grid: Grid): Boolean { - for (y in 0 until Grid.SIZE) { - for (x in 0 until Grid.SIZE) { - if (canPlacePiece(shape, x, y, grid)) return true - } - } - return false -} +/* ────────────────────────────── Piece rendering ───────────────────────────── */ /** * Renders a polyomino shape as tiny 3D-like [BlockPiece] cells. @@ -377,21 +396,16 @@ private fun MiniPiece( val totalH = rows * cellSize + (rows - 1) * gap val shimmer = remember(shimmerKey) { Animatable(-0.4f) } - androidx.compose.runtime.LaunchedEffect(shimmerKey) { - kotlinx.coroutines.delay(180) + LaunchedEffect(shimmerKey) { + delay(180) shimmer.snapTo(-0.4f) - shimmer.animateTo( - targetValue = 1.4f, - animationSpec = tween(650, easing = LinearEasing), - ) + shimmer.animateTo(1.4f, tween(650, easing = LinearEasing)) } Box( modifier = Modifier .size(totalW, totalH) - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - } + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } .drawWithContent { drawContent() val p = shimmer.value diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt index e23837e..b04e592 100644 --- a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponent.kt @@ -4,6 +4,7 @@ import com.app.common.config.AppConfig import com.app.common.decompose.asValue import com.app.common.decompose.coroutineScope import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.childContext import com.arkivanov.decompose.router.slot.ChildSlot import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate @@ -18,6 +19,8 @@ import dev.zacsweers.metro.Inject import ge.yet.blockblast.feature.game.integration.stateToModel import ge.yet.blockblast.feature.game.reviewprompt.DefaultReviewPromptComponent import ge.yet.blockblast.feature.game.store.GameAnalyticsLogger +import ge.yet.blockblast.feature.game.tray.DefaultPieceTrayComponent +import ge.yet.blockblast.feature.game.tray.PieceTrayComponent import ge.yet.blockblast.feature.game.store.GameStore import ge.yet.blockblast.feature.game.store.GameStoreFactory import ge.yet.blockblast.feature.settings.SettingsComponent @@ -47,6 +50,11 @@ internal class DefaultGameComponent( override val model: Value = store.asValue().map(stateToModel) + override val pieceTray: PieceTrayComponent = DefaultPieceTrayComponent( + componentContext = childContext(key = "PieceTray"), + state = store.asValue().map { it.game }, + ) + override val sheetSlot: Value> = childSlot( source = sheetNavigation, diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt index 2b43692..f3b05ad 100644 --- a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/GameComponent.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.slot.ChildSlot import com.arkivanov.decompose.value.Value import ge.yet.blockblast.feature.game.reviewprompt.ReviewPromptComponent +import ge.yet.blockblast.feature.game.tray.PieceTrayComponent import ge.yet.blockblast.feature.settings.SettingsComponent import ge.yet.blokblast.domain.model.GameState @@ -20,6 +21,8 @@ interface GameComponent { val sheetSlot: Value> + val pieceTray: PieceTrayComponent + data class Model( val game: GameState, val continueCountdown: Int, diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponent.kt new file mode 100644 index 0000000..8bcf1fa --- /dev/null +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponent.kt @@ -0,0 +1,103 @@ +package ge.yet.blockblast.feature.game.tray + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.lifecycle.doOnDestroy +import ge.yet.blokblast.domain.model.GameState +import ge.yet.blokblast.domain.model.Grid +import ge.yet.blokblast.domain.model.Piece +import ge.yet.blokblast.domain.model.Polyomino + +/** + * Reconciles engine emissions of `currentPieces` (already compacted) onto a + * variable-length list of slot components. Identity is keyed by [Piece.pieceId] + * — when the engine re-emits with a piece still alive, the same + * [DefaultTraySlotComponent] instance is reused, preserving its per-slot UI + * animation state across reorderings. + */ +internal class DefaultPieceTrayComponent( + componentContext: ComponentContext, + state: Value, +) : PieceTrayComponent, ComponentContext by componentContext { + + private val slotsState = MutableValue>(emptyList()) + override val slots: Value> = slotsState + + private val selectionState = MutableValue(TraySelection.NONE) + override val selection: Value = selectionState + + init { + val cancellation = state.subscribe { reconcile(it.currentPieces, it.grid) } + lifecycle.doOnDestroy { cancellation.cancel() } + } + + override fun clearSelection() { + if (selectionState.value != TraySelection.NONE) selectionState.value = TraySelection.NONE + } + + private fun toggleSelection(pieceId: Long) { + val currentlySelected = selectionState.value.piece?.pieceId + selectionState.value = when (currentlySelected) { + pieceId -> TraySelection.NONE + else -> { + val piece = slotsState.value.firstOrNull { it.piece.pieceId == pieceId }?.piece + if (piece != null) TraySelection(piece) else TraySelection.NONE + } + } + } + + private fun reconcile(currentPieces: List, grid: Grid) { + // Re-use existing slot components keyed by pieceId. Survivors keep + // their instance (and UI animation state); placed pieces drop out. + @Suppress("UNCHECKED_CAST") + val existing: Map = + (slotsState.value as List) + .associateBy { it.piece.pieceId } + + val nextSlots = currentPieces.mapIndexed { index, piece -> + existing[piece.pieceId] ?: newSlot(piece, spawnIndex = index) + } + + // Refresh canFit on every survivor — the grid can have changed under + // a stationary piece (line clear) since the last emission. + nextSlots.forEach { it.updateCanFit(canPlaceAnywhere(it.piece.shape, grid)) } + + // Drop a now-invalid selection (selected piece was placed, or a full + // refill swapped it out from under the user). + val livePieceIds = currentPieces.mapTo(HashSet(currentPieces.size)) { it.pieceId } + val selectedPieceId = selectionState.value.piece?.pieceId + if (selectedPieceId != null && selectedPieceId !in livePieceIds) { + selectionState.value = TraySelection.NONE + } + + slotsState.value = nextSlots + } + + private fun newSlot(piece: Piece, spawnIndex: Int): DefaultTraySlotComponent = + DefaultTraySlotComponent( + piece = piece, + spawnIndex = spawnIndex, + selection = selectionState, + onToggleSelection = ::toggleSelection, + ) +} + +/** True if [shape] has at least one valid placement anywhere on [grid]. */ +private fun canPlaceAnywhere(shape: Polyomino, grid: Grid): Boolean { + for (y in 0 until Grid.SIZE) { + for (x in 0 until Grid.SIZE) { + if (canPlaceAt(shape, x, y, grid)) return true + } + } + return false +} + +private fun canPlaceAt(shape: Polyomino, x: Int, y: Int, grid: Grid): Boolean { + for (cell in shape.cells) { + val gx = x + cell.x + val gy = y + cell.y + if (!grid.inBounds(gx, gy) || !grid.isEmpty(gx, gy)) return false + } + return true +} diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultTraySlotComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultTraySlotComponent.kt new file mode 100644 index 0000000..84b0453 --- /dev/null +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/DefaultTraySlotComponent.kt @@ -0,0 +1,28 @@ +package ge.yet.blockblast.feature.game.tray + +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.operator.map +import ge.yet.blokblast.domain.model.Piece + +internal class DefaultTraySlotComponent( + override val piece: Piece, + override val spawnIndex: Int, + selection: Value, + private val onToggleSelection: (Long) -> Unit, +) : TraySlotComponent { + + private val canFitState = MutableValue(true) + override val canFit: Value = canFitState + + override val isSelected: Value = + selection.map { it.piece?.pieceId == piece.pieceId } + + override fun onTap() { + onToggleSelection(piece.pieceId) + } + + fun updateCanFit(value: Boolean) { + if (canFitState.value != value) canFitState.value = value + } +} diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/PieceTrayComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/PieceTrayComponent.kt new file mode 100644 index 0000000..a559d11 --- /dev/null +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/PieceTrayComponent.kt @@ -0,0 +1,35 @@ +package ge.yet.blockblast.feature.game.tray + +import com.arkivanov.decompose.value.Value +import ge.yet.blokblast.domain.model.Piece + +/** + * Bottom-of-screen tray that holds up to three pieces. Mirrors the engine's + * compacted `currentPieces` list while keeping the per-piece component + * instance stable across emissions (keyed by [Piece.pieceId]), so the UI can + * animate position changes without resetting per-slot animation state. + */ +interface PieceTrayComponent { + /** + * Compacted list of 0..3 slots — same order and length as the engine's + * `currentPieces`. Slot identity is keyed by `pieceId`, so when a piece is + * placed its [TraySlotComponent] is dropped from the list while survivors + * retain their existing instances at their new (shifted) indices. + */ + val slots: Value> + + /** + * Wrapped because [Value] forbids nullable type arguments — see + * [TraySelection.piece] for the contained piece, if any. + */ + val selection: Value + + fun clearSelection() +} + +/** Non-null wrapper around an optional tray selection. */ +data class TraySelection(val piece: Piece? = null) { + companion object { + val NONE = TraySelection() + } +} diff --git a/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/TraySlotComponent.kt b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/TraySlotComponent.kt new file mode 100644 index 0000000..a611791 --- /dev/null +++ b/feature/game/src/commonMain/kotlin/ge/yet/blockblast/feature/game/tray/TraySlotComponent.kt @@ -0,0 +1,30 @@ +package ge.yet.blockblast.feature.game.tray + +import com.arkivanov.decompose.value.Value +import ge.yet.blokblast.domain.model.Piece + +/** + * One piece in the tray. Identity (`==`) is stable across engine emissions as + * long as the same [Piece.pieceId] is still in play — placing the piece drops + * the component, while merely reordering the tray (e.g. neighbours placed) + * keeps this instance alive so its UI-side animation state survives. + */ +interface TraySlotComponent { + val piece: Piece + + /** + * Index this slot held at the moment it was created — used by the + * entrance animation to pick a fly-in direction (left/right/bottom). Stays + * fixed for the lifetime of the slot; the *current* index can shift if + * neighbours are placed. + */ + val spawnIndex: Int + + val isSelected: Value + + /** Whether [piece] can fit anywhere on the current grid. */ + val canFit: Value + + /** Toggle selection of this slot's piece. */ + fun onTap() +} diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponentTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponentTest.kt new file mode 100644 index 0000000..2000e39 --- /dev/null +++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/tray/DefaultPieceTrayComponentTest.kt @@ -0,0 +1,93 @@ +package ge.yet.blockblast.feature.game.tray + +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.resume +import ge.yet.blokblast.domain.model.GameState +import ge.yet.blokblast.domain.model.Grid +import ge.yet.blokblast.domain.model.Piece +import ge.yet.blokblast.domain.model.Polyomino +import ge.yet.blokblast.domain.model.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame + +class DefaultPieceTrayComponentTest { + + private val dot = Polyomino(id = "dot", cells = listOf(Position(0, 0))) + private fun piece(id: Long) = Piece(pieceId = id, shape = dot, colorId = 0) + + private fun build(initial: List): Pair> { + val state = MutableValue(GameState(grid = Grid(), currentPieces = initial)) + val lifecycle = LifecycleRegistry().apply { resume() } + val ctx = DefaultComponentContext(lifecycle) + val component = DefaultPieceTrayComponent(ctx, state) + return component to state + } + + @Test + fun fresh_tray_emits_pieces_in_engine_order() { + val (component, _) = build(listOf(piece(1), piece(2), piece(3))) + val slots = component.slots.value + assertEquals(listOf(1L, 2L, 3L), slots.map { it.piece.pieceId }) + assertEquals(listOf(0, 1, 2), slots.map { it.spawnIndex }) + } + + @Test + fun placing_middle_piece_keeps_survivor_component_instances() { + val (component, state) = build(listOf(piece(1), piece(2), piece(3))) + val originalA = component.slots.value[0] + val originalC = component.slots.value[2] + + // Engine compacts: [1, 2, 3] → place 2 → [1, 3] + state.value = state.value.copy(currentPieces = listOf(piece(1), piece(3))) + + val slots = component.slots.value + assertEquals(2, slots.size) + assertSame(originalA, slots[0]) + assertSame(originalC, slots[1]) // C shifted from index 2 → 1, instance preserved + } + + @Test + fun full_refill_creates_new_slot_instances() { + val (component, state) = build(listOf(piece(1), piece(2), piece(3))) + val before = component.slots.value.toList() + + state.value = state.value.copy(currentPieces = emptyList()) + assertEquals(emptyList(), component.slots.value) + + state.value = state.value.copy(currentPieces = listOf(piece(10), piece(20), piece(30))) + val after = component.slots.value + assertEquals(listOf(10L, 20L, 30L), after.map { it.piece.pieceId }) + before.zip(after).forEach { (b, a) -> assertNotSame(b, a) } + } + + @Test + fun tap_toggles_selection_and_clearSelection_resets() { + val (component, _) = build(listOf(piece(1), piece(2), piece(3))) + val slot1 = component.slots.value[1] + slot1.onTap() + assertEquals(2L, component.selection.value.piece?.pieceId) + assertEquals(true, slot1.isSelected.value) + + slot1.onTap() + assertNull(component.selection.value.piece) + + slot1.onTap() + component.clearSelection() + assertNull(component.selection.value.piece) + } + + @Test + fun placing_selected_piece_clears_selection() { + val (component, state) = build(listOf(piece(1), piece(2), piece(3))) + component.slots.value[1].onTap() + assertEquals(2L, component.selection.value.piece?.pieceId) + + state.value = state.value.copy(currentPieces = listOf(piece(1), piece(3))) + assertNull(component.selection.value.piece) + } +} diff --git a/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt b/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt index dff5b46..5824849 100644 --- a/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt +++ b/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt @@ -161,6 +161,15 @@ class DefaultRootComponentTest { override val sheetSlot = com.arkivanov.decompose.value.MutableValue( com.arkivanov.decompose.router.slot.ChildSlot(child = null), ) + override val pieceTray: ge.yet.blockblast.feature.game.tray.PieceTrayComponent = + object : ge.yet.blockblast.feature.game.tray.PieceTrayComponent { + override val slots = com.arkivanov.decompose.value.MutableValue( + emptyList(), + ) + override val selection = + com.arkivanov.decompose.value.MutableValue(ge.yet.blockblast.feature.game.tray.TraySelection.NONE) + override fun clearSelection() {} + } override fun onCellClicked(pieceId: Long, x: Int, y: Int) {} override fun onReviveClicked() {} override fun onRestartClicked() {} From c77c855306fe2c8b1aac26b4450c9fc8ddc65cc1 Mon Sep 17 00:00:00 2001 From: yet Date: Tue, 19 May 2026 19:31:12 +0400 Subject: [PATCH 2/7] Split sound toggle into Music and SFX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single soundEnabled flag previously gated background music, placement SFX, line-clear SFX, and voice lines together — so a player who wanted quiet background music with placement clicks (or vice versa) had no way to express that. The Settings screen had one row for "Sound" and that was the only audio knob in the app. Replace the single flag with two independent ones on SettingsRepository: musicEnabled (gates DefaultAudioRepository's music combine) and sfxEnabled (gates the placement / clear / voice helpers). Add a one-time migration in SettingsBackedSettingsRepository that seeds both new keys from the legacy blockblast.sound value and removes the legacy key, so existing muted users stay muted on upgrade. Surface a second toggle row in MainSettingsContent so both flags are user-controllable. Update every test stub and the SettingsStore / SettingsStoreFactory / Mapper plumbing to carry both fields through. Co-Authored-By: Claude Opus 4.7 --- .../composeResources/values/strings.xml | 4 ++ .../kotlin/ge/yet3/blokblast/screen/App.kt | 2 +- .../settings/content/MainSettingsContent.kt | 24 +++++++--- .../data/repository/DefaultAudioRepository.kt | 20 ++++---- .../SettingsBackedSettingsRepository.kt | 39 +++++++++++++-- .../repository/DefaultAudioRepositoryTest.kt | 48 ++++++++++++++----- .../DefaultVibrationRepositoryTest.kt | 6 ++- .../SettingsBackedSettingsRepositoryTest.kt | 41 ++++++++++++++-- .../domain/repository/SettingsRepository.kt | 10 +++- .../feature/game/DefaultGameComponentTest.kt | 6 ++- .../game/store/GameStoreFactoryTest.kt | 6 ++- .../feature/home/DefaultHomeComponentTest.kt | 6 ++- .../home/store/HomeStoreFactoryTest.kt | 6 ++- .../feature/root/DefaultRootComponent.kt | 2 +- .../blockblast/feature/root/RootComponent.kt | 4 +- .../feature/root/DefaultRootComponentTest.kt | 15 +++--- .../main/DefaultMainSettingsComponent.kt | 8 +++- .../settings/main/MainSettingsComponent.kt | 6 ++- .../settings/main/integration/Mappers.kt | 3 +- .../settings/main/store/SettingsStore.kt | 10 ++-- .../main/store/SettingsStoreFactory.kt | 23 +++++---- .../main/DefaultMainSettingsComponentTest.kt | 30 ++++++++---- .../settings/main/integration/MappersTest.kt | 10 ++-- .../main/store/SettingsStoreFactoryTest.kt | 46 ++++++++++++------ 24 files changed, 273 insertions(+), 102 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 43700d1..f93f994 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -26,6 +26,10 @@ Settings Sound Game sound effects + Music + Background music + Sound effects + Placement, clear, and voice lines Vibration Haptic feedback on placement Dark theme diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/App.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/App.kt index 45e3cd8..5a97520 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/App.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/App.kt @@ -18,7 +18,7 @@ fun App(rootComponent: RootComponent) { val darkTheme by rootComponent.darkTheme.collectAsState() BlockBlastTheme(darkTheme = darkTheme) { val vibrationEnabled by rootComponent.vibrationEnabled.collectAsState() - val soundEnabled by rootComponent.soundEnabled.collectAsState() + val soundEnabled by rootComponent.sfxEnabled.collectAsState() val tutorialSeen by rootComponent.tutorialSeen.collectAsState() val onTutorialSeen = remember(rootComponent) { { rootComponent.onTutorialSeen() } } CompositionLocalProvider( diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt index 34fc048..15fd4b0 100644 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/screen/settings/content/MainSettingsContent.kt @@ -16,9 +16,11 @@ import blockblast.composeapp.generated.resources.dark_theme import blockblast.composeapp.generated.resources.dark_theme_subtitle import blockblast.composeapp.generated.resources.more import blockblast.composeapp.generated.resources.more_subtitle +import blockblast.composeapp.generated.resources.music +import blockblast.composeapp.generated.resources.music_subtitle import blockblast.composeapp.generated.resources.settings -import blockblast.composeapp.generated.resources.sound -import blockblast.composeapp.generated.resources.sound_subtitle +import blockblast.composeapp.generated.resources.sfx +import blockblast.composeapp.generated.resources.sfx_subtitle import blockblast.composeapp.generated.resources.vibration import blockblast.composeapp.generated.resources.vibration_subtitle import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -51,10 +53,20 @@ fun MainSettingsContent(component: MainSettingsComponent) { SettingsToggleRow( icon = NotificationsActive, - title = stringResource(Res.string.sound), - subtitle = stringResource(Res.string.sound_subtitle), - checked = model.soundEnabled, - onCheckedChange = component::onSoundToggled, + title = stringResource(Res.string.music), + subtitle = stringResource(Res.string.music_subtitle), + checked = model.musicEnabled, + onCheckedChange = component::onMusicToggled, + ) + + SettingsDivider() + + SettingsToggleRow( + icon = NotificationsActive, + title = stringResource(Res.string.sfx), + subtitle = stringResource(Res.string.sfx_subtitle), + checked = model.sfxEnabled, + onCheckedChange = component::onSfxToggled, ) SettingsDivider() diff --git a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepository.kt b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepository.kt index beece2a..8dd176a 100644 --- a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepository.kt +++ b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepository.kt @@ -15,8 +15,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch /** - * Guards every SFX call with the live `soundEnabled` flag, then delegates to the - * platform bridge. + * Guards every SFX/voice call with [SettingsRepository.sfxEnabled] and gates + * music separately on [SettingsRepository.musicEnabled]. * * Music lifecycle is driven by: * - [musicRequested]: a flow set true by [startMusic] (game session active) @@ -24,7 +24,7 @@ import kotlinx.coroutines.launch * - [appForeground]: a flow set false on [onAppBackground] and true on * [onAppForeground]. Backgrounding the app silences music without * forgetting that a session is active. - * - [SettingsRepository.soundEnabled]: user preference. + * - [SettingsRepository.musicEnabled]: user preference (music-only). * * Music plays iff *all three* are true. A single coroutine collects the * combine of those flows and serializes start/stop calls to the platform @@ -70,7 +70,7 @@ internal class DefaultAudioRepository( combine( musicRequested, appForeground, - settings.soundEnabled, + settings.musicEnabled, ) { requested, foreground, enabled -> requested && foreground && enabled } // distinctUntilChanged is critical: combine() re-emits whenever // any upstream emits, even if the boolean output didn't change. @@ -90,19 +90,19 @@ internal class DefaultAudioRepository( } } - private inline fun ifEnabled(block: () -> Unit) { - if (settings.soundEnabled.value) block() + private inline fun ifSfxEnabled(block: () -> Unit) { + if (settings.sfxEnabled.value) block() } - override suspend fun playPlacementSound() = ifEnabled { player.playPlacement() } + override suspend fun playPlacementSound() = ifSfxEnabled { player.playPlacement() } - override suspend fun playClearSound(lines: Int) = ifEnabled { player.playClear(lines) } + override suspend fun playClearSound(lines: Int) = ifSfxEnabled { player.playClear(lines) } override suspend fun playVoiceFeedback(type: FeedbackType) = - ifEnabled { player.playVoiceFeedback(type) } + ifSfxEnabled { player.playVoiceFeedback(type) } override suspend fun playVoiceCombo(combo: Int) = - ifEnabled { player.playVoiceCombo(combo) } + ifSfxEnabled { player.playVoiceCombo(combo) } override suspend fun startMusic() { musicRequested.value = true diff --git a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt index 3af3d51..f5151d2 100644 --- a/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt +++ b/core/data/src/commonMain/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepository.kt @@ -42,8 +42,18 @@ internal class SettingsBackedSettingsRepository( private val writeMutex = Mutex() - override val soundEnabled: StateFlow = - settings.getBooleanStateFlow(scope, KEY_SOUND, defaultValue = true) + init { + // 1.5.0 migration: prior versions had a single KEY_SOUND_LEGACY flag + // that gated both music and SFX. Seed each new key from it once so an + // existing "muted" user stays muted on the first launch after upgrade. + migrateLegacySoundFlag() + } + + override val musicEnabled: StateFlow = + settings.getBooleanStateFlow(scope, KEY_MUSIC, defaultValue = true) + + override val sfxEnabled: StateFlow = + settings.getBooleanStateFlow(scope, KEY_SFX, defaultValue = true) override val vibrationEnabled: StateFlow = settings.getBooleanStateFlow(scope, KEY_VIBRATION, defaultValue = true) @@ -60,8 +70,12 @@ internal class SettingsBackedSettingsRepository( override val tutorialSeen: StateFlow = settings.getBooleanStateFlow(scope, KEY_TUTORIAL_SEEN, defaultValue = false) - override suspend fun setSoundEnabled(enabled: Boolean) = withContext(dispatchers.io) { - settings.putBoolean(KEY_SOUND, enabled) + override suspend fun setMusicEnabled(enabled: Boolean) = withContext(dispatchers.io) { + settings.putBoolean(KEY_MUSIC, enabled) + } + + override suspend fun setSfxEnabled(enabled: Boolean) = withContext(dispatchers.io) { + settings.putBoolean(KEY_SFX, enabled) } override suspend fun setVibrationEnabled(enabled: Boolean) = withContext(dispatchers.io) { @@ -100,8 +114,23 @@ internal class SettingsBackedSettingsRepository( settings.putBoolean(KEY_TUTORIAL_SEEN, true) } + /** + * If a legacy single-flag value is present and neither new key has been + * written, copy the legacy value into both. Idempotent: the legacy key is + * removed afterwards so this runs at most once per device. + */ + private fun migrateLegacySoundFlag() { + if (!settings.hasKey(KEY_SOUND_LEGACY)) return + val legacy = settings.getBoolean(KEY_SOUND_LEGACY, true) + if (!settings.hasKey(KEY_MUSIC)) settings.putBoolean(KEY_MUSIC, legacy) + if (!settings.hasKey(KEY_SFX)) settings.putBoolean(KEY_SFX, legacy) + settings.remove(KEY_SOUND_LEGACY) + } + private companion object { - const val KEY_SOUND = "blockblast.sound" + const val KEY_MUSIC = "blockblast.music" + const val KEY_SFX = "blockblast.sfx" + const val KEY_SOUND_LEGACY = "blockblast.sound" const val KEY_VIBRATION = "blockblast.vibration" const val KEY_DARK = "blockblast.dark_theme" const val KEY_BEST_SCORE = "blockblast.best_score" diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt index 624925c..8c99b21 100644 --- a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt +++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultAudioRepositoryTest.kt @@ -23,10 +23,11 @@ class DefaultAudioRepositoryTest { * transitions, so we snapshot and discard that initial emission. */ private fun setup( - sound: Boolean = true, + music: Boolean = true, + sfx: Boolean = true, ): Triple { val player = RecordingPlayer() - val settings = FakeSettings(soundEnabled = sound) + val settings = FakeSettings(musicEnabled = music, sfxEnabled = sfx) val scope = CoroutineScope(UnconfinedTestDispatcher()) val repo = DefaultAudioRepository(player, settings, scope) player.calls.clear() @@ -43,12 +44,19 @@ class DefaultAudioRepositoryTest { } @Test - fun startMusic_does_not_start_when_sound_off() = runTest { - val (repo, player) = setup(sound = false) + fun startMusic_does_not_start_when_music_off() = runTest { + val (repo, player) = setup(music = false) repo.startMusic() assertTrue(player.calls.isEmpty()) } + @Test + fun startMusic_starts_when_music_on_even_if_sfx_off() = runTest { + val (repo, player) = setup(music = true, sfx = false) + repo.startMusic() + assertEquals(listOf(PlayerCall.Start), player.calls) + } + @Test fun stopMusic_stops_active_music() = runTest { val (repo, player) = setup() @@ -79,17 +87,27 @@ class DefaultAudioRepositoryTest { } @Test - fun toggling_sound_off_then_on_during_music_stops_then_starts() = runTest { + fun toggling_music_off_then_on_during_music_stops_then_starts() = runTest { val (repo, player, settings) = setup() repo.startMusic() - settings.soundFlow.value = false - settings.soundFlow.value = true + settings.musicFlow.value = false + settings.musicFlow.value = true assertEquals( listOf(PlayerCall.Start, PlayerCall.Stop, PlayerCall.Start), player.calls, ) } + @Test + fun toggling_sfx_does_not_affect_music_playback() = runTest { + val (repo, player, settings) = setup() + repo.startMusic() + val baseline = player.calls.toList() + settings.sfxFlow.value = false + settings.sfxFlow.value = true + assertEquals(baseline, player.calls) + } + @Test fun stopMusic_while_backgrounded_does_not_emit_extra_stop() = runTest { val (repo, player) = setup() @@ -118,7 +136,7 @@ class DefaultAudioRepositoryTest { @Test fun sfx_silent_when_disabled() = runTest { - val (repo, player) = setup(sound = false) + val (repo, player) = setup(sfx = false) repo.playPlacementSound() repo.playClearSound(2) repo.playVoiceFeedback(FeedbackType.GOOD) @@ -148,15 +166,21 @@ class DefaultAudioRepositoryTest { override fun release() {} } - private class FakeSettings(soundEnabled: Boolean = true) : SettingsRepository { - val soundFlow = MutableStateFlow(soundEnabled) - override val soundEnabled: StateFlow = soundFlow.asStateFlow() + private class FakeSettings( + musicEnabled: Boolean = true, + sfxEnabled: Boolean = true, + ) : SettingsRepository { + val musicFlow = MutableStateFlow(musicEnabled) + val sfxFlow = MutableStateFlow(sfxEnabled) + override val musicEnabled: StateFlow = musicFlow.asStateFlow() + override val sfxEnabled: StateFlow = sfxFlow.asStateFlow() override val vibrationEnabled = MutableStateFlow(true).asStateFlow() override val darkTheme = MutableStateFlow(false).asStateFlow() override val bestScore = MutableStateFlow(0L).asStateFlow() override val reviewPromptCount = MutableStateFlow(0).asStateFlow() override val tutorialSeen = MutableStateFlow(false).asStateFlow() - override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled } + override suspend fun setMusicEnabled(enabled: Boolean) { musicFlow.value = enabled } + override suspend fun setSfxEnabled(enabled: Boolean) { sfxFlow.value = enabled } override suspend fun setVibrationEnabled(enabled: Boolean) {} override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) {} diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt index b3b0f78..785c69c 100644 --- a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt +++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/DefaultVibrationRepositoryTest.kt @@ -63,13 +63,15 @@ class DefaultVibrationRepositoryTest { private class FakeSettings(vibration: Boolean) : SettingsRepository { val vibrationFlow = MutableStateFlow(vibration) - override val soundEnabled = MutableStateFlow(true).asStateFlow() + override val musicEnabled = MutableStateFlow(true).asStateFlow() + override val sfxEnabled = MutableStateFlow(true).asStateFlow() override val vibrationEnabled: StateFlow = vibrationFlow.asStateFlow() override val darkTheme = MutableStateFlow(false).asStateFlow() override val bestScore = MutableStateFlow(0L).asStateFlow() override val reviewPromptCount = MutableStateFlow(0).asStateFlow() override val tutorialSeen = MutableStateFlow(false).asStateFlow() - override suspend fun setSoundEnabled(enabled: Boolean) {} + override suspend fun setMusicEnabled(enabled: Boolean) {} + override suspend fun setSfxEnabled(enabled: Boolean) {} override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled } override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) {} diff --git a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt index 0c7bcd3..7dff266 100644 --- a/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt +++ b/core/data/src/commonTest/kotlin/ge/yet/blokblast/data/repository/SettingsBackedSettingsRepositoryTest.kt @@ -37,7 +37,8 @@ class SettingsBackedSettingsRepositoryTest { @Test fun defaults() { - assertTrue(repo.soundEnabled.value) + assertTrue(repo.musicEnabled.value) + assertTrue(repo.sfxEnabled.value) assertTrue(repo.vibrationEnabled.value) assertFalse(repo.darkTheme.value) assertEquals(0L, repo.bestScore.value) @@ -46,9 +47,41 @@ class SettingsBackedSettingsRepositoryTest { } @Test - fun setSoundEnabled_updates_flow() = runTest { - repo.setSoundEnabled(false) - assertFalse(repo.soundEnabled.value) + fun setMusicEnabled_updates_flow_without_touching_sfx() = runTest { + repo.setMusicEnabled(false) + assertFalse(repo.musicEnabled.value) + assertTrue(repo.sfxEnabled.value) + } + + @Test + fun setSfxEnabled_updates_flow_without_touching_music() = runTest { + repo.setSfxEnabled(false) + assertFalse(repo.sfxEnabled.value) + assertTrue(repo.musicEnabled.value) + } + + @Test + fun migrates_legacy_sound_flag_into_both_keys() = runTest { + // Simulate an upgrade from a pre-1.5 install with sound = false. + val legacySettings = MapSettings().apply { putBoolean("blockblast.sound", false) } + val migrated = SettingsBackedSettingsRepository( + settings = legacySettings, + scope = scope, + dispatchers = AppDispatchers(default = Dispatchers.Unconfined, io = Dispatchers.Unconfined), + ) + assertFalse(migrated.musicEnabled.value) + assertFalse(migrated.sfxEnabled.value) + } + + @Test + fun migration_runs_only_once() = runTest { + val sharedSettings = MapSettings().apply { putBoolean("blockblast.sound", false) } + SettingsBackedSettingsRepository(sharedSettings, scope, AppDispatchers(Dispatchers.Unconfined, Dispatchers.Unconfined)) + // User re-enables music explicitly after migration. + sharedSettings.putBoolean("blockblast.music", true) + // Second construction (e.g. process restart) must not overwrite that. + val again = SettingsBackedSettingsRepository(sharedSettings, scope, AppDispatchers(Dispatchers.Unconfined, Dispatchers.Unconfined)) + assertTrue(again.musicEnabled.value) } @Test diff --git a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt index e622dd8..c0e56b1 100644 --- a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt +++ b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/SettingsRepository.kt @@ -3,7 +3,12 @@ package ge.yet.blokblast.domain.repository import kotlinx.coroutines.flow.StateFlow interface SettingsRepository { - val soundEnabled: StateFlow + /** Background music gate. Independent of [sfxEnabled] since v1.5.0. */ + val musicEnabled: StateFlow + + /** SFX + voice-line gate (piece placement, line clear, combo voice). */ + val sfxEnabled: StateFlow + val vibrationEnabled: StateFlow val darkTheme: StateFlow @@ -16,7 +21,8 @@ interface SettingsRepository { /** Whether the user has seen (or dismissed) the first-launch tutorial. */ val tutorialSeen: StateFlow - suspend fun setSoundEnabled(enabled: Boolean) + suspend fun setMusicEnabled(enabled: Boolean) + suspend fun setSfxEnabled(enabled: Boolean) suspend fun setVibrationEnabled(enabled: Boolean) suspend fun setDarkTheme(enabled: Boolean) diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt index 4b95256..79433bd 100644 --- a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt +++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/DefaultGameComponentTest.kt @@ -226,13 +226,15 @@ class DefaultGameComponentTest { private class FakeSettings(bestScore: Long = 0L, reviewPromptCount: Int = 0) : SettingsRepository { private val bestScoreFlow = MutableStateFlow(bestScore) private val reviewFlow = MutableStateFlow(reviewPromptCount) - override val soundEnabled = MutableStateFlow(true).asStateFlow() + override val musicEnabled = MutableStateFlow(true).asStateFlow() + override val sfxEnabled = MutableStateFlow(true).asStateFlow() override val vibrationEnabled = MutableStateFlow(true).asStateFlow() override val darkTheme = MutableStateFlow(false).asStateFlow() override val bestScore: StateFlow = bestScoreFlow.asStateFlow() override val reviewPromptCount: StateFlow = reviewFlow.asStateFlow() override val tutorialSeen = MutableStateFlow(false).asStateFlow() - override suspend fun setSoundEnabled(enabled: Boolean) {} + override suspend fun setMusicEnabled(enabled: Boolean) {} + override suspend fun setSfxEnabled(enabled: Boolean) {} override suspend fun setVibrationEnabled(enabled: Boolean) {} override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) { diff --git a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt index 7251fe7..84d1009 100644 --- a/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt +++ b/feature/game/src/commonTest/kotlin/ge/yet/blockblast/feature/game/store/GameStoreFactoryTest.kt @@ -456,13 +456,15 @@ private class FakeSettings( ) : SettingsRepository { private val bestScoreFlow = MutableStateFlow(bestScore) private val reviewFlow = MutableStateFlow(reviewPromptCount) - override val soundEnabled = MutableStateFlow(true).asStateFlow() + override val musicEnabled = MutableStateFlow(true).asStateFlow() + override val sfxEnabled = MutableStateFlow(true).asStateFlow() override val vibrationEnabled = MutableStateFlow(true).asStateFlow() override val darkTheme = MutableStateFlow(false).asStateFlow() override val bestScore: StateFlow = bestScoreFlow.asStateFlow() override val reviewPromptCount: StateFlow = reviewFlow.asStateFlow() override val tutorialSeen = MutableStateFlow(false).asStateFlow() - override suspend fun setSoundEnabled(enabled: Boolean) {} + override suspend fun setMusicEnabled(enabled: Boolean) {} + override suspend fun setSfxEnabled(enabled: Boolean) {} override suspend fun setVibrationEnabled(enabled: Boolean) {} override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) { if (score > bestScoreFlow.value) bestScoreFlow.value = score } diff --git a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt index 708c692..724c3d8 100644 --- a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt +++ b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/DefaultHomeComponentTest.kt @@ -131,13 +131,15 @@ class DefaultHomeComponentTest { } private class StubSettings(bestScore: Long) : SettingsRepository { - override val soundEnabled = MutableStateFlow(true).asStateFlow() + override val musicEnabled = MutableStateFlow(true).asStateFlow() + override val sfxEnabled = MutableStateFlow(true).asStateFlow() override val vibrationEnabled = MutableStateFlow(true).asStateFlow() override val darkTheme = MutableStateFlow(false).asStateFlow() override val bestScore = MutableStateFlow(bestScore).asStateFlow() override val reviewPromptCount = MutableStateFlow(0).asStateFlow() override val tutorialSeen = MutableStateFlow(false).asStateFlow() - override suspend fun setSoundEnabled(enabled: Boolean) {} + override suspend fun setMusicEnabled(enabled: Boolean) {} + override suspend fun setSfxEnabled(enabled: Boolean) {} override suspend fun setVibrationEnabled(enabled: Boolean) {} override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) {} diff --git a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt index 6201c8b..42dfa0e 100644 --- a/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt +++ b/feature/home/src/commonTest/kotlin/ge/yet/blockblast/feature/home/store/HomeStoreFactoryTest.kt @@ -125,13 +125,15 @@ class HomeStoreFactoryTest { } private class StubSettings(bestScore: Long) : SettingsRepository { - override val soundEnabled = MutableStateFlow(true).asStateFlow() + override val musicEnabled = MutableStateFlow(true).asStateFlow() + override val sfxEnabled = MutableStateFlow(true).asStateFlow() override val vibrationEnabled = MutableStateFlow(true).asStateFlow() override val darkTheme = MutableStateFlow(false).asStateFlow() override val bestScore = MutableStateFlow(bestScore).asStateFlow() override val reviewPromptCount = MutableStateFlow(0).asStateFlow() override val tutorialSeen = MutableStateFlow(false).asStateFlow() - override suspend fun setSoundEnabled(enabled: Boolean) {} + override suspend fun setMusicEnabled(enabled: Boolean) {} + override suspend fun setSfxEnabled(enabled: Boolean) {} override suspend fun setVibrationEnabled(enabled: Boolean) {} override suspend fun setDarkTheme(enabled: Boolean) {} override suspend fun setBestScore(score: Long) {} diff --git a/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponent.kt b/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponent.kt index 7550b64..aff674a 100644 --- a/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponent.kt +++ b/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponent.kt @@ -40,7 +40,7 @@ internal class DefaultRootComponent( override val darkTheme: StateFlow = settingsRepository.darkTheme override val vibrationEnabled: StateFlow = settingsRepository.vibrationEnabled - override val soundEnabled: StateFlow = settingsRepository.soundEnabled + override val sfxEnabled: StateFlow = settingsRepository.sfxEnabled override val tutorialSeen: StateFlow = settingsRepository.tutorialSeen override fun onTutorialSeen() { diff --git a/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/RootComponent.kt b/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/RootComponent.kt index 49b1b75..4ac3b85 100644 --- a/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/RootComponent.kt +++ b/feature/root/src/commonMain/kotlin/ge/yet/blockblast/feature/root/RootComponent.kt @@ -24,8 +24,8 @@ interface RootComponent : BackHandlerOwner { /** Whether haptic feedback is enabled (mirrors Settings toggle). */ val vibrationEnabled: StateFlow - /** Whether sound effects are enabled (mirrors Settings toggle). */ - val soundEnabled: StateFlow + /** Whether SFX / voice feedback are enabled (mirrors Settings toggle). */ + val sfxEnabled: StateFlow /** Whether the first-launch tutorial has already been seen / dismissed. */ val tutorialSeen: StateFlow diff --git a/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt b/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt index 5824849..9988a2d 100644 --- a/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt +++ b/feature/root/src/commonTest/kotlin/ge/yet/blockblast/feature/root/DefaultRootComponentTest.kt @@ -44,13 +44,13 @@ class DefaultRootComponentTest { } @Test - fun darkTheme_vibration_sound_tutorial_flows_mirror_settings() { + fun darkTheme_vibration_sfx_tutorial_flows_mirror_settings() { val (component, _, _, settings, _, _) = build() assertFalse(component.darkTheme.value) settings.darkFlow.value = true assertTrue(component.darkTheme.value) - settings.soundFlow.value = false - assertFalse(component.soundEnabled.value) + settings.sfxFlow.value = false + assertFalse(component.sfxEnabled.value) settings.vibrationFlow.value = false assertFalse(component.vibrationEnabled.value) settings.tutorialFlow.value = true @@ -192,17 +192,20 @@ class DefaultRootComponentTest { } private class FakeSettings : SettingsRepository { - val soundFlow = MutableStateFlow(true) + val musicFlow = MutableStateFlow(true) + val sfxFlow = MutableStateFlow(true) val vibrationFlow = MutableStateFlow(true) val darkFlow = MutableStateFlow(false) val tutorialFlow = MutableStateFlow(false) - override val soundEnabled = soundFlow.asStateFlow() + override val musicEnabled = musicFlow.asStateFlow() + override val sfxEnabled = sfxFlow.asStateFlow() override val vibrationEnabled = vibrationFlow.asStateFlow() override val darkTheme = darkFlow.asStateFlow() override val tutorialSeen = tutorialFlow.asStateFlow() override val bestScore = MutableStateFlow(0L).asStateFlow() override val reviewPromptCount = MutableStateFlow(0).asStateFlow() - override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled } + override suspend fun setMusicEnabled(enabled: Boolean) { musicFlow.value = enabled } + override suspend fun setSfxEnabled(enabled: Boolean) { sfxFlow.value = enabled } override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled } override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled } override suspend fun setBestScore(score: Long) {} diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt index 11f3193..9d4391e 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponent.kt @@ -17,8 +17,12 @@ internal class DefaultMainSettingsComponent( override val model: Value = store.asValue().map(stateToModel) - override fun onSoundToggled(enabled: Boolean) { - store.accept(SettingsStore.Intent.SetSound(enabled)) + override fun onMusicToggled(enabled: Boolean) { + store.accept(SettingsStore.Intent.SetMusic(enabled)) + } + + override fun onSfxToggled(enabled: Boolean) { + store.accept(SettingsStore.Intent.SetSfx(enabled)) } override fun onVibrationToggled(enabled: Boolean) { diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt index c4aa6de..0defab7 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/MainSettingsComponent.kt @@ -6,14 +6,16 @@ interface MainSettingsComponent { val model: Value - fun onSoundToggled(enabled: Boolean) + fun onMusicToggled(enabled: Boolean) + fun onSfxToggled(enabled: Boolean) fun onVibrationToggled(enabled: Boolean) fun onDarkThemeToggled(enabled: Boolean) fun onMoreClicked() fun onBackClicked() data class Model( - val soundEnabled: Boolean, + val musicEnabled: Boolean, + val sfxEnabled: Boolean, val vibrationEnabled: Boolean, val darkTheme: Boolean, ) diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt index cc902a3..510746f 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/integration/Mappers.kt @@ -6,7 +6,8 @@ import ge.yet.blockblast.feature.settings.main.store.SettingsStore internal val stateToModel: (SettingsStore.State) -> MainSettingsComponent.Model = { state -> MainSettingsComponent.Model( - soundEnabled = state.sound, + musicEnabled = state.music, + sfxEnabled = state.sfx, vibrationEnabled = state.vibration, darkTheme = state.dark, ) diff --git a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt index c5e7b07..ec4a1b0 100644 --- a/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt +++ b/feature/settings/src/commonMain/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStore.kt @@ -6,13 +6,16 @@ internal interface SettingsStore : Store by storeFactory.create( name = "SettingsStore", initialState = SettingsStore.State( - sound = settingsRepository.soundEnabled.value, + music = settingsRepository.musicEnabled.value, + sfx = settingsRepository.sfxEnabled.value, vibration = settingsRepository.vibrationEnabled.value, dark = settingsRepository.darkTheme.value, ), @@ -32,16 +33,21 @@ internal class SettingsStoreFactory( onAction { launch { combine( - settingsRepository.soundEnabled, + settingsRepository.musicEnabled, + settingsRepository.sfxEnabled, settingsRepository.vibrationEnabled, settingsRepository.darkTheme, - ) { s, v, d -> SettingsStore.Msg.Snapshot(s, v, d) } + ) { m, s, v, d -> SettingsStore.Msg.Snapshot(m, s, v, d) } .collect { dispatch(it) } } } - onIntent { intent -> - logSettingChanged(setting = "sound", enabled = intent.enabled) - launch { settingsRepository.setSoundEnabled(intent.enabled) } + onIntent { intent -> + logSettingChanged(setting = "music", enabled = intent.enabled) + launch { settingsRepository.setMusicEnabled(intent.enabled) } + } + onIntent { intent -> + logSettingChanged(setting = "sfx", enabled = intent.enabled) + launch { settingsRepository.setSfxEnabled(intent.enabled) } } onIntent { intent -> logSettingChanged(setting = "vibration", enabled = intent.enabled) @@ -69,9 +75,10 @@ internal class SettingsStoreFactory( override fun SettingsStore.State.reduce(msg: SettingsStore.Msg): SettingsStore.State = when (msg) { is SettingsStore.Msg.Snapshot -> copy( - sound = msg.sound, + music = msg.music, + sfx = msg.sfx, vibration = msg.vibration, - dark = msg.dark + dark = msg.dark, ) } } diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt index 218670f..4ef74e0 100644 --- a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt +++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/DefaultMainSettingsComponentTest.kt @@ -52,17 +52,28 @@ class DefaultMainSettingsComponentTest { @Test fun model_reflects_initial_state() = runTest { val (component, _, _) = build() - assertTrue(component.model.value.soundEnabled) + assertTrue(component.model.value.musicEnabled) + assertTrue(component.model.value.sfxEnabled) assertTrue(component.model.value.vibrationEnabled) assertFalse(component.model.value.darkTheme) } @Test - fun onSoundToggled_propagates_to_repository_and_model() = runTest { + fun onMusicToggled_propagates_to_repository_and_model() = runTest { val (component, settings, _) = build() - component.onSoundToggled(false) - assertFalse(settings.soundFlow.value) - assertFalse(component.model.value.soundEnabled) + component.onMusicToggled(false) + assertFalse(settings.musicFlow.value) + assertFalse(component.model.value.musicEnabled) + assertTrue(component.model.value.sfxEnabled) + } + + @Test + fun onSfxToggled_propagates_to_repository_and_model() = runTest { + val (component, settings, _) = build() + component.onSfxToggled(false) + assertFalse(settings.sfxFlow.value) + assertFalse(component.model.value.sfxEnabled) + assertTrue(component.model.value.musicEnabled) } @Test @@ -94,16 +105,19 @@ class DefaultMainSettingsComponentTest { } private class FakeSettings : SettingsRepository { - val soundFlow = MutableStateFlow(true) + val musicFlow = MutableStateFlow(true) + val sfxFlow = MutableStateFlow(true) val vibrationFlow = MutableStateFlow(true) val darkFlow = MutableStateFlow(false) - override val soundEnabled: StateFlow = soundFlow.asStateFlow() + override val musicEnabled: StateFlow = musicFlow.asStateFlow() + override val sfxEnabled: StateFlow = sfxFlow.asStateFlow() override val vibrationEnabled: StateFlow = vibrationFlow.asStateFlow() override val darkTheme: StateFlow = darkFlow.asStateFlow() override val bestScore = MutableStateFlow(0L).asStateFlow() override val reviewPromptCount = MutableStateFlow(0).asStateFlow() override val tutorialSeen = MutableStateFlow(false).asStateFlow() - override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled } + override suspend fun setMusicEnabled(enabled: Boolean) { musicFlow.value = enabled } + override suspend fun setSfxEnabled(enabled: Boolean) { sfxFlow.value = enabled } override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled } override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled } override suspend fun setBestScore(score: Long) {} diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt index ed10ffd..910fe92 100644 --- a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt +++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/integration/MappersTest.kt @@ -7,11 +7,12 @@ import kotlin.test.assertEquals class MappersTest { @Test - fun maps_three_flags_through() { + fun maps_all_flags_through() { val model = stateToModel( - SettingsStore.State(sound = false, vibration = true, dark = true), + SettingsStore.State(music = false, sfx = true, vibration = true, dark = true), ) - assertEquals(false, model.soundEnabled) + assertEquals(false, model.musicEnabled) + assertEquals(true, model.sfxEnabled) assertEquals(true, model.vibrationEnabled) assertEquals(true, model.darkTheme) } @@ -19,7 +20,8 @@ class MappersTest { @Test fun maps_default_state() { val model = stateToModel(SettingsStore.State()) - assertEquals(true, model.soundEnabled) + assertEquals(true, model.musicEnabled) + assertEquals(true, model.sfxEnabled) assertEquals(true, model.vibrationEnabled) assertEquals(false, model.darkTheme) } diff --git a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt index cb25583..f533cfa 100644 --- a/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt +++ b/feature/settings/src/commonTest/kotlin/ge/yet/blockblast/feature/settings/main/store/SettingsStoreFactoryTest.kt @@ -30,11 +30,12 @@ class SettingsStoreFactoryTest { fun tearDown() { Dispatchers.resetMain() } private fun make( - sound: Boolean = true, + music: Boolean = true, + sfx: Boolean = true, vibration: Boolean = true, dark: Boolean = false, ): Triple { - val settings = FakeSettings(sound, vibration, dark) + val settings = FakeSettings(music, sfx, vibration, dark) val analytics = RecordingAnalytics() return Triple( SettingsStoreFactory(DefaultStoreFactory(), settings, analytics), @@ -45,33 +46,44 @@ class SettingsStoreFactoryTest { @Test fun initial_state_mirrors_settings() = runTest { - val (f, _, _) = make(sound = false, vibration = true, dark = true) + val (f, _, _) = make(music = false, sfx = true, vibration = true, dark = true) val store = f.create() - assertFalse(store.state.sound) + assertFalse(store.state.music) + assertTrue(store.state.sfx) assertTrue(store.state.vibration) assertTrue(store.state.dark) } @Test - fun external_settings_change_propagates_to_state() = runTest { + fun external_music_change_propagates_to_state() = runTest { val (f, settings, _) = make() val store = f.create() - settings.soundFlow.value = false - assertFalse(store.state.sound) + settings.musicFlow.value = false + assertFalse(store.state.music) + assertTrue(store.state.sfx) } @Test - fun setSound_writes_and_logs() = runTest { + fun setMusic_writes_and_logs() = runTest { val (f, settings, analytics) = make() val store = f.create() - store.accept(SettingsStore.Intent.SetSound(false)) - assertFalse(settings.soundFlow.value) + store.accept(SettingsStore.Intent.SetMusic(false)) + assertFalse(settings.musicFlow.value) val ev = analytics.events.last() assertEquals("setting_changed", ev.first) - assertEquals("sound", ev.second["setting"]) + assertEquals("music", ev.second["setting"]) assertEquals(false, ev.second["enabled"]) } + @Test + fun setSfx_writes_and_logs() = runTest { + val (f, settings, analytics) = make() + val store = f.create() + store.accept(SettingsStore.Intent.SetSfx(false)) + assertFalse(settings.sfxFlow.value) + assertNotNull(analytics.events.find { it.first == "setting_changed" && it.second["setting"] == "sfx" }) + } + @Test fun setVibration_writes_and_logs() = runTest { val (f, settings, analytics) = make() @@ -93,20 +105,24 @@ class SettingsStoreFactoryTest { // ── Fakes ──────────────────────────────────────────────────────────── private class FakeSettings( - sound: Boolean, + music: Boolean, + sfx: Boolean, vibration: Boolean, dark: Boolean, ) : SettingsRepository { - val soundFlow = MutableStateFlow(sound) + val musicFlow = MutableStateFlow(music) + val sfxFlow = MutableStateFlow(sfx) val vibrationFlow = MutableStateFlow(vibration) val darkFlow = MutableStateFlow(dark) - override val soundEnabled: StateFlow = soundFlow.asStateFlow() + override val musicEnabled: StateFlow = musicFlow.asStateFlow() + override val sfxEnabled: StateFlow = sfxFlow.asStateFlow() override val vibrationEnabled: StateFlow = vibrationFlow.asStateFlow() override val darkTheme: StateFlow = darkFlow.asStateFlow() override val bestScore = MutableStateFlow(0L).asStateFlow() override val reviewPromptCount = MutableStateFlow(0).asStateFlow() override val tutorialSeen = MutableStateFlow(false).asStateFlow() - override suspend fun setSoundEnabled(enabled: Boolean) { soundFlow.value = enabled } + override suspend fun setMusicEnabled(enabled: Boolean) { musicFlow.value = enabled } + override suspend fun setSfxEnabled(enabled: Boolean) { sfxFlow.value = enabled } override suspend fun setVibrationEnabled(enabled: Boolean) { vibrationFlow.value = enabled } override suspend fun setDarkTheme(enabled: Boolean) { darkFlow.value = enabled } override suspend fun setBestScore(score: Long) {} From 4e43fc20bad8e42caf35443eb13828769a63b507 Mon Sep 17 00:00:00 2001 From: yet Date: Tue, 19 May 2026 19:38:11 +0400 Subject: [PATCH 3/7] Add localized string resources for music and sound effect settings - Add `music`, `music_subtitle`, `sfx`, and `sfx_subtitle` keys to localized `strings.xml` files for 36 languages. - Provide translated strings for background music and granular sound effect descriptions (placement, clearing, and voice lines) to support expanded audio configuration options. --- .../src/commonMain/composeResources/values-ar/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-az/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-be/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-bn/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-da/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-de/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-el/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-es/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-fi/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-fr/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-he/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-hi/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-hu/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-hy/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-id/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-it/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-ja/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-ka/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-kk/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-ko/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-ky/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-nb/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-nl/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-pl/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-pt/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-ro/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-ru/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-sv/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-tg/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-th/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-tk/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-tr/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-uk/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-uz/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-vi/strings.xml | 4 ++++ .../src/commonMain/composeResources/values-zh/strings.xml | 4 ++++ 36 files changed, 144 insertions(+) diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index ea7aee5..ac327a7 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -26,6 +26,10 @@ الإعدادات الصوت المؤثرات الصوتية للعبة + موسيقى + موسيقى خلفية + مؤثرات صوتية + الوضع، المسح، والتعليقات الصوتية الاهتزاز ردود فعل لمسية عند الوضع المظهر الداكن diff --git a/composeApp/src/commonMain/composeResources/values-az/strings.xml b/composeApp/src/commonMain/composeResources/values-az/strings.xml index 493c78e..af52d1c 100644 --- a/composeApp/src/commonMain/composeResources/values-az/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-az/strings.xml @@ -26,6 +26,10 @@ Tənzimləmələr Səs Oyunun səs effektləri + Musiqi + Fon musiqisi + Səs effektləri + Yerləşdirmə, təmizləmə və səs xətləri Vibrasiya Yerləşdirmə zamanı haptik rəy Tünd mövzu diff --git a/composeApp/src/commonMain/composeResources/values-be/strings.xml b/composeApp/src/commonMain/composeResources/values-be/strings.xml index 9002ca9..2a6b1e0 100644 --- a/composeApp/src/commonMain/composeResources/values-be/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-be/strings.xml @@ -26,6 +26,10 @@ Налады Гук Гукавыя эфекты гульні + Музыка + Фонавая музыка + Гукавыя эфекты + Размяшчэнне, ачыстка і агучка Вібрацыя Тактыльная аддача пры ўсталёўцы Цёмная тэма diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index 17c2ceb..6c4448b 100644 --- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -26,6 +26,10 @@ সেটিংস শব্দ খেলার শব্দ প্রভাব + সঙ্গীত + আবহ সঙ্গীত + শব্দ প্রভাব + প্লেসমেন্ট, ক্লিয়ার এবং ভয়েস লাইন কম্পন রাখার সময় হ্যাপটিক ফিডব্যাক ডার্ক থিম diff --git a/composeApp/src/commonMain/composeResources/values-da/strings.xml b/composeApp/src/commonMain/composeResources/values-da/strings.xml index 3adb4c2..1873591 100644 --- a/composeApp/src/commonMain/composeResources/values-da/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-da/strings.xml @@ -26,6 +26,10 @@ Indstillinger Lyd Lydeffekter i spillet + Musik + Baggrundsmusik + Lydeffekter + Placering, rydning og stemmelinjer Vibration Haptisk feedback ved placering Mørkt tema diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 2e71f9a..28d8aca 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -26,6 +26,10 @@ Einstellungen Ton Soundeffekte des Spiels + Musik + Hintergrundmusik + Soundeffekte + Platzieren, Löschen und Stimmen Vibration Haptisches Feedback beim Platzieren Dunkles Design diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml index 1cfaf63..9f506d1 100644 --- a/composeApp/src/commonMain/composeResources/values-el/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -26,6 +26,10 @@ Ρυθμίσεις Ήχος Ηχητικά εφέ παιχνιδιού + Μουσική + Μουσική υποβάθρου + Ηχητικά εφέ + Τοποθέτηση, καθαρισμός και φωνητικές γραμμές Δόνηση Απτική ανάδραση κατά την τοποθέτηση Σκούρο θέμα diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index b791613..ccd7337 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -26,6 +26,10 @@ Ajustes Sonido Efectos de sonido del juego + Música + Música de fondo + Efectos de sonido + Colocación, limpieza y líneas de voz Vibración Respuesta háptica al colocar Tema oscuro diff --git a/composeApp/src/commonMain/composeResources/values-fi/strings.xml b/composeApp/src/commonMain/composeResources/values-fi/strings.xml index 28774cd..0c6b3b8 100644 --- a/composeApp/src/commonMain/composeResources/values-fi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fi/strings.xml @@ -26,6 +26,10 @@ Asetukset Ääni Pelin äänitehosteet + Musiikki + Taustamusiikki + Äänitehosteet + Sijoittelu, tyhjennys ja puherivit Värinä Haptinen palaute asetettaessa Tumma teema diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 5c73961..d66d936 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -26,6 +26,10 @@ Paramètres Son Effets sonores du jeu + Musique + Musique de fond + Effets sonores + Placement, effacement et répliques vocales Vibration Retour haptique lors du placement Thème sombre diff --git a/composeApp/src/commonMain/composeResources/values-he/strings.xml b/composeApp/src/commonMain/composeResources/values-he/strings.xml index bfeb97c..360e960 100644 --- a/composeApp/src/commonMain/composeResources/values-he/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-he/strings.xml @@ -26,6 +26,10 @@ הגדרות צליל אפקטים קוליים של המשחק + מוזיקה + מוזיקת רקע + אפקטים קוליים + הנחה, ניקוי ודיבור רטט משוב רטט בעת הנחה ערכת נושא כהה diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index ecff06d..1570e38 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -26,6 +26,10 @@ सेटिंग ध्वनि खेल ध्वनि प्रभाव + संगीत + पृष्ठभूमि संगीत + ध्वनि प्रभाव + प्लेसमेंट, क्लियर और वॉयस लाइन्स कंपन रखने पर हैप्टिक फीडबैक डार्क थीम diff --git a/composeApp/src/commonMain/composeResources/values-hu/strings.xml b/composeApp/src/commonMain/composeResources/values-hu/strings.xml index c777bf8..d88267a 100644 --- a/composeApp/src/commonMain/composeResources/values-hu/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hu/strings.xml @@ -26,6 +26,10 @@ Beállítások Hang Játék hanghatások + Zene + Háttérzene + Hanghatások + Elhelyezés, törlés és hangsorok Rezgés Haptikus visszajelzés elhelyezéskor Sötét téma diff --git a/composeApp/src/commonMain/composeResources/values-hy/strings.xml b/composeApp/src/commonMain/composeResources/values-hy/strings.xml index df27cbf..6c76b93 100644 --- a/composeApp/src/commonMain/composeResources/values-hy/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hy/strings.xml @@ -26,6 +26,10 @@ Կարգավորումներ Ձայն Խաղի ձայնային էֆեկտներ + Երաժշտություն + Ֆոնային երաժշտություն + Ձայնային էֆեկտներ + Տեղադրում, մաքրում և ձայնային տողեր Թրթռում Հպման հետադարձ կապ տեղադրման ժամանակ Մուգ թեմա diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 6228f92..900ad15 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -26,6 +26,10 @@ Pengaturan Suara Efek suara permainan + Musik + Musik latar + Efek suara + Penempatan, pembersihan, dan garis suara Getaran Umpan balik haptik saat menaruh Tema Gelap diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index eebb42b..5c464e4 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -26,6 +26,10 @@ Impostazioni Suono Effetti sonori del gioco + Musica + Musica di sottofondo + Effetti sonori + Posizionamento, cancellazione e linee vocali Vibrazione Feedback aptico al posizionamento Tema scuro diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index 28eaafe..613f944 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -26,6 +26,10 @@ 設定 サウンド ゲームの音響効果 + 音楽 + BGM + 効果音 + 配置、消去、およびボイスライン バイブレーション ブロック配置時の振動 ダークテーマ diff --git a/composeApp/src/commonMain/composeResources/values-ka/strings.xml b/composeApp/src/commonMain/composeResources/values-ka/strings.xml index e21f8d8..35c2372 100644 --- a/composeApp/src/commonMain/composeResources/values-ka/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ka/strings.xml @@ -26,6 +26,10 @@ პარამეტრები ხმა თამაშის ხმოვანი ეფექტები + მუსიკა + ფონური მუსიკა + ხმის ეფექტები + განთავსება, გასუფთავება და ხმოვანი ხაზები ვიბრაცია ვიბრაცია ბლოკის დასმისას მუქი თემა diff --git a/composeApp/src/commonMain/composeResources/values-kk/strings.xml b/composeApp/src/commonMain/composeResources/values-kk/strings.xml index f76d0bf..a1c41b4 100644 --- a/composeApp/src/commonMain/composeResources/values-kk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-kk/strings.xml @@ -26,6 +26,10 @@ Параметрлер Дыбыс Ойынның дыбыстық эффектілері + Музыка + Фондық музыка + Дыбыс әсерлері + Орналастыру, тазалау және дыбыстық жолдар Діріл Орналастыру кезіндегі тактильді кері байланыс Қараңғы тақырып diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index efcb73a..373aee6 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -26,6 +26,10 @@ 설정 소리 게임 음향 효과 + 음악 + 배경 음악 + 사운드 효과 + 배치, 제거 및 음성 대사 진동 블록 배치 시 진동 피드백 다크 테마 diff --git a/composeApp/src/commonMain/composeResources/values-ky/strings.xml b/composeApp/src/commonMain/composeResources/values-ky/strings.xml index 3e938e6..350e020 100644 --- a/composeApp/src/commonMain/composeResources/values-ky/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ky/strings.xml @@ -26,6 +26,10 @@ Орнотуулар Үн Оюндун үн эффекттери + Музыка + Фондук музыка + Үн эффекттери + Жайгаштыруу, тазалоо жана үн линиялары Дирилдөө Жайгаштырууда тактилдик байланыш Караңгы тема diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml index 72edcd7..6b76f1d 100644 --- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml @@ -26,6 +26,10 @@ Innstillinger Lyd Lydeffekter i spillet + Musikk + Bakgrunnsmusikk + Lydeffekter + Plassering, tømming og stemmelinjer Vibrasjon Haptisk tilbakemelding ved plassering Mørkt tema diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index 0137f3a..ec037c2 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -26,6 +26,10 @@ Instellingen Geluid Geluidseffecten van het spel + Muziek + Achtergrondmuziek + Geluidseffecten + Plaatsen, wissen en stemlijnen Trillen Haptische feedback bij plaatsen Donker thema diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index c4a54e5..8b601cf 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -26,6 +26,10 @@ Ustawienia Dźwięk Efekty dźwiękowe gry + Muzyka + Muzyka w tle + Efekty dźwiękowe + Umieszczanie, czyszczenie i linie głosowe Wibracje Haptyczne sprzężenie zwrotne Ciemny motyw diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 8860455..638c6e0 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -26,6 +26,10 @@ Configurações Som Efeitos sonoros do jogo + Música + Música de fundo + Efeitos sonoros + Colocação, limpeza e linhas de voz Vibração Feedback háptico ao colocar Tema escuro diff --git a/composeApp/src/commonMain/composeResources/values-ro/strings.xml b/composeApp/src/commonMain/composeResources/values-ro/strings.xml index cc03355..a97014f 100644 --- a/composeApp/src/commonMain/composeResources/values-ro/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ro/strings.xml @@ -26,6 +26,10 @@ Setări Sunet Efecte sonore ale jocului + Muzică + Muzică de fundal + Efecte sonore + Plasare, ștergere și replici vocale Vibrație Feedback haptic la plasare Temă întunecată diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index b0fe784..a1e40f1 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -26,6 +26,10 @@ Настройки Звук Звуковые эффекты игры + Музыка + Фоновая музыка + Звуковые эффекты + Размещение, очистка и озвучка Вибрация Тактильный отклик при установке Темная тема diff --git a/composeApp/src/commonMain/composeResources/values-sv/strings.xml b/composeApp/src/commonMain/composeResources/values-sv/strings.xml index 8dbde47..7d0a820 100644 --- a/composeApp/src/commonMain/composeResources/values-sv/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-sv/strings.xml @@ -26,6 +26,10 @@ Inställningar Ljud Ljudeffekter i spelet + Musik + Bakgrundsmusik + Ljudeffekter + Placering, rensning och röstlinjer Vibration Haptisk feedback vid placering Mörkt tema diff --git a/composeApp/src/commonMain/composeResources/values-tg/strings.xml b/composeApp/src/commonMain/composeResources/values-tg/strings.xml index a55ac47..8ce4198 100644 --- a/composeApp/src/commonMain/composeResources/values-tg/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tg/strings.xml @@ -26,6 +26,10 @@ Танзимот Овоз Эффектҳои овозии бозӣ + Мусиқӣ + Мусиқии заминавӣ + Эффектҳои савтӣ + Ҷойгиркунӣ, тозакунӣ ва хатҳои овозӣ Ларзиш Алоқаи тактилӣ ҳангоми ҷойгиркунӣ Мавзӯи торик diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index 6e09680..b27db2e 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -26,6 +26,10 @@ ตั้งค่า เสียง เอฟเฟกต์เสียงในเกม + ดนตรี + เพลงประกอบ + เอฟเฟกต์เสียง + การวาง การล้าง และเสียงบรรยาย การสั่น การสั่นตอบสนองเมื่อวางบล็อก ธีมมืด diff --git a/composeApp/src/commonMain/composeResources/values-tk/strings.xml b/composeApp/src/commonMain/composeResources/values-tk/strings.xml index 317e428..11c6f98 100644 --- a/composeApp/src/commonMain/composeResources/values-tk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tk/strings.xml @@ -26,6 +26,10 @@ Sazlamalar Ses Oýnuň ses effektleri + Saz + Fon sazy + Ses effektleri + Ýerleşdirme, arassalama we ses setirleri Wibrasiýa Ýerleşdireniňizde taktil seslenmesi Garaňky tema diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index aa64a03..60df7cf 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -26,6 +26,10 @@ Ayarlar Ses Oyun ses efektleri + Müzik + Arka plan müziği + Ses efektleri + Yerleştirme, temizleme ve ses hatları Titreşim Yerleştirme sırasında dokunsal geri bildirim Koyu Tema diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index c62fac7..3c93fd3 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -26,6 +26,10 @@ Налаштування Звук Звукові ефекти гри + Музика + Фонова музика + Звукові ефекти + Розміщення, очищення та озвучення Вібрація Тактильний відгук при встановленні Темная тема diff --git a/composeApp/src/commonMain/composeResources/values-uz/strings.xml b/composeApp/src/commonMain/composeResources/values-uz/strings.xml index bc7a068..b8a53d9 100644 --- a/composeApp/src/commonMain/composeResources/values-uz/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uz/strings.xml @@ -26,6 +26,10 @@ Sozlamalar Ovoz O'yinning ovoz effektlari + Musiqa + Fon musiqisi + Ovoz effektlari + Joylashtirish, tozalash va ovozli liniyalar Vibratsiya Joylashtirishda tebranishli aloqa Tungi mavzu diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index e477fd9..2fad359 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -26,6 +26,10 @@ Cài đặt Âm thanh Hiệu ứng âm thanh trò chơi + Nhạc + Nhạc nền + Hiệu ứng âm thanh + Đặt, xóa và lời thoại Rung Phản hồi rung khi đặt khối Giao diện tối diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index 51eb3f6..a91e168 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -26,6 +26,10 @@ 设置 声音 游戏音效 + 音乐 + 背景音乐 + 音效 + 放置、消除和语音旁白 振动 放置方块时的触感反馈 深色主题 From 80fc2781b2e5230c170991e33ae5cf2970748340 Mon Sep 17 00:00:00 2001 From: yet Date: Sat, 23 May 2026 17:27:41 +0400 Subject: [PATCH 4/7] Update Compose Multiplatform and Metro versions - Update `composeMultiplatform` to 1.11.0. - Update `metro` to 1.1.1. --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1640a5..878eb0b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,12 +17,12 @@ android-review = "2.0.2" androidx-activity = "1.13.0" androidx-core-splashscreen = "1.2.0" androidx-lifecycle = "2.10.0" -composeMultiplatform = "1.10.3" +composeMultiplatform = "1.11.0" confettikit = "0.8.0" material3 = "1.10.0-alpha05" kotlin = "2.3.21" -metro = "1.0.0" +metro = "1.1.1" kotlinx-coroutines = "1.11.0" From afeb8a6eb9a12c08fc27079a2e2f263d1ad986b3 Mon Sep 17 00:00:00 2001 From: yet Date: Sat, 23 May 2026 18:35:11 +0400 Subject: [PATCH 5/7] Replace text tutorial with wordless gesture onboarding Swap the two-step text spotlight for an animated hand + ghost piece that loops the drag gesture, fading out with a confetti burst once the player engages. Remove the now-unused tutorial string resources across all locales. Co-Authored-By: Claude Opus 4.7 --- .../composeResources/values-ar/strings.xml | 6 - .../composeResources/values-az/strings.xml | 6 - .../composeResources/values-be/strings.xml | 6 - .../composeResources/values-bn/strings.xml | 6 - .../composeResources/values-da/strings.xml | 6 - .../composeResources/values-de/strings.xml | 6 - .../composeResources/values-el/strings.xml | 6 - .../composeResources/values-es/strings.xml | 6 - .../composeResources/values-fi/strings.xml | 6 - .../composeResources/values-fr/strings.xml | 6 - .../composeResources/values-he/strings.xml | 6 - .../composeResources/values-hi/strings.xml | 6 - .../composeResources/values-hu/strings.xml | 6 - .../composeResources/values-hy/strings.xml | 6 - .../composeResources/values-id/strings.xml | 6 - .../composeResources/values-it/strings.xml | 6 - .../composeResources/values-ja/strings.xml | 6 - .../composeResources/values-ka/strings.xml | 6 - .../composeResources/values-kk/strings.xml | 6 - .../composeResources/values-ko/strings.xml | 6 - .../composeResources/values-ky/strings.xml | 6 - .../composeResources/values-nb/strings.xml | 6 - .../composeResources/values-nl/strings.xml | 6 - .../composeResources/values-pl/strings.xml | 6 - .../composeResources/values-pt/strings.xml | 6 - .../composeResources/values-ro/strings.xml | 6 - .../composeResources/values-ru/strings.xml | 6 - .../composeResources/values-sv/strings.xml | 6 - .../composeResources/values-tg/strings.xml | 6 - .../composeResources/values-th/strings.xml | 6 - .../composeResources/values-tk/strings.xml | 6 - .../composeResources/values-tr/strings.xml | 6 - .../composeResources/values-uk/strings.xml | 6 - .../composeResources/values-uz/strings.xml | 6 - .../composeResources/values-vi/strings.xml | 6 - .../composeResources/values-zh/strings.xml | 6 - .../composeResources/values/strings.xml | 6 - .../component/overlay/GestureTutorial.kt | 322 ++++++++++++++++++ .../component/overlay/SpotlightTutorial.kt | 253 -------------- .../yet3/blokblast/screen/game/GameContent.kt | 44 ++- 40 files changed, 353 insertions(+), 488 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GestureTutorial.kt delete mode 100644 composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/SpotlightTutorial.kt diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index ac327a7..1c9987b 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -54,13 +54,7 @@ كومبو - اختر قطعة - اضغط مع الاستمرار على قطعة في الدرج لرفعها. ضعها على اللوحة - اسحب القطعة إلى اللوحة لوضعها. املأ صفاً أو عموداً بالكامل لمسحه. - التالي - حسناً - تخطي هل تستمتع بـ Logica؟ diff --git a/composeApp/src/commonMain/composeResources/values-az/strings.xml b/composeApp/src/commonMain/composeResources/values-az/strings.xml index af52d1c..f104134 100644 --- a/composeApp/src/commonMain/composeResources/values-az/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-az/strings.xml @@ -54,13 +54,7 @@ Kombo - Bir fiqur seçin - Qaldırmaq üçün paneldəki bir fiqura toxunun və saxlayın. Lövhəyə yerləşdirin - Yerləşdirmək üçün fiquru lövhəyə sürün. Təmizləmək üçün tam bir sətir və ya sütunu doldurun. - Növbəti - Anladım - Ötür Logica xoşunuza gəlir? diff --git a/composeApp/src/commonMain/composeResources/values-be/strings.xml b/composeApp/src/commonMain/composeResources/values-be/strings.xml index 2a6b1e0..efb6729 100644 --- a/composeApp/src/commonMain/composeResources/values-be/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-be/strings.xml @@ -54,13 +54,7 @@ Комба - Абярыце фігуру - Націсніце і ўтрымлівайце фігуру на панэлі, каб падняць яе. Перацягніце на дошку - Перацягніце фігуру на дошку, каб размясціць яе. Запоўніце цэлы рад ці слупок, каб ачысціць яго. - Далей - Зразумела - Прапусціць Падабаецца Logica? diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index 6c4448b..1b97e7e 100644 --- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -54,13 +54,7 @@ কম্বো - একটি টুকরা চয়ন করুন - একটি টুকরা তোলার জন্য ট্রেতে থাকা টুকরাটিকে চেপে ধরে রাখুন। এটি বোর্ডে রাখুন - এটি রাখার জন্য টুকরাটিকে বোর্ডে টেনে আনুন। এটি সরানোর জন্য একটি সম্পূর্ণ সারি বা কলাম পূরণ করুন। - পরবর্তী - বুঝেছি - এড়িয়ে যান Logica উপভোগ করছেন? diff --git a/composeApp/src/commonMain/composeResources/values-da/strings.xml b/composeApp/src/commonMain/composeResources/values-da/strings.xml index 1873591..4691b5b 100644 --- a/composeApp/src/commonMain/composeResources/values-da/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-da/strings.xml @@ -54,13 +54,7 @@ Combo - Vælg en brik - Tryk og hold på en brik i bakken for at løfte den. Placer på brættet - Træk brikken over på brættet for at placere den. Fyld en hel række eller kolonne for at fjerne den. - Næste - Forstået - Spring over Nyder du Logica? diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 28d8aca..12afe5c 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -54,13 +54,7 @@ Combo - Wähle ein Teil - Tippe und halte ein Teil im Fach, um es anzuheben. Auf das Spielfeld legen - Ziehe das Teil auf das Spielfeld, um es zu platzieren. Fülle eine komplette Reihe oder Spalte, um sie zu leeren. - Weiter - Verstanden - Überspringen Gefällt dir Logica? diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml index 9f506d1..7de19f0 100644 --- a/composeApp/src/commonMain/composeResources/values-el/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -54,13 +54,7 @@ Combo - Επίλεξε ένα κομμάτι - Πάτησε παρατεταμένα ένα κομμάτι στον δίσκο για να το σηκώσεις. Τοποθέτησέ το στο ταμπλό - Σύρε το κομμάτι στο ταμπλό για να το τοποθετήσεις. Συμπλήρωσε μια ολόκληρη σειρά ή στήλη για να την εξαφανίσεις. - Επόμενο - Έγινε - Παράλειψη Σου αρέσει το Logica; diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index ccd7337..929f80e 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -54,13 +54,7 @@ Combo - Elige una pieza - Mantén presionada una pieza en la bandeja para levantarla. Colócala en el tablero - Arrastra la pieza al tablero para colocarla. Completa una fila o columna entera para eliminarla. - Siguiente - Entendido - Omitir ¿Disfrutando Logica? diff --git a/composeApp/src/commonMain/composeResources/values-fi/strings.xml b/composeApp/src/commonMain/composeResources/values-fi/strings.xml index 0c6b3b8..a681cf7 100644 --- a/composeApp/src/commonMain/composeResources/values-fi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fi/strings.xml @@ -54,13 +54,7 @@ Kombo - Valitse pala - Nosta pala tarjottimelta painamalla sitä pitkään. Aseta laudalle - Vedä pala laudalle asettaaksesi sen. Tyhjennä rivi tai sarake täyttämällä se kokonaan. - Seuraava - Selvä - Ohita Pidätkö Logicasta? diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index d66d936..8228740 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -54,13 +54,7 @@ Combo - Choisis une pièce - Appuie longuement sur une pièce pour la soulever. Dépose-la sur la grille - Fais glisser la pièce sur la grille pour la placer. Remplis une ligne ou une colonne entière pour l'effacer. - Suivant - Compris - Passer Vous aimez Logica ? diff --git a/composeApp/src/commonMain/composeResources/values-he/strings.xml b/composeApp/src/commonMain/composeResources/values-he/strings.xml index 360e960..f3d3223 100644 --- a/composeApp/src/commonMain/composeResources/values-he/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-he/strings.xml @@ -54,13 +54,7 @@ קומבו - בחר חלק - לחץ והחזק חלק במגש כדי להרים אותו. הנח אותו על הלוח - גרור את החלק אל הלוח כדי למקם אותו. מלא שורה או עמודה שלמה כדי לנקות אותה. - הבא - הבנתי - דלג נהנה מ-Logica? diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 1570e38..84f00bc 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -54,13 +54,7 @@ कॉम्बो - एक टुकड़ा चुनें - उठाने के लिए ट्रे में एक टुकड़े को दबाकर रखें। इसे बोर्ड पर रखें - इसे रखने के लिए टुकड़े को बोर्ड पर खींचें। इसे हटाने के लिए एक पूरी पंक्ति या कॉलम भरें। - अगला - समझ गया - छोड़ें Logica का आनंद ले रहे हैं? diff --git a/composeApp/src/commonMain/composeResources/values-hu/strings.xml b/composeApp/src/commonMain/composeResources/values-hu/strings.xml index d88267a..962c6ea 100644 --- a/composeApp/src/commonMain/composeResources/values-hu/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hu/strings.xml @@ -54,13 +54,7 @@ Kombó - Válassz egy elemet - Érintsd meg hosszan az elemet a tálcán a felemeléséhez. Helyezd a táblára - Húzd az elemet a táblára az elhelyezéshez. Tölts meg egy teljes sort vagy oszlopot az eltüntetéséhez. - Tovább - Értem - Kihagyás Tetszik a Logica? diff --git a/composeApp/src/commonMain/composeResources/values-hy/strings.xml b/composeApp/src/commonMain/composeResources/values-hy/strings.xml index 6c76b93..8badf97 100644 --- a/composeApp/src/commonMain/composeResources/values-hy/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hy/strings.xml @@ -54,13 +54,7 @@ Կոմբո - Ընտրիր պատկերը - Սեղմիր և պահիր պատկերը այն բարձրացնելու համար: Տեղադրիր տախտակին - Քաշիր պատկերը տախտակին այն տեղադրելու համար: Լրացրու ամբողջական տող կամ սյունակ այն ջնջելու համար: - Հաջորդը - Պարզ է - Բաց թողնել Հավանու՞մ եք Logica-ն diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 900ad15..ef4198f 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -54,13 +54,7 @@ Kombo - Pilih balok - Ketuk dan tahan balok di baki untuk mengangkatnya. Letakkan di papan - Seret balok ke papan untuk menempatkannya. Isi seluruh baris atau kolom untuk menghapusnya. - Berikutnya - Mengerti - Lewati Menikmati Logica? diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 5c464e4..8b813f1 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -54,13 +54,7 @@ Combo - Scegli un pezzo - Tocca e tieni premuto un pezzo nel vassoio per sollevarlo. Trascinalo sulla griglia - Trascina il pezzo sulla griglia per posizionarlo. Riempi una riga o una colonna intera per eliminarla. - Avanti - Ho capito - Salta Ti piace Logica? diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index 613f944..e04f2e1 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -54,13 +54,7 @@ コンボ - ピースを選ぼう - トレイにあるピースを長押しして持ち上げます。 ボードに置こう - ピースをボードにドラッグして配置します。行または列を揃えて消去しましょう。 - 次へ - わかった - スキップ Logicaを楽しんでいますか? diff --git a/composeApp/src/commonMain/composeResources/values-ka/strings.xml b/composeApp/src/commonMain/composeResources/values-ka/strings.xml index 35c2372..9ef155d 100644 --- a/composeApp/src/commonMain/composeResources/values-ka/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ka/strings.xml @@ -54,13 +54,7 @@ კომბო - აირჩიე ფიგურა - დააჭირე და გეჭიროს ფიგურა მის ასაღებად. განათავსე დაფაზე - გადაიტანე ფიგურა დაფაზე მის განსათავსებლად. შეავსე მთლიანი რიგი ან სვეტი მის გასაქრობად. - შემდეგი - გასაგებია - გამოტოვება მოგწონთ Logica? diff --git a/composeApp/src/commonMain/composeResources/values-kk/strings.xml b/composeApp/src/commonMain/composeResources/values-kk/strings.xml index a1c41b4..0ce9ebd 100644 --- a/composeApp/src/commonMain/composeResources/values-kk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-kk/strings.xml @@ -54,13 +54,7 @@ Комбо - Фигураны таңдаңыз - Көтеру үшін панельдегі фигураны басып тұрыңыз. Тақтаға орналастырыңыз - Орналастыру үшін фигураны тақтаға сүйреңіз. Тазалау үшін толық жолды немесе бағанды толтырыңыз. - Келесі - Түсінікті - Өткізіп жіберу Logica ұнады ма? diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index 373aee6..31ce7ab 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -54,13 +54,7 @@ 콤보 - 조각 선택하기 - 트레이에 있는 조각을 길게 눌러 들어 올리세요. 보드에 배치하기 - 조각을 보드로 드래그하여 배치하세요. 가로 또는 세로 줄을 채워 조각을 제거하세요. - 다음 - 확인 - 건너뛰기 Logica가 마음에 드시나요? diff --git a/composeApp/src/commonMain/composeResources/values-ky/strings.xml b/composeApp/src/commonMain/composeResources/values-ky/strings.xml index 350e020..079e3bd 100644 --- a/composeApp/src/commonMain/composeResources/values-ky/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ky/strings.xml @@ -54,13 +54,7 @@ Комбо - Фигураны тандаңыз - Көтөрүү үчүн фигураны басып туруңуз. Тактага жайгаштырыңыз - Жайгаштыруу үчүн фигураны тактага сүйрөңүз. Тазалоо үчүн толук сапты же тилкени толтуруңуз. - Кийинки - Түшүнүктүү - Өткөрүп жиберүү Logica жакты беби? diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml index 6b76f1d..256e1fb 100644 --- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml @@ -54,13 +54,7 @@ Kombo - Velg en brikke - Trykk og hold på en brikke i brettet for å løfte den. Plasser på brettet - Dra brikken til brettet for å plassere den. Fyll en hel rad eller kolonne for å fjerne den. - Neste - Skjønner - Hopp over Liker du Logica? diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index ec037c2..434f58d 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -54,13 +54,7 @@ Combo - Kies een stuk - Houd een stuk in de lade ingedrukt om het op te tillen. Leg het op het bord - Sleep het stuk naar het bord om het te plaatsen. Vul een volledige rij of kolom om deze te wissen. - Volgende - Begrepen - Overslaan Geniet je van Logica? diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 8b601cf..c89be49 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -54,13 +54,7 @@ Combo - Wybierz element - Naciśnij i przytrzymaj element na tacy, aby go podnieść. Połóż na planszy - Przeciągnij element na planszę, aby go umieścić. Wypełnij cały wiersz lub kolumnę, aby je wyczyścić. - Dalej - Rozumiem - Pomiń Podoba Ci się Logica? diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 638c6e0..4b86bca 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -54,13 +54,7 @@ Combo - Escolha uma peça - Toque e segure uma peça na bandeja para levantá-la. Coloque no tabuleiro - Arraste a peça para o tabuleiro para posicioná-la. Preencha uma linha ou coluna inteira para eliminá-la. - Próximo - Entendi - Pular Gostando de Logica? diff --git a/composeApp/src/commonMain/composeResources/values-ro/strings.xml b/composeApp/src/commonMain/composeResources/values-ro/strings.xml index a97014f..81b9f07 100644 --- a/composeApp/src/commonMain/composeResources/values-ro/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ro/strings.xml @@ -54,13 +54,7 @@ Combo - Alege o piesă - Apasă lung pe o piesă din tavă pentru a o ridica. Pune-o pe tablă - Trage piesa pe tablă pentru a o plasa. Completează un rând sau o coloană întreagă pentru a o elimina. - Următorul - Am înțeles - Omite Îți place Logica? diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index a1e40f1..5077796 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -54,13 +54,7 @@ Комбо - Выберите фигуру - Нажмите и удерживайте фигуру на панели, чтобы поднять ее. Перетащите на доску - Перетащите фигуру на доску, чтобы разместить ее. Заполните целый ряд или столбец, чтобы очистить его. - Далее - Понятно - Пропустить Нравится Logica? diff --git a/composeApp/src/commonMain/composeResources/values-sv/strings.xml b/composeApp/src/commonMain/composeResources/values-sv/strings.xml index 7d0a820..f783e6b 100644 --- a/composeApp/src/commonMain/composeResources/values-sv/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-sv/strings.xml @@ -54,13 +54,7 @@ Kombo - Välj en bit - Tryck och håll på en bit i brickan för att lyfta den. Placera på brädet - Dra biten till brädet för att placera den. Fyll en hel rad eller kolumn för att rensa den. - Nästa - Fattar - Hoppa över Gillar du Logica? diff --git a/composeApp/src/commonMain/composeResources/values-tg/strings.xml b/composeApp/src/commonMain/composeResources/values-tg/strings.xml index 8ce4198..0750c11 100644 --- a/composeApp/src/commonMain/composeResources/values-tg/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tg/strings.xml @@ -54,13 +54,7 @@ Комбо - Фигураро интихоб кунед - Барои бардоштан фигураро пахш карда нигоҳ доред. Дар тахта ҷойгир кунед - Барои ҷойгир кардан фигураро ба тахта кашед. Барои тоза кардан сатр ё сутуни пурраро пур кунед. - Оянда - Фаҳмидам - Гузаштан Logica-ро дӯст медоред? diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index b27db2e..c08110a 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -54,13 +54,7 @@ คอมโบ - เลือกชิ้นส่วน - แตะค้างที่ชิ้นส่วนในถาดเพื่อยกขึ้น วางลงบนกระดาน - ลากชิ้นส่วนลงบนกระดานเพื่อวาง เติมให้เต็มแถวหรือคอลัมน์เพื่อลบออก - ถัดไป - ตกลง - ข้าม ชอบ Logica ไหม? diff --git a/composeApp/src/commonMain/composeResources/values-tk/strings.xml b/composeApp/src/commonMain/composeResources/values-tk/strings.xml index 11c6f98..096b6eb 100644 --- a/composeApp/src/commonMain/composeResources/values-tk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tk/strings.xml @@ -54,13 +54,7 @@ Kombo - Bir şekil saýlaň - Galdyrmak üçin paneldäki şekili basyp tutuň. Tagta ýerleşdiriň - Ýerleşdirmek için şekili tagta süýräň. Arassalamak üçin doly bir setiri ýa-da sütüni dolduryň. - Indiki - Düşündim - Geç Logica halaýarmy? diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 60df7cf..d565745 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -54,13 +54,7 @@ Kombo - Bir parça seç - Kaldırmak için tepsideki bir parçaya basılı tutun. Tahtaya yerleştir - Yerleştirmek için parçayı tahtaya sürükleyin. Temizlemek için tam bir satır veya sütun doldurun. - Sonraki - Anladım - Geç Logica'yı beğeniyor musunuz? diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index 3c93fd3..b5c1ab5 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -54,13 +54,7 @@ Комбо - Оберіть фігуру - Натисніть і утримуйте фігуру на панелі, щоб підняти її. Перетягніть на дошку - Перетягніть фігуру на дошку, щоб розмістити її. Заповніть цілий ряд або стовпець, щоб очистити його. - Далі - Зрозуміло - Пропустити Подобається Logica? diff --git a/composeApp/src/commonMain/composeResources/values-uz/strings.xml b/composeApp/src/commonMain/composeResources/values-uz/strings.xml index b8a53d9..1424c89 100644 --- a/composeApp/src/commonMain/composeResources/values-uz/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uz/strings.xml @@ -54,13 +54,7 @@ Kombo - Shaklni tanlang - Ko'tarish uchun shaklni bosib turing. Doskaga joylashtiring - Joylashtirish uchun shaklni doskaga suring. Tozalash uchun qator yoki ustunni to'ldiring. - Keyingi - Tushunarli - O'tkazib yuborish Logica yoqdimi? diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 2fad359..c263425 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -54,13 +54,7 @@ Combo - Chọn một mảnh - Chạm và giữ một mảnh trong khay để nhấc nó lên. Thả vào bảng - Kéo mảnh vào bảng để đặt nó. Lấp đầy một hàng hoặc cột để xóa nó. - Tiếp theo - Đã hiểu - Bỏ qua Bạn thích Logica? diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index a91e168..7c6cc1c 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -54,13 +54,7 @@ 连击 - 选择方块 - 长按托盘中的方块将其拿起。 放置在棋盘上 - 将方块拖到棋盘上进行放置。填满整行或整列即可消除。 - 下一步 - 知道了 - 跳过 喜欢Logica吗? diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f93f994..fc9f973 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -54,13 +54,7 @@ Combo - Pick a piece - Tap and hold a piece in the tray to lift it. Drop it on the board - Drag the piece onto the board to place it. Fill a full row or column to clear it. - Next - Got it - Skip Enjoying Logica? diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GestureTutorial.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GestureTutorial.kt new file mode 100644 index 0000000..c1dbeab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/GestureTutorial.kt @@ -0,0 +1,322 @@ +package ge.yet3.blokblast.component.overlay + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.compose.ui.util.lerp +import blockblast.composeapp.generated.resources.Res +import blockblast.composeapp.generated.resources.tutorial_grid_title +import ge.yet.blokblast.domain.model.Piece +import ge.yet.blokblast.domain.model.Polyomino +import ge.yet3.blokblast.screen.game.BlockPiece +import ge.yet3.blokblast.screen.game.effects.ConfettiEffect +import ge.yet3.blokblast.theme.pieceColor +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource +import kotlin.math.roundToInt + +// Fingertip hotspot as a fraction of the pointer canvas — the index-finger tip +// that the gesture aligns to, and the pivot the press-scale shrinks toward. +private const val TIP_X = 0.41f +private const val TIP_Y = 0.05f + +private val POINTER_W = 64.dp +private val POINTER_H = 74.dp +private val GHOST_CELL = 26.dp +private val GHOST_GAP = 3.dp + +// How far above the fingertip the lifted ghost piece floats, mirroring the +// vertical lift of a real drag so the demo reads like the real gesture. +private val GHOST_LIFT = 30.dp + +/** + * Wordless first-launch onboarding: a translucent scrim dims the screen while + * an animated hand loops the core gesture — lift the first tray piece and drag + * it onto the board — with a ghost copy of [piece] following the fingertip. + * + * Touches pass straight through: the player dismisses it simply by performing + * the gesture themselves. The caller flips [dismissing] on first engagement, + * which fires a confetti burst and a fade-out before [onExitComplete] runs + * (where the caller persists the "seen" flag and unmounts this overlay). + * [trayBounds] and [gridBounds] are in root pixels. + */ +@Composable +fun GestureTutorial( + trayBounds: Rect, + gridBounds: Rect, + piece: Piece?, + captionTopPadding: Dp, + dismissing: Boolean, + onExitComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + if (piece == null || trayBounds == Rect.Zero || gridBounds == Rect.Zero) return + + val density = LocalDensity.current + val cornerPx = with(density) { 16.dp.toPx() } + val padPx = with(density) { 6.dp.toPx() } + + // Start at the centre of the first tray slot (left third), end near the + // centre of the board. + val trayPoint = Offset(trayBounds.left + trayBounds.width / 6f, trayBounds.center.y) + val boardPoint = gridBounds.center + + val cols = piece.shape.width + val rows = piece.shape.height + val ghostWPx = with(density) { (cols * GHOST_CELL + (cols - 1) * GHOST_GAP).toPx() } + val ghostHPx = with(density) { (rows * GHOST_CELL + (rows - 1) * GHOST_GAP).toPx() } + val pointerWPx = with(density) { POINTER_W.toPx() } + val pointerHPx = with(density) { POINTER_H.toPx() } + val liftPx = with(density) { GHOST_LIFT.toPx() } + + val progress = remember { Animatable(0f) } + val pointerAlpha = remember { Animatable(0f) } + val exitAlpha = remember { Animatable(1f) } + var pressed by remember { mutableStateOf(false) } + var ghostVisible by remember { mutableStateOf(false) } + var showConfetti by remember { mutableStateOf(false) } + + // Demo gesture loop — halts the moment the player engages so it doesn't + // keep moving under the fade-out. + LaunchedEffect(trayPoint, boardPoint, dismissing) { + if (dismissing) return@LaunchedEffect + while (true) { + progress.snapTo(0f) + pressed = false + ghostVisible = false + pointerAlpha.snapTo(0f) + pointerAlpha.animateTo(1f, tween(250)) + delay(400) + pressed = true + delay(260) + ghostVisible = true + progress.animateTo(1f, tween(950, easing = FastOutSlowInEasing)) + delay(160) + pressed = false + delay(140) + ghostVisible = false + delay(420) + pointerAlpha.animateTo(0f, tween(300)) + delay(420) + } + } + + // Exit: pop the confetti, fade the scrim/hand away, then hand control back + // to the caller (which persists "seen" and unmounts us). We stay mounted a + // beat longer so the confetti has time to fall. + LaunchedEffect(dismissing) { + if (dismissing) { + showConfetti = true + exitAlpha.animateTo(0f, tween(380)) + delay(1100) + onExitComplete() + } + } + + val scrimColor = Color.Black.copy(alpha = 0.5f) + val ringColor = MaterialTheme.colorScheme.primary + + Box(modifier = modifier) { + Box( + modifier = Modifier + .fillMaxSize() + // BlendMode.Clear needs an offscreen layer to actually punch holes; + // the same layer's alpha drives the fade-out. + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + alpha = exitAlpha.value + } + .drawWithCache { + onDrawWithContent { + drawRect(scrimColor) + drawSpotlight(trayBounds, padPx, cornerPx, ringColor) + drawSpotlight(gridBounds, padPx, cornerPx, ringColor) + drawContent() + } + }, + ) { + // Short caption, parked just under the score bar. + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = captionTopPadding) + .padding(horizontal = 24.dp) + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(50)) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Text( + text = stringResource(Res.string.tutorial_grid_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // Ghost piece — lifts off the tray and rides above the fingertip. + if (ghostVisible) { + GhostPiece( + shape = piece.shape, + color = pieceColor(piece.colorId), + modifier = Modifier.offset { + val x = lerp(trayPoint.x, boardPoint.x, progress.value) + val y = lerp(trayPoint.y, boardPoint.y, progress.value) - liftPx + IntOffset( + (x - ghostWPx / 2f).roundToInt(), + (y - ghostHPx / 2f).roundToInt(), + ) + }, + ) + } + + // The pointing hand, fingertip pinned to the current gesture point. + val pressScale by animateFloatAsState(if (pressed) 0.9f else 1f, tween(180), label = "press") + val rippleAlpha by animateFloatAsState(if (pressed) 0.35f else 0f, tween(180), label = "ripple") + Canvas( + modifier = Modifier + .offset { + val x = lerp(trayPoint.x, boardPoint.x, progress.value) + val y = lerp(trayPoint.y, boardPoint.y, progress.value) + IntOffset( + (x - pointerWPx * TIP_X).roundToInt(), + (y - pointerHPx * TIP_Y).roundToInt(), + ) + } + .size(POINTER_W, POINTER_H) + .graphicsLayer { + alpha = pointerAlpha.value + scaleX = pressScale + scaleY = pressScale + transformOrigin = TransformOrigin(TIP_X, TIP_Y) + }, + ) { + if (rippleAlpha > 0f) { + drawCircle( + color = Color.White.copy(alpha = rippleAlpha), + radius = size.width * 0.32f, + center = Offset(size.width * TIP_X, size.height * TIP_Y), + ) + } + drawHand() + } + } + + // Celebration burst — drawn outside the faded layer so it stays vivid. + if (showConfetti) ConfettiEffect() + } +} + +/** Punches a rounded transparent hole over [target] and outlines it faintly. */ +private fun DrawScope.drawSpotlight(target: Rect, padPx: Float, cornerPx: Float, ring: Color) { + if (target == Rect.Zero) return + val topLeft = Offset(target.left - padPx, target.top - padPx) + val size = Size(target.width + 2f * padPx, target.height + 2f * padPx) + val corner = CornerRadius(cornerPx, cornerPx) + drawRoundRect( + color = Color.Transparent, + topLeft = topLeft, + size = size, + cornerRadius = corner, + blendMode = BlendMode.Clear, + ) + drawRoundRect( + color = ring.copy(alpha = 0.6f), + topLeft = topLeft, + size = size, + cornerRadius = corner, + style = androidx.compose.ui.graphics.drawscope.Stroke(width = 3f), + ) +} + +/** Draws a stylised pointing hand (index finger up) filling the canvas. */ +private fun DrawScope.drawHand() { + val w = size.width + val h = size.height + // Soft drop shadow, then the white hand on top — overlapping same-colour + // shapes hide their seams, so no outline is needed. + drawHandShapes(w, h, Color.Black.copy(alpha = 0.22f), Offset(w * 0.04f, h * 0.05f)) + drawHandShapes(w, h, Color.White, Offset.Zero) +} + +private fun DrawScope.drawHandShapes(w: Float, h: Float, color: Color, o: Offset) { + // Palm / fist. + drawRoundRect( + color = color, + topLeft = Offset(w * 0.16f + o.x, h * 0.40f + o.y), + size = Size(w * 0.74f, h * 0.58f), + cornerRadius = CornerRadius(w * 0.22f, w * 0.22f), + ) + // Index finger (capsule). + drawRoundRect( + color = color, + topLeft = Offset(w * 0.30f + o.x, h * 0.02f + o.y), + size = Size(w * 0.22f, h * 0.52f), + cornerRadius = CornerRadius(w * 0.11f, w * 0.11f), + ) + // Thumb (capsule, angled out to the left). + rotate(degrees = -28f, pivot = Offset(w * 0.24f + o.x, h * 0.62f + o.y)) { + drawRoundRect( + color = color, + topLeft = Offset(w * 0.02f + o.x, h * 0.52f + o.y), + size = Size(w * 0.40f, w * 0.22f), + cornerRadius = CornerRadius(w * 0.11f, w * 0.11f), + ) + } +} + +@Composable +private fun GhostPiece( + shape: Polyomino, + color: Color, + modifier: Modifier = Modifier, +) { + val w = shape.width * GHOST_CELL + (shape.width - 1) * GHOST_GAP + val h = shape.height * GHOST_CELL + (shape.height - 1) * GHOST_GAP + Box(modifier = modifier.size(w, h)) { + shape.cells.forEach { pos -> + BlockPiece( + color = color, + cellSize = GHOST_CELL, + filled = true, + modifier = Modifier.offset( + x = pos.x * (GHOST_CELL + GHOST_GAP), + y = pos.y * (GHOST_CELL + GHOST_GAP), + ), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/SpotlightTutorial.kt b/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/SpotlightTutorial.kt deleted file mode 100644 index c92d198..0000000 --- a/composeApp/src/commonMain/kotlin/ge/yet3/blokblast/component/overlay/SpotlightTutorial.kt +++ /dev/null @@ -1,253 +0,0 @@ -package ge.yet3.blokblast.component.overlay - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import blockblast.composeapp.generated.resources.Res -import blockblast.composeapp.generated.resources.tutorial_done -import blockblast.composeapp.generated.resources.tutorial_grid_body -import blockblast.composeapp.generated.resources.tutorial_grid_title -import blockblast.composeapp.generated.resources.tutorial_next -import blockblast.composeapp.generated.resources.tutorial_skip -import blockblast.composeapp.generated.resources.tutorial_tray_body -import blockblast.composeapp.generated.resources.tutorial_tray_title -import org.jetbrains.compose.resources.stringResource - -/** - * One step of the spotlight tutorial: a screen-space rectangle to highlight, - * plus the callout text. [target] in root pixels; pass [Rect.Zero] to dim - * everything (no cutout). - */ -data class SpotlightStep( - val target: Rect, - val title: String, - val body: String, -) - -/** - * Full-screen scrim with a rounded cutout around the current step's target - * and a callout card placed just below it. Walks through [steps] and calls - * [onFinished] on the final tap or skip. - * - * Touches on the scrim are absorbed so the user cannot interact with the - * underlying UI while the tutorial is up. - */ -@Composable -fun SpotlightTutorial( - steps: List, - onFinished: () -> Unit, - modifier: Modifier = Modifier, -) { - if (steps.isEmpty()) return - var index by remember { mutableIntStateOf(0) } - val safeIndex = index.coerceIn(0, steps.lastIndex) - val step = steps[safeIndex] - val isLast = safeIndex >= steps.lastIndex - - val density = LocalDensity.current - val padPx = with(density) { 8.dp.toPx() } - val cornerPx = with(density) { 12.dp.toPx() } - - val left by animateFloatAsState(step.target.left, tween(280), label = "spotlight-l") - val top by animateFloatAsState(step.target.top, tween(280), label = "spotlight-t") - val right by animateFloatAsState(step.target.right, tween(280), label = "spotlight-r") - val bottom by animateFloatAsState(step.target.bottom, tween(280), label = "spotlight-b") - - val scrimColor = Color.Black.copy(alpha = 0.72f) - val ringColor = MaterialTheme.colorScheme.primary - - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent() - event.changes.forEach { it.consume() } - } - } - } - // BlendMode.Clear needs an offscreen layer to actually punch a hole. - .graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } - .drawWithCache { - // Pass the four floats directly — animateFloatAsState is per-frame - // and constructing a Rect/Offset/Size each time would allocate on - // every animation tick. drawWithCache only re-runs when the keys - // referenced inside change, so we still get caching of the lambda. - onDrawWithContent { - drawRect(scrimColor) - val hasTarget = right > left && bottom > top - if (hasTarget) { - val holeLeft = left - padPx - val holeTop = top - padPx - val holeWidth = (right - left) + 2f * padPx - val holeHeight = (bottom - top) + 2f * padPx - val topLeft = Offset(holeLeft, holeTop) - val size = Size(holeWidth, holeHeight) - val corner = CornerRadius(cornerPx, cornerPx) - drawRoundRect( - color = Color.Transparent, - topLeft = topLeft, - size = size, - cornerRadius = corner, - blendMode = BlendMode.Clear, - ) - drawRoundRect( - color = ringColor, - topLeft = topLeft, - size = size, - cornerRadius = corner, - style = Stroke(width = 4f), - ) - } - drawContent() - } - }, - ) { - val screenHeightPx = with(density) { maxHeight.toPx() } - Callout( - targetTop = top, - targetBottom = bottom, - screenHeightPx = screenHeightPx, - targetVisible = right > left && bottom > top, - title = step.title, - body = step.body, - isLast = isLast, - onNext = { if (isLast) onFinished() else index = safeIndex + 1 }, - onSkip = onFinished, - ) - } -} - -@Composable -private fun BoxScope.Callout( - targetTop: Float, - targetBottom: Float, - screenHeightPx: Float, - targetVisible: Boolean, - title: String, - body: String, - isLast: Boolean, - onNext: () -> Unit, - onSkip: () -> Unit, -) { - val density = LocalDensity.current - val gapPx = with(density) { 16.dp.toPx() } - val hasTarget = targetVisible - - val cardModifier = Modifier - .padding(horizontal = 24.dp) - .widthIn(max = 360.dp) - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(16.dp)) - .padding(20.dp) - - // Heuristic: place the callout where there is more vertical space. - // This prevents the card from being pushed off-screen or behind system bars. - val spaceAbove = targetTop - val spaceBelow = screenHeightPx - targetBottom - val preferAbove = hasTarget && spaceBelow < spaceAbove - - Column( - modifier = if (!hasTarget) { - Modifier.align(Alignment.Center).then(cardModifier) - } else if (preferAbove) { - Modifier - .align(Alignment.BottomCenter) - // Alignment.BottomCenter puts the bottom of the card at screenHeightPx. - // We offset it up (negative y) so its bottom is at targetTop - gapPx. - .offset { IntOffset(0, (-(screenHeightPx - targetTop + gapPx)).toInt()) } - .then(cardModifier) - } else { - Modifier - .align(Alignment.TopCenter) - // Alignment.TopCenter puts the top of the card at y=0. - // We offset it down so its top is at targetBottom + gapPx. - .offset { IntOffset(0, (targetBottom + gapPx).toInt()) } - .then(cardModifier) - }, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = body, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - modifier = Modifier.padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!isLast) { - TextButton(onClick = onSkip) { - Text(stringResource(Res.string.tutorial_skip)) - } - } - Spacer(Modifier.weight(1f)) - TextButton(onClick = onNext) { - Text( - stringResource( - if (isLast) Res.string.tutorial_done else Res.string.tutorial_next - ), - ) - } - } - } -} - -/** Standard 2-step tutorial: tray then board. */ -@Composable -fun rememberGameTutorialSteps( - trayBounds: Rect, - gridBounds: Rect, -): List { - val trayTitle = stringResource(Res.string.tutorial_tray_title) - val trayBody = stringResource(Res.string.tutorial_tray_body) - val gridTitle = stringResource(Res.string.tutorial_grid_title) - val gridBody = stringResource(Res.string.tutorial_grid_body) - return remember(trayBounds, gridBounds, trayTitle, trayBody, gridTitle, gridBody) { - listOf( - SpotlightStep(target = trayBounds, title = trayTitle, body = trayBody), - SpotlightStep(target = gridBounds, title = gridTitle, body = gridBody), - ) - } -} 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 808ef3b..18eb1b3 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 @@ -32,7 +32,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import ge.yet3.blokblast.component.modifier.liftedPieceShadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedback @@ -42,8 +41,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.geometry.Rect -import ge.yet3.blokblast.component.overlay.SpotlightTutorial -import ge.yet3.blokblast.component.overlay.rememberGameTutorialSteps +import ge.yet3.blokblast.component.overlay.GestureTutorial import ge.yet3.blokblast.theme.LocalOnTutorialSeen import ge.yet3.blokblast.theme.LocalTutorialSeen import ge.yet3.blokblast.theme.LocalVibrationEnabled @@ -106,6 +104,7 @@ fun GameContent(component: GameComponent) { val model = uiModel.game val traySelection by component.pieceTray.selection.subscribeAsState() val selectedPiece = traySelection.piece + val traySlots by component.pieceTray.slots.subscribeAsState() // ── Effect states ──────────────────────────────────────────────────── val dragDrop = rememberDragDropState() @@ -127,12 +126,23 @@ fun GameContent(component: GameComponent) { var cellSizePx by remember { mutableFloatStateOf(0f) } var gapPx by remember { mutableFloatStateOf(0f) } - // Bounds (root-coords) used by the first-launch spotlight tutorial. + // Bounds (root-coords) used by the first-launch gesture tutorial. var gridBounds by remember { mutableStateOf(Rect.Zero) } var trayBounds by remember { mutableStateOf(Rect.Zero) } val tutorialSeen = LocalTutorialSeen.current val onTutorialSeen = LocalOnTutorialSeen.current + // The wordless tutorial dismisses itself the moment the player engages — + // either by dragging a piece or tapping one to select it. Dismissal is + // local + immediate (a fade-out + confetti) so it never lags behind the + // async "seen" persistence; the flag is persisted once the exit finishes. + var tutorialDismissing by remember { mutableStateOf(false) } + var tutorialDismissed by remember { mutableStateOf(false) } + val userEngaged = dragDrop.isDragging || selectedPiece != null + LaunchedEffect(userEngaged) { + if (userEngaged && !tutorialSeen) tutorialDismissing = true + } + var prevComboLevel by remember { mutableStateOf(model.comboLevel) } LaunchedEffect(model.comboLevel) { if (model.comboLevel > prevComboLevel && model.comboLevel > 0) { @@ -412,15 +422,23 @@ fun GameContent(component: GameComponent) { ) } - // ── First-launch spotlight tutorial ───────────────────────────── - // Shown until the user finishes/skips it; persisted via Settings so - // it never appears again. Only renders once both targets have been - // measured so the cutout lands on real geometry. - if (!tutorialSeen && trayBounds != Rect.Zero && gridBounds != Rect.Zero && !model.isGameOver) { - val steps = rememberGameTutorialSteps(trayBounds = trayBounds, gridBounds = gridBounds) - SpotlightTutorial( - steps = steps, - onFinished = onTutorialSeen, + // ── First-launch gesture tutorial ─────────────────────────────── + // A wordless looping hand demonstrates the drag gesture. Persisted + // via Settings so it never appears again, and only renders once both + // targets have been measured so the spotlight lands on real geometry. + if (!tutorialSeen && !tutorialDismissed && + trayBounds != Rect.Zero && gridBounds != Rect.Zero && !model.isGameOver + ) { + GestureTutorial( + trayBounds = trayBounds, + gridBounds = gridBounds, + piece = traySlots.firstOrNull()?.piece, + captionTopPadding = innerPadding.calculateTopPadding() + 8.dp, + dismissing = tutorialDismissing, + onExitComplete = { + tutorialDismissed = true + onTutorialSeen() + }, modifier = Modifier.fillMaxSize(), ) } From a08a81a0996499cf90cb6389e3ea69d2eb4a7530 Mon Sep 17 00:00:00 2001 From: yet Date: Sun, 24 May 2026 11:03:12 +0400 Subject: [PATCH 6/7] Rename `AnalyticRepositoryImpl` to `AnalyticRepository` --- .../{AnalyticRepositoryImpl.kt => AnalyticRepository.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/{AnalyticRepositoryImpl.kt => AnalyticRepository.kt} (100%) diff --git a/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/AnalyticRepositoryImpl.kt b/core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/AnalyticRepository.kt similarity index 100% rename from core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/AnalyticRepositoryImpl.kt rename to core/domain/src/commonMain/kotlin/ge/yet/blokblast/domain/repository/AnalyticRepository.kt From 1ebb51d9e46c9fb5b82c5a4a09fb01a541a8b568 Mon Sep 17 00:00:00 2001 From: yet Date: Sun, 24 May 2026 11:06:11 +0400 Subject: [PATCH 7/7] Bump version to 1.5.0 (versionCode 12) Co-Authored-By: Claude Opus 4.7 --- fastlane/metadata/android/en-US/changelogs/12.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/12.txt diff --git a/fastlane/metadata/android/en-US/changelogs/12.txt b/fastlane/metadata/android/en-US/changelogs/12.txt new file mode 100644 index 0000000..f3b9c4b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/12.txt @@ -0,0 +1 @@ +New-player welcome, reimagined! 🖐️ The first-launch tutorial now shows you the ropes with an animated hand instead of walls of text — just watch the gesture, then play (with a little confetti to celebrate your first move 🎉). 🎵 Music and sound effects split into separate toggles, so you can mute the tunes while keeping those satisfying clicks. The tray also slides more smoothly as pieces settle, and there's the usual round of speed-ups under the hood. Happy puzzling! 🧩✨ diff --git a/gradle.properties b/gradle.properties index 16708ce..b2fd1b7 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.7 -appVersionCode=11 \ No newline at end of file +appVersionName=1.5.0 +appVersionCode=12 \ No newline at end of file diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index d860f1f..e3a09eb 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=11 -MARKETING_VERSION=1.4.7 \ No newline at end of file +CURRENT_PROJECT_VERSION=12 +MARKETING_VERSION=1.5.0 \ No newline at end of file