스테이지 시작 이펙트 동작 트리거 수정

스테이지 클리어 이펙트 최적화
클리어 이펙트 동작 시간 수정 -> 3.8초
This commit is contained in:
2026-10-11 14:35:49 +09:00
parent 81e577dce2
commit 1dceb02dbf
88 changed files with 726 additions and 736 deletions
@@ -1,2 +1,2 @@
#- File Locator -
listingFile=../../../../outputs/apk/debug/output-metadata.json
listingFile=../../../apk/debug/output-metadata.json
@@ -1,4 +1,4 @@
#Sun Oct 11 14:18:29 KST 2026
#Sun Oct 11 14:34:13 KST 2026
base.0=/home/gerd/Workspace/AndroidStudioProjects/wordpuzzles/app/build/intermediates/dex/debug/mergeExtDexDebug/classes.dex
base.1=/home/gerd/Workspace/AndroidStudioProjects/wordpuzzles/app/build/intermediates/dex/debug/mergeProjectDexDebug/0/classes.dex
base.10=/home/gerd/Workspace/AndroidStudioProjects/wordpuzzles/app/build/intermediates/dex/debug/mergeProjectDexDebug/6/classes.dex
File diff suppressed because one or more lines are too long
@@ -71,6 +71,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:testOnly="true"
android:theme="@style/Theme.WordPuzzle" >
<!-- AdMob Sample App ID for testing -->
@@ -71,6 +71,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:testOnly="true"
android:theme="@style/Theme.WordPuzzle" >
<!-- AdMob Sample App ID for testing -->
@@ -71,6 +71,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:testOnly="true"
android:theme="@style/Theme.WordPuzzle" >
<!-- AdMob Sample App ID for testing -->
@@ -1 +1 @@
ε5ά&Α*Σ*φ'θ)ι)τ'
Е5э&а*с*Ж'Х)И)Т'ж$с$В$
@@ -1 +1 @@
钟邨䦶饏賱鹺閦觽鍌魧謱饗饗蹗陰
钟邨䦶饏賱鹺閦觽鍌魧謱饗饗蹗陰韸閯茒
@@ -1,2 +1,2 @@
80
84
0
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -59,7 +59,7 @@
</td>
<td>
<div class="infoBox duration">
<div class="counter">0.019s</div>
<div class="counter">0.011s</div>
<p>duration</p>
</div>
</td>
@@ -95,7 +95,7 @@
<td>1</td>
<td>0</td>
<td>0</td>
<td data-sort-value="13">0.013s</td>
<td data-sort-value="8">0.008s</td>
<td class="success" data-sort-value="100">100%</td>
</tr>
</tbody>
@@ -112,7 +112,7 @@
<input id="line-wrapping-toggle" type="checkbox" autocomplete="off"/>
</label>
</div>Generated by
<a href="https://www.gradle.org">Gradle 9.6.0</a> at 2026. 10. 11. 오후 2:17:48</p>
<a href="https://www.gradle.org">Gradle 9.6.0</a> at 2026. 10. 11. 오후 2:33:05</p>
</div>
</div>
</body>
@@ -59,7 +59,7 @@
</td>
<td>
<div class="infoBox duration">
<div class="counter">0.081s</div>
<div class="counter">0.071s</div>
<p>duration</p>
</div>
</td>
@@ -95,7 +95,7 @@
<td>1</td>
<td>0</td>
<td>0</td>
<td data-sort-value="79">0.079s</td>
<td data-sort-value="71">0.071s</td>
<td class="success" data-sort-value="100">100%</td>
</tr>
</tbody>
@@ -112,7 +112,7 @@
<input id="line-wrapping-toggle" type="checkbox" autocomplete="off"/>
</label>
</div>Generated by
<a href="https://www.gradle.org">Gradle 9.6.0</a> at 2026. 10. 11. 오후 2:17:48</p>
<a href="https://www.gradle.org">Gradle 9.6.0</a> at 2026. 10. 11. 오후 2:33:05</p>
</div>
</div>
</body>
@@ -59,7 +59,7 @@
</td>
<td>
<div class="infoBox duration">
<div class="counter">0.010s</div>
<div class="counter">0.008s</div>
<p>duration</p>
</div>
</td>
@@ -95,7 +95,7 @@
<td>1</td>
<td>0</td>
<td>0</td>
<td data-sort-value="10">0.010s</td>
<td data-sort-value="7">0.007s</td>
<td class="success" data-sort-value="100">100%</td>
</tr>
</tbody>
@@ -112,7 +112,7 @@
<input id="line-wrapping-toggle" type="checkbox" autocomplete="off"/>
</label>
</div>Generated by
<a href="https://www.gradle.org">Gradle 9.6.0</a> at 2026. 10. 11. 오후 2:17:48</p>
<a href="https://www.gradle.org">Gradle 9.6.0</a> at 2026. 10. 11. 오후 2:33:05</p>
</div>
</div>
</body>
@@ -95,7 +95,7 @@
<td>1</td>
<td>0</td>
<td>0</td>
<td data-sort-value="3">0.003s</td>
<td data-sort-value="2">0.002s</td>
<td class="success" data-sort-value="100">100%</td>
</tr>
<tr>
@@ -104,7 +104,7 @@
<td>1</td>
<td>0</td>
<td>0</td>
<td data-sort-value="0">0s</td>
<td data-sort-value="1">0.001s</td>
<td class="success" data-sort-value="100">100%</td>
</tr>
<tr>
@@ -139,7 +139,7 @@
<input id="line-wrapping-toggle" type="checkbox" autocomplete="off"/>
</label>
</div>Generated by
<a href="https://www.gradle.org">Gradle 9.6.0</a> at 2026. 10. 11. 오후 2:17:48</p>
<a href="https://www.gradle.org">Gradle 9.6.0</a> at 2026. 10. 11. 오후 2:33:05</p>
</div>
</div>
</body>
@@ -56,7 +56,7 @@
</td>
<td>
<div class="infoBox duration">
<div class="counter">0.480s</div>
<div class="counter">0.391s</div>
<p>duration</p>
</div>
</td>
@@ -94,7 +94,7 @@
<td>1</td>
<td>0</td>
<td>0</td>
<td data-sort-value="19">0.019s</td>
<td data-sort-value="11">0.011s</td>
<td class="success" data-sort-value="100">100%</td>
</tr>
<tr>
@@ -105,7 +105,7 @@
<td>1</td>
<td>0</td>
<td>0</td>
<td data-sort-value="81">0.081s</td>
<td data-sort-value="71">0.071s</td>
<td class="success" data-sort-value="100">100%</td>
</tr>
<tr>
@@ -116,7 +116,7 @@
<td>1</td>
<td>0</td>
<td>0</td>
<td data-sort-value="10">0.010s</td>
<td data-sort-value="8">0.008s</td>
<td class="success" data-sort-value="100">100%</td>
</tr>
<tr>
@@ -155,7 +155,7 @@
<input id="line-wrapping-toggle" type="checkbox" autocomplete="off"/>
</label>
</div>Generated by
<a href="https://www.gradle.org">Gradle 9.6.0</a> at 2026. 10. 11. 오후 2:17:48</p>
<a href="https://www.gradle.org">Gradle 9.6.0</a> at 2026. 10. 11. 오후 2:33:05</p>
</div>
</div>
</body>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="com.wordpuzzle.hangul.domain.engine.HangulHelperTest" tests="1" skipped="0" failures="0" errors="0" timestamp="2026-10-11T05:17:48.302Z" hostname="vivoboxx" time="0.017">
<testsuite name="com.wordpuzzle.hangul.domain.engine.HangulHelperTest" tests="1" skipped="0" failures="0" errors="0" timestamp="2026-10-11T05:33:05.520Z" hostname="vivoboxx" time="0.01">
<properties/>
<testcase name="isHangulSyllable_validatesCorrectly" classname="com.wordpuzzle.hangul.domain.engine.HangulHelperTest" time="0.013"/>
<testcase name="isHangulSyllable_validatesCorrectly" classname="com.wordpuzzle.hangul.domain.engine.HangulHelperTest" time="0.008"/>
<system-out><![CDATA[]]></system-out>
<system-err><![CDATA[]]></system-err>
</testsuite>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="com.wordpuzzle.hangul.domain.engine.StageDataIntegrityTest" tests="1" skipped="0" failures="0" errors="0" timestamp="2026-10-11T05:17:48.322Z" hostname="vivoboxx" time="0.08">
<testsuite name="com.wordpuzzle.hangul.domain.engine.StageDataIntegrityTest" tests="1" skipped="0" failures="0" errors="0" timestamp="2026-10-11T05:33:05.532Z" hostname="vivoboxx" time="0.071">
<properties/>
<testcase name="testAllStages_withinGridBounds_andNoIntersectionConflicts" classname="com.wordpuzzle.hangul.domain.engine.StageDataIntegrityTest" time="0.079"/>
<testcase name="testAllStages_withinGridBounds_andNoIntersectionConflicts" classname="com.wordpuzzle.hangul.domain.engine.StageDataIntegrityTest" time="0.071"/>
<system-out><![CDATA[]]></system-out>
<system-err><![CDATA[]]></system-err>
</testsuite>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="com.wordpuzzle.hangul.domain.engine.TilePoolGeneratorTest" tests="1" skipped="0" failures="0" errors="0" timestamp="2026-10-11T05:17:48.404Z" hostname="vivoboxx" time="0.01">
<testsuite name="com.wordpuzzle.hangul.domain.engine.TilePoolGeneratorTest" tests="1" skipped="0" failures="0" errors="0" timestamp="2026-10-11T05:33:05.604Z" hostname="vivoboxx" time="0.007">
<properties/>
<testcase name="generateLayoutWithStarterLetters_respectsFiftyPercentLimitAndPlacesStarterLettersCorrectly" classname="com.wordpuzzle.hangul.domain.engine.TilePoolGeneratorTest" time="0.01"/>
<testcase name="generateLayoutWithStarterLetters_respectsFiftyPercentLimitAndPlacesStarterLettersCorrectly" classname="com.wordpuzzle.hangul.domain.engine.TilePoolGeneratorTest" time="0.007"/>
<system-out><![CDATA[]]></system-out>
<system-err><![CDATA[]]></system-err>
</testsuite>
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="com.wordpuzzle.hangul.domain.usecase.SolveTileUseCaseTest" tests="4" skipped="0" failures="0" errors="0" timestamp="2026-10-11T05:17:48.417Z" hostname="vivoboxx" time="0.004">
<testsuite name="com.wordpuzzle.hangul.domain.usecase.SolveTileUseCaseTest" tests="4" skipped="0" failures="0" errors="0" timestamp="2026-10-11T05:33:05.614Z" hostname="vivoboxx" time="0.004">
<properties/>
<testcase name="removeCellChar_restoresTileToPool" classname="com.wordpuzzle.hangul.domain.usecase.SolveTileUseCaseTest" time="0.001"/>
<testcase name="applyHint_revealsOneCell" classname="com.wordpuzzle.hangul.domain.usecase.SolveTileUseCaseTest" time="0.003"/>
<testcase name="easyMode_tapTile_automaticallyInsertsIntoCorrectCell_andClears" classname="com.wordpuzzle.hangul.domain.usecase.SolveTileUseCaseTest" time="0.0"/>
<testcase name="applyHint_revealsOneCell" classname="com.wordpuzzle.hangul.domain.usecase.SolveTileUseCaseTest" time="0.002"/>
<testcase name="easyMode_tapTile_automaticallyInsertsIntoCorrectCell_andClears" classname="com.wordpuzzle.hangul.domain.usecase.SolveTileUseCaseTest" time="0.001"/>
<testcase name="normalMode_insertChar_advancesToNextCellInWord_orClosestCell()" classname="com.wordpuzzle.hangul.domain.usecase.SolveTileUseCaseTest" time="0.0"/>
<system-out><![CDATA[]]></system-out>
<system-err><![CDATA[]]></system-err>
@@ -1,5 +1,8 @@
package com.wordpuzzle.hangul.ui.game
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
@@ -7,23 +10,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.withTransform
import kotlinx.coroutines.isActive
import kotlin.math.sin
import kotlin.random.Random
data class ConfettiParticle(
var x: Float,
var y: Float,
var vx: Float,
var vy: Float,
var size: Float,
var color: Color,
var rotation: Float,
var vRot: Float,
var isCircle: Boolean,
var alpha: Float = 1.0f
)
private val CONFETTI_COLORS = listOf(
Color(0xFFFFD54F), // 황금 노랑
Color(0xFFE57373), // 상큼 로즈 핑크
@@ -34,97 +23,90 @@ private val CONFETTI_COLORS = listOf(
Color(0xFF4DB6AC) // 세이지 틸
)
// 메모리 가비지 컬렉션(GC) 부하 0%를 위한 플랫 원시 타입 파티클 시스템
private class FastConfettiSystem(val count: Int) {
val x = FloatArray(count)
val y = FloatArray(count)
val vx = FloatArray(count)
val vy = FloatArray(count)
val size = FloatArray(count)
val colorIndex = IntArray(count)
val flutterPhase = FloatArray(count)
val isCircle = BooleanArray(count)
init {
val rand = Random(42)
for (i in 0 until count) {
x[i] = rand.nextFloat() * 1000f
y[i] = -30f - rand.nextFloat() * 350f
vx[i] = (rand.nextFloat() - 0.5f) * 5f
vy[i] = 3.5f + rand.nextFloat() * 7f
size[i] = 12f + rand.nextFloat() * 16f
colorIndex[i] = rand.nextInt(CONFETTI_COLORS.size)
flutterPhase[i] = rand.nextFloat() * 6.28f
isCircle[i] = rand.nextBoolean()
}
}
}
@Composable
fun ConfettiParticleCanvas(
modifier: Modifier = Modifier,
particleCount: Int = 75,
particleCount: Int = 45,
onAnimationEnd: () -> Unit = {}
) {
var particles by remember { mutableStateOf<List<ConfettiParticle>>(emptyList()) }
var animationTime by remember { mutableFloatStateOf(0f) }
val system = remember { FastConfettiSystem(particleCount) }
val animProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
val rand = Random(System.currentTimeMillis())
val list = mutableListOf<ConfettiParticle>()
for (i in 0 until particleCount) {
val startX = rand.nextFloat() * 1000f
val startY = -50f - rand.nextFloat() * 300f
val vx = (rand.nextFloat() - 0.5f) * 12f
val vy = 10f + rand.nextFloat() * 18f
val size = 12f + rand.nextFloat() * 16f
val color = CONFETTI_COLORS[rand.nextInt(CONFETTI_COLORS.size)]
val rotation = rand.nextFloat() * 360f
val vRot = (rand.nextFloat() - 0.5f) * 15f
val isCircle = rand.nextBoolean()
list.add(
ConfettiParticle(
x = startX,
y = startY,
vx = vx,
vy = vy,
size = size,
color = color,
rotation = rotation,
vRot = vRot,
isCircle = isCircle
)
animProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = 3800,
easing = LinearEasing
)
}
particles = list
val startTime = withFrameNanos { it }
val durationNanos = 2_800_000_000L // 2.8초
while (isActive) {
val currentNanos = withFrameNanos { it }
val elapsedNanos = currentNanos - startTime
val progress = (elapsedNanos.toFloat() / durationNanos.toFloat()).coerceIn(0f, 1f)
animationTime = progress
for (p in particles) {
p.x += p.vx
p.y += p.vy
p.vy += 0.35f // gravity
p.vx *= 0.98f // air drag
p.rotation += p.vRot
if (progress > 0.7f) {
p.alpha = ((1f - progress) / 0.3f).coerceIn(0f, 1f)
}
}
if (progress >= 1f) {
onAnimationEnd()
break
}
}
)
onAnimationEnd()
}
val progress = animProgress.value
Canvas(modifier = modifier.fillMaxSize()) {
val canvasWidth = size.width
val canvasHeight = size.height
val elapsedSec = progress * 3.8f
for (p in particles) {
val px = (p.x / 1000f) * canvasWidth
val py = (p.y / 1200f) * canvasHeight
val globalAlpha = if (progress > 0.82f) {
((1f - progress) / 0.18f).coerceIn(0f, 1f)
} else {
1f
}
withTransform({
rotate(degrees = p.rotation, pivot = Offset(px, py))
}) {
if (p.isCircle) {
drawCircle(
color = p.color.copy(alpha = p.color.alpha * p.alpha),
radius = p.size / 2f,
center = Offset(px, py)
)
} else {
drawRect(
color = p.color.copy(alpha = p.color.alpha * p.alpha),
topLeft = Offset(px - p.size / 2f, py - p.size / 2f),
size = Size(p.size, p.size * 0.6f)
)
}
if (globalAlpha <= 0f) return@Canvas
for (i in 0 until system.count) {
// 수학 공식으로 위치 계산 (매 프레임 객체 생성 0개)
val flutterX = sin(elapsedSec * 3.5f + system.flutterPhase[i]) * 18f
val px = ((system.x[i] + system.vx[i] * elapsedSec * 30f + flutterX) / 1000f) * canvasWidth
val py = ((system.y[i] + system.vy[i] * elapsedSec * 35f + 0.5f * 9.8f * elapsedSec * elapsedSec * 15f) / 1000f) * canvasHeight
if (py < -20f || py > canvasHeight + 50f) continue
val pColor = CONFETTI_COLORS[system.colorIndex[i]].copy(alpha = globalAlpha)
val pSize = system.size[i]
if (system.isCircle[i]) {
drawCircle(
color = pColor,
radius = pSize / 2f,
center = Offset(px, py)
)
} else {
drawRect(
color = pColor,
topLeft = Offset(px - pSize / 2f, py - pSize / 2f),
size = Size(pSize, pSize * 0.65f)
)
}
}
}
@@ -27,6 +27,7 @@ import com.wordpuzzle.hangul.ui.theme.BackgroundLight
import com.wordpuzzle.hangul.ui.theme.PrimaryIndigo
import com.wordpuzzle.hangul.ui.theme.SecondaryOrange
import com.wordpuzzle.hangul.util.SoundManager
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -68,9 +69,12 @@ fun GameScreen(
}
}
// 클리어 이벤트 수신: 누적 퍼즐 풀이 시간이 5분(300초) 이상이면 다음 스테이지 진입 전 전체화면 광고 노출 후 5분 카운트 리셋
// 클리어 이벤트 수신: 참 잘했어요 도장과 폭죽 연출이 바닥까지 완전히 떨어지도록 3.8초 딜레이 후 전환
LaunchedEffect(Unit) {
viewModel.stageClearEvent.collect { event ->
// 종이가 완전히 떨어지는 애니메이션을 위한 3.8초 딜레이
delay(3800L)
if (event.isLastStage) {
ToastUtils.showTopToast(context, "🎉 모든 스테이지를 클리어하셨습니다!")
onNavigateBack()