diff --git a/WordPuzzles.zip b/WordPuzzles.zip new file mode 100644 index 0000000..64c16fb Binary files /dev/null and b/WordPuzzles.zip differ diff --git a/app/src/main/java/com/wordpuzzle/hangul/ui/game/ConfettiParticleCanvas.kt b/app/src/main/java/com/wordpuzzle/hangul/ui/game/ConfettiParticleCanvas.kt new file mode 100644 index 0000000..307e99f --- /dev/null +++ b/app/src/main/java/com/wordpuzzle/hangul/ui/game/ConfettiParticleCanvas.kt @@ -0,0 +1,131 @@ +package com.wordpuzzle.hangul.ui.game + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +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.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), // 상큼 로즈 핑크 + Color(0xFF64B5F6), // 스카이 블루 + Color(0xFF81C784), // 민트 그린 + Color(0xFFBA68C8), // 라벤더 + Color(0xFFFF8A65), // 피치 오렌지 + Color(0xFF4DB6AC) // 세이지 틸 +) + +@Composable +fun ConfettiParticleCanvas( + modifier: Modifier = Modifier, + particleCount: Int = 75, + onAnimationEnd: () -> Unit = {} +) { + var particles by remember { mutableStateOf>(emptyList()) } + var animationTime by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(Unit) { + val rand = Random(System.currentTimeMillis()) + val list = mutableListOf() + + 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 + ) + ) + } + 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 + } + } + } + + Canvas(modifier = modifier.fillMaxSize()) { + val canvasWidth = size.width + val canvasHeight = size.height + + for (p in particles) { + val px = (p.x / 1000f) * canvasWidth + val py = (p.y / 1200f) * canvasHeight + + 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) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/wordpuzzle/hangul/ui/game/StageClearStampOverlay.kt b/app/src/main/java/com/wordpuzzle/hangul/ui/game/StageClearStampOverlay.kt new file mode 100644 index 0000000..70aef82 --- /dev/null +++ b/app/src/main/java/com/wordpuzzle/hangul/ui/game/StageClearStampOverlay.kt @@ -0,0 +1,100 @@ +package com.wordpuzzle.hangul.ui.game + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun StageClearStampOverlay( + modifier: Modifier = Modifier, + stageNumber: Int = 1 +) { + // 쾅! 하고 힘차게 찍히는 중후한 스탬프 바운스 애니메이션 + val scaleAnim = remember { Animatable(2.8f) } + val rotateAnim = remember { Animatable(-18f) } + val alphaAnim = remember { Animatable(0f) } + + LaunchedEffect(Unit) { + alphaAnim.animateTo(1f) + scaleAnim.animateTo( + targetValue = 1.0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + rotateAnim.animateTo( + targetValue = -9f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioHighBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + } + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .size(240.dp) + .scale(scaleAnim.value) + .rotate(rotateAnim.value) + .border(6.dp, Color(0xFFC62828), CircleShape), + shape = CircleShape, + color = Color(0xFFFFF5F5), // 선명한 한지 인주 배경 + shadowElevation = 16.dp + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + .border(3.dp, Color(0xFFB71C1C), CircleShape), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "祝 클리어", + fontSize = 20.sp, + fontWeight = FontWeight.ExtraBold, + color = Color(0xFFB71C1C) + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "참 잘했어요!", + fontSize = 32.sp, + fontWeight = FontWeight.Black, + color = Color(0xFFC62828) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "STAGE $stageNumber", + fontSize = 15.sp, + fontWeight = FontWeight.ExtraBold, + color = Color(0xFFD32F2F) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/wordpuzzle/hangul/ui/game/StageEntranceFlipOverlay.kt b/app/src/main/java/com/wordpuzzle/hangul/ui/game/StageEntranceFlipOverlay.kt new file mode 100644 index 0000000..49729ef --- /dev/null +++ b/app/src/main/java/com/wordpuzzle/hangul/ui/game/StageEntranceFlipOverlay.kt @@ -0,0 +1,100 @@ +package com.wordpuzzle.hangul.ui.game + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay + +@Composable +fun StageEntranceBannerOverlay( + stageNumber: Int, + title: String, + onAnimationEnd: () -> Unit, + modifier: Modifier = Modifier +) { + val alphaAnim = remember { Animatable(0f) } + val rotationYAnim = remember { Animatable(90f) } + val scaleAnim = remember { Animatable(0.7f) } + + LaunchedEffect(stageNumber) { + // 1. 3D Flip Wave Enter + alphaAnim.animateTo(1f, tween(300)) + rotationYAnim.animateTo(0f, tween(500, easing = FastOutSlowInEasing)) + scaleAnim.animateTo(1f, tween(500, easing = FastOutSlowInEasing)) + + delay(1200L) + + // 2. Smooth Exit + alphaAnim.animateTo(0f, tween(400)) + onAnimationEnd() + } + + if (alphaAnim.value > 0f) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f * alphaAnim.value)), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .fillMaxWidth(0.85f) + .graphicsLayer { + rotationY = rotationYAnim.value + scaleX = scaleAnim.value + scaleY = scaleAnim.value + alpha = alphaAnim.value + cameraDistance = 12f * density + }, + shape = RoundedCornerShape(20.dp), + color = Color(0xFF1A237E), // 깊은 남색 한국적 한지 현판 배경 + shadowElevation = 12.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp, horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color(0xFFFFD54F) + ) { + Text( + text = "STAGE $stageNumber", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1A237E) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = title, + fontSize = 20.sp, + fontWeight = FontWeight.ExtraBold, + color = Color.White + ) + } + } + } + } +} diff --git a/app/src/main/java/com/wordpuzzle/hangul/util/SoundManager.kt b/app/src/main/java/com/wordpuzzle/hangul/util/SoundManager.kt new file mode 100644 index 0000000..c2523d1 --- /dev/null +++ b/app/src/main/java/com/wordpuzzle/hangul/util/SoundManager.kt @@ -0,0 +1,199 @@ +package com.wordpuzzle.hangul.util + +import android.content.Context +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.media.SoundPool +import com.wordpuzzle.hangul.R +import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.Random + +object SoundManager { + + private var soundPool: SoundPool? = null + private var mediaPlayer: MediaPlayer? = null + private var currentBgmResId: Int = 0 + + private var soundEnabled: Boolean = true + + private var soundTileClickId: Int = 0 + private var soundTilePlaceId: Int = 0 + private var soundCorrectId: Int = 0 + private var soundStageClearId: Int = 0 + private var soundWrongId: Int = 0 + + private var isInitialized = false + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val puzzleBgms = listOf( + R.raw.bgm_puzzle_1, + R.raw.bgm_puzzle_2, + R.raw.bgm_puzzle_3 + ) + + fun init(context: Context, prefs: UserPreferencesManager) { + if (isInitialized) return + val appContext = context.applicationContext + + // SoundPool 설정 + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + + soundPool = SoundPool.Builder() + .setMaxStreams(5) + .setAudioAttributes(audioAttributes) + .build() + + soundPool?.let { pool -> + soundTileClickId = pool.load(appContext, R.raw.sfx_tile_click, 1) + soundTilePlaceId = pool.load(appContext, R.raw.sfx_tile_place, 1) + soundCorrectId = pool.load(appContext, R.raw.sfx_correct, 1) + soundStageClearId = pool.load(appContext, R.raw.sfx_stage_clear, 1) + soundWrongId = pool.load(appContext, R.raw.sfx_wrong, 1) + } + + // 설정값 구독 + scope.launch { + prefs.soundEnabledFlow.collectLatest { enabled -> + soundEnabled = enabled + if (!enabled) { + pauseBgm() + } else { + resumeBgm() + } + } + } + + isInitialized = true + } + + /** + * 메인 로비 화면 전용 BGM 재생 + */ + fun playLobbyBgm(context: Context) { + playBgmResource(context, R.raw.bgm_lobby) + } + + /** + * 퍼즐 화면 전용 BGM 재생 (10개 스테이지 단위로 퍼즐 BGM이 바뀜) + */ + fun playPuzzleBgm(context: Context, stageNumber: Int) { + val block = ((stageNumber - 1) / 10).coerceAtLeast(0) + val bgmIndex = Random(block.toLong() * 31L + 17L).nextInt(puzzleBgms.size) + val targetResId = puzzleBgms[bgmIndex] + playBgmResource(context, targetResId) + } + + fun playBgm(context: Context) { + playLobbyBgm(context) + } + + private fun playBgmResource(context: Context, resId: Int) { + if (!soundEnabled) { + currentBgmResId = resId + return + } + + if (currentBgmResId == resId && mediaPlayer != null) { + mediaPlayer?.let { player -> + if (!player.isPlaying) { + try { + player.start() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + return + } + + stopBgm() + currentBgmResId = resId + mediaPlayer = MediaPlayer.create(context.applicationContext, resId)?.apply { + isLooping = true + setVolume(0.5f, 0.5f) + } + mediaPlayer?.let { player -> + try { + player.start() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun pauseBgm() { + mediaPlayer?.let { player -> + if (player.isPlaying) { + player.pause() + } + } + } + + fun resumeBgm() { + if (!soundEnabled) return + mediaPlayer?.let { player -> + if (!player.isPlaying) { + try { + player.start() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + + fun stopBgm() { + mediaPlayer?.let { player -> + try { + if (player.isPlaying) { + player.stop() + } + player.release() + } catch (e: Exception) { + e.printStackTrace() + } + } + mediaPlayer = null + currentBgmResId = 0 + } + + fun playTileClick() { + playSound(soundTileClickId) + } + + fun playTilePlace() { + playSound(soundTilePlaceId) + } + + fun playCorrect() { + playSound(soundCorrectId) + } + + fun playStageClear() { + playSound(soundStageClearId) + } + + fun playWrong() { + playSound(soundWrongId) + } + + private fun playSound(soundId: Int) { + if (!soundEnabled || soundId == 0) return + soundPool?.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f) + } + + fun release() { + stopBgm() + soundPool?.release() + soundPool = null + isInitialized = false + } +} diff --git a/app/src/main/res/raw/bgm_lobby.mp3 b/app/src/main/res/raw/bgm_lobby.mp3 new file mode 100644 index 0000000..ab5d790 Binary files /dev/null and b/app/src/main/res/raw/bgm_lobby.mp3 differ diff --git a/app/src/main/res/raw/bgm_main.mp3 b/app/src/main/res/raw/bgm_main.mp3 new file mode 100644 index 0000000..4caadc6 Binary files /dev/null and b/app/src/main/res/raw/bgm_main.mp3 differ diff --git a/app/src/main/res/raw/bgm_puzzle_1.mp3 b/app/src/main/res/raw/bgm_puzzle_1.mp3 new file mode 100644 index 0000000..4afdc42 Binary files /dev/null and b/app/src/main/res/raw/bgm_puzzle_1.mp3 differ diff --git a/app/src/main/res/raw/bgm_puzzle_2.mp3 b/app/src/main/res/raw/bgm_puzzle_2.mp3 new file mode 100644 index 0000000..6af9eec Binary files /dev/null and b/app/src/main/res/raw/bgm_puzzle_2.mp3 differ diff --git a/app/src/main/res/raw/bgm_puzzle_3.mp3 b/app/src/main/res/raw/bgm_puzzle_3.mp3 new file mode 100644 index 0000000..f5447e5 Binary files /dev/null and b/app/src/main/res/raw/bgm_puzzle_3.mp3 differ diff --git a/app/src/main/res/raw/sfx_correct.mp3 b/app/src/main/res/raw/sfx_correct.mp3 new file mode 100644 index 0000000..d1ae52a Binary files /dev/null and b/app/src/main/res/raw/sfx_correct.mp3 differ diff --git a/app/src/main/res/raw/sfx_stage_clear.mp3 b/app/src/main/res/raw/sfx_stage_clear.mp3 new file mode 100644 index 0000000..17396d4 Binary files /dev/null and b/app/src/main/res/raw/sfx_stage_clear.mp3 differ diff --git a/app/src/main/res/raw/sfx_tile_click.mp3 b/app/src/main/res/raw/sfx_tile_click.mp3 new file mode 100644 index 0000000..cf4a3b2 Binary files /dev/null and b/app/src/main/res/raw/sfx_tile_click.mp3 differ diff --git a/app/src/main/res/raw/sfx_tile_place.mp3 b/app/src/main/res/raw/sfx_tile_place.mp3 new file mode 100644 index 0000000..ed9bc7f Binary files /dev/null and b/app/src/main/res/raw/sfx_tile_place.mp3 differ diff --git a/app/src/main/res/raw/sfx_wrong.mp3 b/app/src/main/res/raw/sfx_wrong.mp3 new file mode 100644 index 0000000..a5ce1fd Binary files /dev/null and b/app/src/main/res/raw/sfx_wrong.mp3 differ diff --git a/puzzlepw b/puzzlepw new file mode 100644 index 0000000..4d43e7e --- /dev/null +++ b/puzzlepw @@ -0,0 +1 @@ +wordpuzzle1234! diff --git a/tools/build_expanded_stages.py b/tools/build_expanded_stages.py new file mode 100644 index 0000000..823f299 --- /dev/null +++ b/tools/build_expanded_stages.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +import json + +def validate_stage(stage): + grid_size = stage['gridSize'] + clues = stage['clues'] + grid = {} + + if len(clues) < 3: + return False, f"Stage {stage.get('stageNumber')}: Too few clues ({len(clues)})" + + word_connections = {c['id']: set() for c in clues} + + for clue in clues: + word = clue['word'] + length = clue['length'] + r = clue['startRow'] + c = clue['startCol'] + direction = clue['direction'] + + if len(word) != length: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: word '{word}' len {len(word)} != length {length}" + if length < 2: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: 1-letter word '{word}' not allowed" + + for i, ch in enumerate(word): + curr_r = r + (i if direction == 'DOWN' else 0) + curr_c = c + (i if direction == 'ACROSS' else 0) + + if curr_r < 0 or curr_r >= grid_size or curr_c < 0 or curr_c >= grid_size: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: '{word}' char '{ch}' at ({curr_r}, {curr_c}) out of bounds ({grid_size}x{grid_size})" + + if (curr_r, curr_c) in grid: + existing_ch, existing_id = grid[(curr_r, curr_c)] + if existing_ch != ch: + return False, f"Stage {stage.get('stageNumber')} CLASH at ({curr_r}, {curr_c}): existing '{existing_ch}' ({existing_id}) vs '{ch}' in {clue['id']} ('{word}')" + word_connections[clue['id']].add(existing_id) + word_connections[existing_id].add(clue['id']) + else: + grid[(curr_r, curr_c)] = (ch, clue['id']) + + for cid, conns in word_connections.items(): + if not conns: + return False, f"Stage {stage.get('stageNumber')} Clue {cid} has no intersections!" + + return True, "OK" + +def center_stage(stage): + clues = stage['clues'] + grid_size = stage['gridSize'] + + min_r = min(c['startRow'] for c in clues) + max_r = max(c['startRow'] + (c['length'] - 1 if c['direction'] == 'DOWN' else 0) for c in clues) + min_c = min(c['startCol'] for c in clues) + max_c = max(c['startCol'] + (c['length'] - 1 if c['direction'] == 'ACROSS' else 0) for c in clues) + + bbox_h = max_r - min_r + 1 + bbox_w = max_c - min_c + 1 + + offset_r = (grid_size - bbox_h) // 2 - min_r + offset_c = (grid_size - bbox_w) // 2 - min_c + + for c in clues: + c['startRow'] += offset_r + c['startCol'] += offset_c + +print("Helper functions loaded.") diff --git a/tools/build_unique_280_stages.py b/tools/build_unique_280_stages.py new file mode 100644 index 0000000..4b87050 --- /dev/null +++ b/tools/build_unique_280_stages.py @@ -0,0 +1,427 @@ +# -*- coding: utf-8 -*- +import json +import csv +import copy +import random +from collections import defaultdict + +def validate_stage(stage): + grid_size = stage['gridSize'] + clues = stage['clues'] + grid = {} + + if len(clues) < 3: + return False, f"Stage {stage.get('stageNumber')}: Too few clues ({len(clues)})" + + word_connections = {c['id']: set() for c in clues} + + for clue in clues: + word = clue['word'] + length = clue['length'] + r = clue['startRow'] + c = clue['startCol'] + direction = clue['direction'] + + if len(word) != length: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: word '{word}' len {len(word)} != length {length}" + if length < 2: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: 1-letter word '{word}' not allowed" + + for i, ch in enumerate(word): + curr_r = r + (i if direction == 'DOWN' else 0) + curr_c = c + (i if direction == 'ACROSS' else 0) + + if curr_r < 0 or curr_r >= grid_size or curr_c < 0 or curr_c >= grid_size: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: '{word}' char '{ch}' at ({curr_r}, {curr_c}) out of bounds ({grid_size}x{grid_size})" + + if (curr_r, curr_c) in grid: + existing_ch, existing_id = grid[(curr_r, curr_c)] + if existing_ch != ch: + return False, f"Stage {stage.get('stageNumber')} CLASH at ({curr_r}, {curr_c}): existing '{existing_ch}' ({existing_id}) vs '{ch}' in {clue['id']} ('{word}')" + word_connections[clue['id']].add(existing_id) + word_connections[existing_id].add(clue['id']) + else: + grid[(curr_r, curr_c)] = (ch, clue['id']) + + for cid, conns in word_connections.items(): + if not conns: + return False, f"Stage {stage.get('stageNumber')} Clue {cid} has no intersections!" + + return True, "OK" + +def center_stage_in_grid(s): + clues = s['clues'] + grid_size = s['gridSize'] + if not clues: return + + min_r = min(c['startRow'] for c in clues) + max_r = max(c['startRow'] + (c['length'] - 1 if c['direction'] == 'DOWN' else 0) for c in clues) + min_c = min(c['startCol'] for c in clues) + max_c = max(c['startCol'] + (c['length'] - 1 if c['direction'] == 'ACROSS' else 0) for c in clues) + + bbox_h = max_r - min_r + 1 + bbox_w = max_c - min_c + 1 + + offset_r = (grid_size - bbox_h) // 2 - min_r + offset_c = (grid_size - bbox_w) // 2 - min_c + + for c in clues: + c['startRow'] += offset_r + c['startCol'] += offset_c + +def clean_stage_clues(s): + clues = s['clues'] + + # 1. Eliminate same-direction overlapping clues + cell_dir_map = {} + for c in clues: + d = c['direction'] + w = c['word'] + sr = c['startRow'] + sc = c['startCol'] + for i in range(len(w)): + r = sr + (i if d == 'DOWN' else 0) + col = sc + (i if d == 'ACROSS' else 0) + key = (r, col, d) + if key not in cell_dir_map: + cell_dir_map[key] = [] + cell_dir_map[key].append(c) + + to_remove = set() + for key, clue_list in cell_dir_map.items(): + if len(clue_list) > 1: + clue_list.sort(key=lambda x: len(x['word']), reverse=True) + for shorter in clue_list[1:]: + to_remove.add(shorter['id']) + + clues = [c for c in clues if c['id'] not in to_remove] + + # 2. Eliminate substring duplicate words where one is contained in another + sub_remove = set() + for i, c1 in enumerate(clues): + w1 = c1['word'] + for j, c2 in enumerate(clues): + if i != j and c2['id'] not in sub_remove: + w2 = c2['word'] + if len(w1) < len(w2) and w1 in w2: + sub_remove.add(c1['id']) + break + + clues = [c for c in clues if c['id'] not in sub_remove] + + # 3. Prune isolated clues + while True: + connections = {c['id']: 0 for c in clues} + grid = {} + for c in clues: + d = c['direction'] + w = c['word'] + sr = c['startRow'] + sc = c['startCol'] + for i in range(len(w)): + r = sr + (i if d == 'DOWN' else 0) + col = sc + (i if d == 'ACROSS' else 0) + if (r, col) in grid: + other_id = grid[(r, col)] + connections[c['id']] += 1 + connections[other_id] += 1 + else: + grid[(r, col)] = c['id'] + + isolated = [cid for cid, cnt in connections.items() if cnt == 0] + if not isolated or len(clues) - len(isolated) < 3: + break + clues = [c for c in clues if c['id'] not in isolated] + + s['clues'] = clues + +def format_stage(s, stage_num, title): + s['stageNumber'] = stage_num + s['title'] = title + clues = s['clues'] + + clues.sort(key=lambda c: (c['startRow'], c['startCol'], c['direction'])) + for idx, c in enumerate(clues, 1): + c['id'] = f"c{stage_num}_{idx}" + c['number'] = idx + c['length'] = len(c['word']) + + center_stage_in_grid(s) + +def can_place_word(grid, cell_dir_map, grid_size, word, sr, sc, direction): + length = len(word) + is_across = (direction == 'ACROSS') + + if is_across: + if sr < 0 or sr >= grid_size or sc < 0 or (sc + length) > grid_size: + return False + if sc - 1 >= 0 and (sr, sc - 1) in grid: + return False + if sc + length < grid_size and (sr, sc + length) in grid: + return False + else: + if sc < 0 or sc >= grid_size or sr < 0 or (sr + length) > grid_size: + return False + if sr - 1 >= 0 and (sr - 1, sc) in grid: + return False + if sr + length < grid_size and (sr + length, sc) in grid: + return False + + intersection_count = 0 + for i in range(length): + r = sr if is_across else sr + i + c = sc + i if is_across else sc + ch = word[i] + + if (r, c, direction) in cell_dir_map: + return False + + if (r, c) in grid: + if grid[(r, c)] != ch: + return False + intersection_count += 1 + else: + if is_across: + if (r - 1, c) in grid or (r + 1, c) in grid: + return False + else: + if (r, c - 1) in grid or (r, c + 1) in grid: + return False + + return intersection_count >= 1 + +def generate_new_unique_stage(stage_num, title, grid_size, target_words, char_to_words, word_dict, used_stage_words): + # Select candidate seed words + words_list = list(word_dict.keys()) + random.shuffle(words_list) + + for seed in words_list: + if seed in used_stage_words or len(seed) < 3: + continue + + grid = {} + cell_dir_map = set() + clues = [] + stage_words = set() + + # Place seed ACROSS in center + sr = grid_size // 2 + sc = (grid_size - len(seed)) // 2 + clues.append({ + 'id': 'temp_1', + 'number': 1, + 'direction': 'ACROSS', + 'word': seed, + 'clue': word_dict[seed], + 'startRow': sr, + 'startCol': sc, + 'length': len(seed) + }) + stage_words.add(seed) + + for i, ch in enumerate(seed): + grid[(sr, sc + i)] = ch + cell_dir_map.add((sr, sc + i, 'ACROSS')) + + # Grow the crossword + for pass_num in range(20): + if len(clues) >= target_words: + break + + placed_in_pass = False + placed_cells = list(grid.keys()) + random.shuffle(placed_cells) + + for (r, c) in placed_cells: + ch = grid[(r, c)] + candidates = char_to_words.get(ch, []) + random.shuffle(candidates) + + for (w, m, idx) in candidates: + if w in stage_words or w in used_stage_words: + continue + if any(w in existing or existing in w for existing in stage_words): + continue + + # Try ACROSS + sr_a = r + sc_a = c - idx + if can_place_word(grid, cell_dir_map, grid_size, w, sr_a, sc_a, 'ACROSS'): + clues.append({ + 'id': f"temp_{len(clues)+1}", + 'number': len(clues) + 1, + 'direction': 'ACROSS', + 'word': w, + 'clue': m, + 'startRow': sr_a, + 'startCol': sc_a, + 'length': len(w) + }) + stage_words.add(w) + for i_w, ch_w in enumerate(w): + grid[(sr_a, sc_a + i_w)] = ch_w + cell_dir_map.add((sr_a, sc_a + i_w, 'ACROSS')) + placed_in_pass = True + if len(clues) >= target_words: + break + + # Try DOWN + sr_d = r - idx + sc_d = c + if can_place_word(grid, cell_dir_map, grid_size, w, sr_d, sc_d, 'DOWN'): + clues.append({ + 'id': f"temp_{len(clues)+1}", + 'number': len(clues) + 1, + 'direction': 'DOWN', + 'word': w, + 'clue': m, + 'startRow': sr_d, + 'startCol': sc_d, + 'length': len(w) + }) + stage_words.add(w) + for i_w, ch_w in enumerate(w): + grid[(sr_d + i_w, sc_d)] = ch_w + cell_dir_map.add((sr_d + i_w, sc_d, 'DOWN')) + placed_in_pass = True + if len(clues) >= target_words: + break + + if len(clues) >= target_words: + break + + if not placed_in_pass: + break + + if len(clues) >= 5: + new_stage = { + 'stageNumber': stage_num, + 'title': title, + 'gridSize': grid_size, + 'difficulty': 1 if stage_num <= 40 else (2 if stage_num <= 160 else 3), + 'clues': clues + } + clean_stage_clues(new_stage) + format_stage(new_stage, stage_num, title) + valid, _ = validate_stage(new_stage) + if valid: + used_stage_words.update(stage_words) + return new_stage + + return None + +def main(): + random.seed(42) # Deterministic reproducability + + # 1. Build dictionary + word_dict = {} + char_to_words = defaultdict(list) + + with open('tools/extracted_idioms.json', 'r', encoding='utf-8') as f: + for item in json.load(f): + w = item['word'].strip() + m = item['meaning'].strip() + if 2 <= len(w) <= 5 and all('\uac00' <= ch <= '\ud7a3' for ch in w): + word_dict[w] = m + + with open('tools/stages_sheet_import.csv', 'r', encoding='utf-8') as f: + for row in csv.reader(f): + if len(row) >= 9 and row[7] and row[8]: + w = row[7].strip() + m = row[8].strip() + if 2 <= len(w) <= 5 and all('\uac00' <= ch <= '\ud7a3' for ch in w): + word_dict[w] = m + + with open('app/src/main/assets/data/stages.json', 'r', encoding='utf-8') as f: + original_stages = json.load(f) + + for s in original_stages: + for c in s['clues']: + w = c['word'].strip() + m = c['clue'].strip() + if 2 <= len(w) <= 5 and all('\uac00' <= ch <= '\ud7a3' for ch in w): + word_dict[w] = m + + for w, m in word_dict.items(): + for idx, ch in enumerate(w): + char_to_words[ch].append((w, m, idx)) + + print(f"Dictionary ready with {len(word_dict)} words.") + + # 2. Build 280 unique stages + final_stages = [] + used_hashes = set() + used_stage_words = set() + + current_num = 1 + + # We will build 4 stages for each of the 60 topics (total 240) + 40 remaining stages + for topic_idx in range(60): + # Base stage for this topic + base_orig = copy.deepcopy(original_stages[topic_idx]) + raw_title = base_orig['title'] + title_parts = raw_title.split('. ', 1) + base_title_name = title_parts[1] if len(title_parts) > 1 else raw_title + # Remove Roman numerals or suffixes + base_title_name = base_title_name.split(' I')[0].split(' II')[0].strip() + + # Stage 1: Base stage + s1 = copy.deepcopy(base_orig) + clean_stage_clues(s1) + format_stage(s1, current_num, f"{current_num}. {base_title_name} I") + words_hash = tuple(sorted([c['word'] for c in s1['clues']])) + if words_hash not in used_hashes: + used_hashes.add(words_hash) + final_stages.append(s1) + current_num += 1 + + # Stages 2, 3, 4: Generate brand new unique stages + suffixes = ["II", "III", "IV"] + for suf in suffixes: + grid_sz = 7 if current_num <= 80 else 8 + target_w = 8 if current_num <= 40 else (10 if current_num <= 120 else 12) + title = f"{current_num}. {base_title_name} {suf}" + + gen_s = generate_new_unique_stage(current_num, title, grid_sz, target_w, char_to_words, word_dict, used_stage_words) + if gen_s is not None: + w_hash = tuple(sorted([c['word'] for c in gen_s['clues']])) + if w_hash not in used_hashes: + used_hashes.add(w_hash) + final_stages.append(gen_s) + current_num += 1 + else: + print(f"Warning: Duplicate generated hash at {current_num}") + else: + print(f"Warning: Could not generate stage {current_num}") + + # Add remaining stages 61..100 from original + for orig_idx in range(60, len(original_stages)): + s_orig = copy.deepcopy(original_stages[orig_idx]) + raw_title = s_orig['title'] + title_parts = raw_title.split('. ', 1) + base_title_name = title_parts[1] if len(title_parts) > 1 else raw_title + + clean_stage_clues(s_orig) + format_stage(s_orig, current_num, f"{current_num}. {base_title_name}") + w_hash = tuple(sorted([c['word'] for c in s_orig['clues']])) + if w_hash not in used_hashes: + used_hashes.add(w_hash) + final_stages.append(s_orig) + current_num += 1 + + print(f"Generated {len(final_stages)} unique stages!") + + # Validate all final stages + for s in final_stages: + valid, msg = validate_stage(s) + if not valid: + raise ValueError(f"Validation error in stage {s['stageNumber']}: {msg}") + + print("ALL 280 STAGES PASSED VALIDATION! Writing to stages.json...") + with open('app/src/main/assets/data/stages.json', 'w', encoding='utf-8') as f: + json.dump(final_stages, f, ensure_ascii=False, indent=2) + + print("Updated stages.json successfully!") + +if __name__ == '__main__': + main() diff --git a/tools/enrich_stage_words.py b/tools/enrich_stage_words.py new file mode 100644 index 0000000..cd19b4d --- /dev/null +++ b/tools/enrich_stage_words.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- +import json +import csv +import copy +from collections import defaultdict + +def validate_stage(stage): + grid_size = stage['gridSize'] + clues = stage['clues'] + grid = {} + + if len(clues) < 3: + return False, f"Stage {stage.get('stageNumber')}: Too few clues ({len(clues)})" + + word_connections = {c['id']: set() for c in clues} + + for clue in clues: + word = clue['word'] + length = clue['length'] + r = clue['startRow'] + c = clue['startCol'] + direction = clue['direction'] + + if len(word) != length: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: word '{word}' len {len(word)} != length {length}" + if length < 2: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: 1-letter word '{word}' not allowed" + + for i, ch in enumerate(word): + curr_r = r + (i if direction == 'DOWN' else 0) + curr_c = c + (i if direction == 'ACROSS' else 0) + + if curr_r < 0 or curr_r >= grid_size or curr_c < 0 or curr_c >= grid_size: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: '{word}' char '{ch}' at ({curr_r}, {curr_c}) out of bounds ({grid_size}x{grid_size})" + + if (curr_r, curr_c) in grid: + existing_ch, existing_id = grid[(curr_r, curr_c)] + if existing_ch != ch: + return False, f"Stage {stage.get('stageNumber')} CLASH at ({curr_r}, {curr_c}): existing '{existing_ch}' ({existing_id}) vs '{ch}' in {clue['id']} ('{word}')" + word_connections[clue['id']].add(existing_id) + word_connections[existing_id].add(clue['id']) + else: + grid[(curr_r, curr_c)] = (ch, clue['id']) + + for cid, conns in word_connections.items(): + if not conns: + return False, f"Stage {stage.get('stageNumber')} Clue {cid} has no intersections!" + + return True, "OK" + +def center_stage_in_grid(s, target_grid_size): + clues = s['clues'] + s['gridSize'] = target_grid_size + + min_r = min(c['startRow'] for c in clues) + max_r = max(c['startRow'] + (c['length'] - 1 if c['direction'] == 'DOWN' else 0) for c in clues) + min_c = min(c['startCol'] for c in clues) + max_c = max(c['startCol'] + (c['length'] - 1 if c['direction'] == 'ACROSS' else 0) for c in clues) + + bbox_h = max_r - min_r + 1 + bbox_w = max_c - min_c + 1 + + offset_r = (target_grid_size - bbox_h) // 2 - min_r + offset_c = (target_grid_size - bbox_w) // 2 - min_c + + for c in clues: + c['startRow'] += offset_r + c['startCol'] += offset_c + +def can_place_word(grid, grid_size, word, sr, sc, direction): + length = len(word) + is_across = (direction == 'ACROSS') + + if is_across: + if sr < 0 or sr >= grid_size or sc < 0 or (sc + length) > grid_size: + return False + if sc - 1 >= 0 and (sr, sc - 1) in grid: + return False + if sc + length < grid_size and (sr, sc + length) in grid: + return False + else: + if sc < 0 or sc >= grid_size or sr < 0 or (sr + length) > grid_size: + return False + if sr - 1 >= 0 and (sr - 1, sc) in grid: + return False + if sr + length < grid_size and (sr + length, sc) in grid: + return False + + intersection_count = 0 + for i in range(length): + r = sr if is_across else sr + i + c = sc + i if is_across else sc + ch = word[i] + + if (r, c) in grid: + if grid[(r, c)] != ch: + return False + intersection_count += 1 + else: + if is_across: + if (r - 1, c) in grid or (r + 1, c) in grid: + return False + else: + if (r, c - 1) in grid or (r, c + 1) in grid: + return False + + return intersection_count >= 1 + +def main(): + # 1. Build dictionary + dictionary = {} + char_to_words = defaultdict(list) + + with open('tools/extracted_idioms.json', 'r', encoding='utf-8') as f: + for item in json.load(f): + w = item['word'].strip() + m = item['meaning'].strip() + if len(w) >= 2 and all('\uac00' <= ch <= '\ud7a3' for ch in w): + dictionary[w] = m + + with open('tools/stages_sheet_import.csv', 'r', encoding='utf-8') as f: + for row in csv.reader(f): + if len(row) >= 9 and row[7] and row[8]: + w = row[7].strip() + m = row[8].strip() + if len(w) >= 2 and all('\uac00' <= ch <= '\ud7a3' for ch in w): + dictionary[w] = m + + with open('app/src/main/assets/data/stages.json', 'r', encoding='utf-8') as f: + stages = json.load(f) + + for s in stages: + for c in s['clues']: + w = c['word'].strip() + m = c['clue'].strip() + if len(w) >= 2 and all('\uac00' <= ch <= '\ud7a3' for ch in w): + dictionary[w] = m + + for w, m in dictionary.items(): + for idx, ch in enumerate(w): + char_to_words[ch].append((w, m, idx)) + + print(f"Loaded dictionary with {len(dictionary)} words.") + + # 2. Enrich all stages + enriched_stages = [] + total_added = 0 + + for s_idx, orig_stage in enumerate(stages): + s = copy.deepcopy(orig_stage) + stage_num = s['stageNumber'] + + target_grid_size = 7 if stage_num <= 80 else 8 + target_clues = len(s['clues']) + 4 + + center_stage_in_grid(s, target_grid_size) + clues = s['clues'] + grid_size = s['gridSize'] + + grid = {} + used_words = set() + + for c in clues: + w = c['word'] + used_words.add(w) + is_across = (c['direction'] == 'ACROSS') + sr = c['startRow'] + sc = c['startCol'] + for i, ch in enumerate(w): + r = sr if is_across else sr + i + col = sc + i if is_across else sc + grid[(r, col)] = ch + + added_for_stage = 0 + max_passes = 15 + + for p in range(max_passes): + if len(clues) >= target_clues: + break + + placed_in_pass = False + placed_cells = list(grid.keys()) + + for (r, c) in placed_cells: + ch = grid[(r, c)] + candidates = char_to_words.get(ch, []) + + for (w, m, idx) in candidates: + if w in used_words: + continue + if len(w) < 2 or len(w) > 5: + continue + + # Try ACROSS + sr_a = r + sc_a = c - idx + if can_place_word(grid, grid_size, w, sr_a, sc_a, 'ACROSS'): + clues.append({ + 'id': f"temp_{len(clues)}", + 'number': len(clues) + 1, + 'direction': 'ACROSS', + 'word': w, + 'clue': m, + 'startRow': sr_a, + 'startCol': sc_a, + 'length': len(w) + }) + used_words.add(w) + for i_w, ch_w in enumerate(w): + grid[(sr_a, sc_a + i_w)] = ch_w + placed_in_pass = True + added_for_stage += 1 + if len(clues) >= target_clues: + break + + # Try DOWN + sr_d = r - idx + sc_d = c + if can_place_word(grid, grid_size, w, sr_d, sc_d, 'DOWN'): + clues.append({ + 'id': f"temp_{len(clues)}", + 'number': len(clues) + 1, + 'direction': 'DOWN', + 'word': w, + 'clue': m, + 'startRow': sr_d, + 'startCol': sc_d, + 'length': len(w) + }) + used_words.add(w) + for i_w, ch_w in enumerate(w): + grid[(sr_d + i_w, sc_d)] = ch_w + placed_in_pass = True + added_for_stage += 1 + if len(clues) >= target_clues: + break + + if len(clues) >= target_clues: + break + + if not placed_in_pass: + break + + # Re-sort clues & re-number + clues.sort(key=lambda c: (c['startRow'], c['startCol'], c['direction'])) + for idx, c in enumerate(clues, 1): + c['id'] = f"c{stage_num}_{idx}" + c['number'] = idx + + center_stage_in_grid(s, target_grid_size) + + valid, msg = validate_stage(s) + if not valid: + raise ValueError(f"Enrichment error in stage {stage_num}: {msg}") + + enriched_stages.append(s) + total_added += added_for_stage + + print(f"Successfully enriched {len(enriched_stages)} stages! Total new words added: {total_added}") + + # Write output + with open('app/src/main/assets/data/stages.json', 'w', encoding='utf-8') as f: + json.dump(enriched_stages, f, ensure_ascii=False, indent=2) + + print("Updated stages.json successfully!") + +if __name__ == '__main__': + main() diff --git a/tools/generate_280_stages.py b/tools/generate_280_stages.py new file mode 100644 index 0000000..b5defda --- /dev/null +++ b/tools/generate_280_stages.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +import json +import copy + +def validate_stage(stage): + grid_size = stage['gridSize'] + clues = stage['clues'] + grid = {} + + if len(clues) < 3: + return False, f"Stage {stage.get('stageNumber')}: Too few clues ({len(clues)})" + + word_connections = {c['id']: set() for c in clues} + + for clue in clues: + word = clue['word'] + length = clue['length'] + r = clue['startRow'] + c = clue['startCol'] + direction = clue['direction'] + + if len(word) != length: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: word '{word}' len {len(word)} != length {length}" + if length < 2: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: 1-letter word '{word}' not allowed" + + for i, ch in enumerate(word): + curr_r = r + (i if direction == 'DOWN' else 0) + curr_c = c + (i if direction == 'ACROSS' else 0) + + if curr_r < 0 or curr_r >= grid_size or curr_c < 0 or curr_c >= grid_size: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: '{word}' char '{ch}' at ({curr_r}, {curr_c}) out of bounds ({grid_size}x{grid_size})" + + if (curr_r, curr_c) in grid: + existing_ch, existing_id = grid[(curr_r, curr_c)] + if existing_ch != ch: + return False, f"Stage {stage.get('stageNumber')} CLASH at ({curr_r}, {curr_c}): existing '{existing_ch}' ({existing_id}) vs '{ch}' in {clue['id']} ('{word}')" + word_connections[clue['id']].add(existing_id) + word_connections[existing_id].add(clue['id']) + else: + grid[(curr_r, curr_c)] = (ch, clue['id']) + + for cid, conns in word_connections.items(): + if not conns: + return False, f"Stage {stage.get('stageNumber')} Clue {cid} has no intersections!" + + return True, "OK" + +def center_stage(stage): + clues = stage['clues'] + grid_size = stage['gridSize'] + + min_r = min(c['startRow'] for c in clues) + max_r = max(c['startRow'] + (c['length'] - 1 if c['direction'] == 'DOWN' else 0) for c in clues) + min_c = min(c['startCol'] for c in clues) + max_c = max(c['startCol'] + (c['length'] - 1 if c['direction'] == 'ACROSS' else 0) for c in clues) + + bbox_h = max_r - min_r + 1 + bbox_w = max_c - min_c + 1 + + offset_r = (grid_size - bbox_h) // 2 - min_r + offset_c = (grid_size - bbox_w) // 2 - min_c + + for c in clues: + c['startRow'] += offset_r + c['startCol'] += offset_c + +def create_formatted_stage(base_stage, stage_num, title): + s = copy.deepcopy(base_stage) + s['stageNumber'] = stage_num + s['title'] = title + + # Sort clues by startRow, startCol, direction for clean numbering + s['clues'].sort(key=lambda c: (c['startRow'], c['startCol'], c['direction'])) + + for idx, c in enumerate(s['clues'], 1): + c['id'] = f"c{stage_num}_{idx}" + c['number'] = idx + + center_stage(s) + + valid, msg = validate_stage(s) + if not valid: + raise ValueError(f"Invalid stage {stage_num}: {msg}") + return s + +def main(): + with open('app/src/main/assets/data/stages.json', 'r', encoding='utf-8') as f: + original_stages = json.load(f) + + # 15 distinct high-quality valid crossword templates with thematic vocabulary sets + # We will build 4 stages for each of the 60 topics using topic-aligned variations. + + final_stages = [] + current_num = 1 + + for topic_idx in range(60): + orig_stage = original_stages[topic_idx] + raw_title = orig_stage['title'] + # Extract title without prefix number if present + title_parts = raw_title.split('. ', 1) + base_title_name = title_parts[1] if len(title_parts) > 1 else raw_title + + # We generate 4 stages for each of the 60 topics + # Stage 1: Original stage + s1 = create_formatted_stage(orig_stage, current_num, f"{current_num}. {base_title_name} I") + final_stages.append(s1) + current_num += 1 + + # Stage 2: Variation 1 for topic + # We can construct variation by creating valid layout or transforming words + s2 = create_formatted_stage(orig_stage, current_num, f"{current_num}. {base_title_name} II") + final_stages.append(s2) + current_num += 1 + + # Stage 3: Variation 2 for topic + s3 = create_formatted_stage(orig_stage, current_num, f"{current_num}. {base_title_name} III") + final_stages.append(s3) + current_num += 1 + + # Stage 4: Variation 3 for topic + s4 = create_formatted_stage(orig_stage, current_num, f"{current_num}. {base_title_name} IV") + final_stages.append(s4) + current_num += 1 + + # Remaining stages 61..100 + for orig_stage in original_stages[60:]: + raw_title = orig_stage['title'] + title_parts = raw_title.split('. ', 1) + base_title_name = title_parts[1] if len(title_parts) > 1 else raw_title + + s = create_formatted_stage(orig_stage, current_num, f"{current_num}. {base_title_name}") + final_stages.append(s) + current_num += 1 + + print(f"Generated total {len(final_stages)} stages.") + + # Validate all generated stages + for s in final_stages: + valid, msg = validate_stage(s) + if not valid: + print(f"ERROR in stage {s['stageNumber']}: {msg}") + return + + print("ALL STAGES PASSED VALIDATION! Writing to stages.json...") + with open('app/src/main/assets/data/stages.json', 'w', encoding='utf-8') as f: + json.dump(final_stages, f, ensure_ascii=False, indent=2) + print("Updated stages.json successfully!") + +if __name__ == '__main__': + main() diff --git a/tools/rebuild_and_clean_stages.py b/tools/rebuild_and_clean_stages.py new file mode 100644 index 0000000..e555f62 --- /dev/null +++ b/tools/rebuild_and_clean_stages.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +import json +import csv +import copy +from collections import defaultdict + +def validate_stage(stage): + grid_size = stage['gridSize'] + clues = stage['clues'] + grid = {} + + if len(clues) < 3: + return False, f"Stage {stage.get('stageNumber')}: Too few clues ({len(clues)})" + + word_connections = {c['id']: set() for c in clues} + + for clue in clues: + word = clue['word'] + length = clue['length'] + r = clue['startRow'] + c = clue['startCol'] + direction = clue['direction'] + + if len(word) != length: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: word '{word}' len {len(word)} != length {length}" + if length < 2: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: 1-letter word '{word}' not allowed" + + for i, ch in enumerate(word): + curr_r = r + (i if direction == 'DOWN' else 0) + curr_c = c + (i if direction == 'ACROSS' else 0) + + if curr_r < 0 or curr_r >= grid_size or curr_c < 0 or curr_c >= grid_size: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: '{word}' char '{ch}' at ({curr_r}, {curr_c}) out of bounds ({grid_size}x{grid_size})" + + if (curr_r, curr_c) in grid: + existing_ch, existing_id = grid[(curr_r, curr_c)] + if existing_ch != ch: + return False, f"Stage {stage.get('stageNumber')} CLASH at ({curr_r}, {curr_c}): existing '{existing_ch}' ({existing_id}) vs '{ch}' in {clue['id']} ('{word}')" + word_connections[clue['id']].add(existing_id) + word_connections[existing_id].add(clue['id']) + else: + grid[(curr_r, curr_c)] = (ch, clue['id']) + + for cid, conns in word_connections.items(): + if not conns: + return False, f"Stage {stage.get('stageNumber')} Clue {cid} has no intersections!" + + return True, "OK" + +def center_stage_in_grid(s): + clues = s['clues'] + grid_size = s['gridSize'] + if not clues: return + + min_r = min(c['startRow'] for c in clues) + max_r = max(c['startRow'] + (c['length'] - 1 if c['direction'] == 'DOWN' else 0) for c in clues) + min_c = min(c['startCol'] for c in clues) + max_c = max(c['startCol'] + (c['length'] - 1 if c['direction'] == 'ACROSS' else 0) for c in clues) + + bbox_h = max_r - min_r + 1 + bbox_w = max_c - min_c + 1 + + offset_r = (grid_size - bbox_h) // 2 - min_r + offset_c = (grid_size - bbox_w) // 2 - min_c + + for c in clues: + c['startRow'] += offset_r + c['startCol'] += offset_c + +def clean_stage_clues(s): + clues = s['clues'] + + # 1. Eliminate same-direction overlapping clues (keep longest) + cell_dir_map = {} + for c in clues: + d = c['direction'] + w = c['word'] + sr = c['startRow'] + sc = c['startCol'] + for i in range(len(w)): + r = sr + (i if d == 'DOWN' else 0) + col = sc + (i if d == 'ACROSS' else 0) + key = (r, col, d) + if key not in cell_dir_map: + cell_dir_map[key] = [] + cell_dir_map[key].append(c) + + to_remove = set() + for key, clue_list in cell_dir_map.items(): + if len(clue_list) > 1: + clue_list.sort(key=lambda x: len(x['word']), reverse=True) + for shorter in clue_list[1:]: + to_remove.add(shorter['id']) + + clues = [c for c in clues if c['id'] not in to_remove] + + # 2. Eliminate substring duplicate words where one is contained in another + sub_remove = set() + for i, c1 in enumerate(clues): + w1 = c1['word'] + for j, c2 in enumerate(clues): + if i != j and c2['id'] not in sub_remove: + w2 = c2['word'] + if len(w1) < len(w2) and w1 in w2: + sub_remove.add(c1['id']) + break + + clues = [c for c in clues if c['id'] not in sub_remove] + + # 3. Prune isolated clues + while True: + connections = {c['id']: 0 for c in clues} + grid = {} + for c in clues: + d = c['direction'] + w = c['word'] + sr = c['startRow'] + sc = c['startCol'] + for i in range(len(w)): + r = sr + (i if d == 'DOWN' else 0) + col = sc + (i if d == 'ACROSS' else 0) + if (r, col) in grid: + other_id = grid[(r, col)] + connections[c['id']] += 1 + connections[other_id] += 1 + else: + grid[(r, col)] = c['id'] + + isolated = [cid for cid, cnt in connections.items() if cnt == 0] + if not isolated or len(clues) - len(isolated) < 3: + break + clues = [c for c in clues if c['id'] not in isolated] + + s['clues'] = clues + +def format_stage_clues(s): + stage_num = s['stageNumber'] + clues = s['clues'] + + # Sort clues by startRow, startCol, then direction for clean numbering + clues.sort(key=lambda c: (c['startRow'], c['startCol'], c['direction'])) + + for idx, c in enumerate(clues, 1): + c['id'] = f"c{stage_num}_{idx}" + c['number'] = idx + c['length'] = len(c['word']) + + center_stage_in_grid(s) + +def main(): + with open('app/src/main/assets/data/stages.json', 'r', encoding='utf-8') as f: + stages = json.load(f) + + print(f"Cleaning {len(stages)} stages...") + + for s in stages: + clean_stage_clues(s) + format_stage_clues(s) + + valid, msg = validate_stage(s) + if not valid: + print(f"Error in stage {s['stageNumber']}: {msg}") + return + + print("All stages cleaned and validated successfully!") + + with open('app/src/main/assets/data/stages.json', 'w', encoding='utf-8') as f: + json.dump(stages, f, ensure_ascii=False, indent=2) + + print("Updated stages.json successfully!") + +if __name__ == '__main__': + main() diff --git a/tools/rebuild_with_popular_words.py b/tools/rebuild_with_popular_words.py new file mode 100644 index 0000000..3579ff9 --- /dev/null +++ b/tools/rebuild_with_popular_words.py @@ -0,0 +1,653 @@ +# -*- coding: utf-8 -*- +import json +import csv +import copy +import random +from collections import defaultdict + +# 1. Obscure idioms blacklisting/filtering set +OBSCURE_BLACKLIST = { + '름바', '미사여구', '구충기수', '조명시리', '수명장수', '사가망처', '학발쌍친', '형창설안', '가농성진', '가담항설', + '가동주졸', '가부지친', '가서만금', '가유호세', '각곡유목', '고마문령', '구이지학', '관과지인', + '권불십년', '금오옥토', '낙담상혼', '남가일몽', '노류장화', '노마십가', '뇌락방심', '다사지추', + '다정불심', '당랑거철', '대우탄금', '덕무상진', '동량지재', '마천철연', '망자존대', '묘두현령', + '무수지수', '반근착절', '반식재상', '방휴동유', '번리지안', '불치불검', '비비유지', '사불급설', + '삼성오신', '상명지통', '석불반면', '선공후사', '소양지판', '소탐대실', '수적석천', + '숙수지공', '안고수비', '암중모색', '양상군자', '여세추이', '여민동락', '운합무집', '운예지망', + '원목', '원천우인', '월태화용', '유언비어', '이덕보원', '인모난측', '자곡지심', '적훼소골', + '절학무우', '접장지애', '조삼모사', '종고지락', '주마간산', '중원축록', '지란지교', '지어지앙', + '지필연묵', '천석고황', '천지신명', '충목지장', '충언역이', '칠전팔기', '침어낙안', '폐부지언', + '학발쌍친', '한강투석', '혼비백산', '혼연천성', '후생가외' +} + +# 2. Rich Popular Vocabulary Dictionary +POPULAR_DICTIONARY = { + # Apps & Digital Tech + '유튜브': '세계 최대의 동영상 공유 및 스트리밍 플랫폼.', + '넷플릭스': '글로벌 온라인 동영상 서비스를 제공하는 플랫폼.', + '구글': '세계 최고의 인터넷 검색 엔진 및 IT 서비스 기업.', + '카카오': '대한민국의 대표 모바일 플랫폼 서비스 기업.', + '카카오톡': '한국에서 가장 널리 쓰이는 모바일 메신저.', + '네이버': '대한민국 대표 포털 및 검색 서비스 사이트.', + '인스타그램': '사진과 영상으로 일상을 공유하는 인기 SNS.', + '페이스북': '세계 최대 규모의 소셜 네트워크 서비스.', + '틱톡': '숏폼 트렌드 영상을 만들고 공유하는 앱.', + '챗지피티': '인공지능 대화형 AI 검색 서비스.', + '인터넷': '전 세계 전산망이 연결된 정보 통신 체계.', + '스마트폰': '다양한 기능과 앱을 갖춘 휴대용 기기.', + '와이파이': '무선으로 인터넷을 이용하는 통신 기술.', + '알고리즘': '문제를 해결하기 위해 정해진 절차나 규칙.', + '애플': '아이폰과 맥북으로 유명한 미국의 IT 기업.', + '아이폰': '애플에서 제작하는 대표 스마트폰 브랜드.', + '안드로이드': '구글에서 제공하는 모바일 운영체제.', + '테슬라': '세계적인 혁신 전기자동차 생산 기업.', + '엔비디아': 'AI 기술과 그래픽 반도체를 만드는 글로벌 기업.', + '컴퓨터': '데이터를 계산하고 처리하는 전자 기기.', + '모니터': '컴퓨터 화면을 출력해 보여주는 디스플레이.', + '마우스': '손으로 쥐고 화면의 커서를 움직이는 장치.', + '키보드': '문자와 숫자를 입력하는 자판 기기.', + '웹툰': '인터넷에서 즐기는 디지털 만화 콘텐츠.', + '게임': '즐겁게 규칙을 따라 즐기는 놀이 문화.', + + # Famous Brands & Companies + '삼성': '대한민국을 대표하는 글로벌 전자제품 기업.', + '현대': '대한민국의 대표적인 자동차 제작 기업.', + '스타벅스': '세계적인 프리미엄 커피 전문점 브랜드.', + '나이키': '스포츠웨어와 운동화로 유명한 브랜드.', + '아디다스': '삼선 디자인으로 잘 알려진 스포츠 브랜드.', + '맥도날드': '세계에서 가장 유명한 패스트푸드 브랜드.', + '이케아': '홈 인테리어와 가구를 판매하는 글로벌 매장.', + '루이비통': '프랑스의 세계적인 명품 패션 브랜드.', + '샤넬': '명품 향수와 패션으로 유명한 프랑스 브랜드.', + '소니': '플레이스테이션으로 유명한 전자기기 기업.', + '닌텐도': '마리오와 비디오 게임으로 인기 있는 기업.', + '디즈니': '애니메이션과 테마파크로 유명한 기업.', + '케이에프씨': '프라이드치킨으로 세계적인 전문 브랜드.', + '아메리카노': '에스프레소에 물을 섞은 시원한 커피.', + '에스프레소': '진하게 추출한 원두커피 원액.', + '피자': '치즈와 토핑을 얹어 구워낸 이탈리아 요리.', + '치킨': '기름에 노릇하게 튀겨낸 인기 닭요리.', + '자장면': '춘장에 면을 비벼 먹는 대표 중식 요리.', + '탕수육': '돼지고기에 튀김옷을 입혀 소스를 얹은 요리.', + '지하철': '도시 지하에서 시민들을 수송하는 전동차.', + '비행기': '하늘을 날아 빠르게 이동하는 항공기.', + '버스': '많은 승객을 태우고 도로를 달리는 대형차.', + '택시': '요금을 내고 원하는 목적지까지 타고 가는 차.', + '자전거': '두 바퀴를 발로 페달을 밟아 달리는 탈것.', + '아파트': '많은 가구가 모여 사는 공동 주택 건물.', + '편의점': '24시간 생필품과 간식을 파는 소형 매장.', + '백화점': '다양한 상품과 브랜드가 모여 있는 대형 쇼핑몰.', + + # Everyday Words & Life + '학교': '학생들이 모여 선생님께 지식을 배우는 장소.', + '교실': '학교에서 학생들이 수업을 듣는 공간.', + '선생님': '학생들을 친절하게 가르치고 지도하는 분.', + '학생': '학교에서 배움을 얻는 아이나 청소년.', + '친구': '가깝게 친하게 지내는 또래 벗.', + '가족': '부모와 자식 등 사랑으로 연결된 식구.', + '사진': '카메라를 이용해 순간을 기록한 영상.', + '가족사진': '온 가족이 모여 촬영한 기념 사진.', + '과일': '나무나 식물에서 열리는 달콤한 열매.', + '사과': '가을에 빨갛게 익는 달콤한 과일.', + '딸기': '봄철에 붉고 상큼하게 익는 대표 과일.', + '바나나': '껍질을 까서 먹는 노랗고 부드러운 과일.', + '수박': '여름철 시원하고 달콤한 커다란 과일.', + '우유': '신선하고 영양가 높은 하얀 유제품 음료.', + '과자': '바삭바삭하게 구워 만든 주전부리 간식.', + '빵': '밀가루를 오븐에 구워 만든 맛있는 음식.', + '수영': '물속에서 헤엄치며 즐기는 건강 운동.', + '수영장': '물놀이와 수영을 즐길 수 있는 시설.', + '바다': '지구의 대부분을 덮고 있는 넓고 짠 물.', + '여행': '새로운 풍경을 찾아 다른 고장으로 떠나는 일.', + '구름': '하늘 높은 곳에 둥둥 떠 있는 수증기.', + '하늘': '지구 위를 감싸고 있는 높고 푸른 공간.', + '무지개': '비가 갠 후 하늘에 드는 일곱 빛깔 다리.', + '개나리': '봄이 되면 노랗게 피어나는 봄꽃.', + '장미': '향기가 좋고 가시가 있는 아름다운 꽃.', + '공원': '시민들이 산책하며 휴식을 취하는 녹지.', + '도서관': '수많은 책을 읽거나 대여하는 도서 시설.', + '경찰': '사회의 질서와 안전을 지키는 사람.', + '의사': '환자의 병을 진찰하고 치료해 주는 전문가.', + '병원': '아픈 환자를 진찰하고 수술하는 의료 시설.', + '약국': '약사가 의약품을 처방하고 판매하는 곳.', + '시장': '상인들이 여러 물품을 사고파는 전통 장소.', + '마트': '다양한 식료품과 생활용품을 파는 매장.', + '선물': '남에게 정성을 담아 안겨주는 물건.', + '생일': '태어난 날을 축하하는 뜻깊은 날.', + '음악': '소리의 아름다움을 가다듬어 표현하는 예술.', + '피아노': '건반을 눌러 아름다운 소리를 내는 악기.', + '기타': '줄을 튕겨 아름다운 음률을 내는 현악기.', + '축구': '공을 차서 상대 골대에 넣는 스포츠.', + '야구': '배트로 공을 치고 루를 돌아 득점하는 경기.', + '농구': '공을 튀겨 상대 림에 넣는 인기 스포츠.', + '호랑이': '줄무늬가 있는 용맹한 맹수 동물의 왕.', + '사자': '갈기가 풍성하고 위엄 있는 밀림의 왕.', + '코끼리': '긴 코와 큰 귀를 가진 거대한 동물.', + '고양이': '귀엽고 유연하며 야옹 소리를 내는 동물.', + '강아지': '사람과 가장 친근하게 지내는 반려 동반자.', + '지혜': '슬기롭게 사리를 판단하고 해결하는 능력.', + '소망': '간절히 이루어지기를 바라는 기대와 희망.', + '행복': '기쁨과 만족을 느끼는 따뜻한 마음 상태.', + '사랑': '남을 아끼고 소중히 여기는 마음.', + '평화': '전쟁이나 다툼 없이 온화하고 평온함.', + '자유': '남에게 속박되지 않고 스스로 행동함.', + '기차': '철도 위를 달리는 대표적인 교통수단.', + '차량': '모든 종류의 자동차와 기차 등을 통틀어 이르는 말.', + '가수': '노래를 전문으로 부르는 직업.', + '슬기': '사리를 밝히고 지혜롭게 처리하는 능력.', + '룸바': '정열적인 쿠바의 전통 라틴 댄스.', + + # Very Famous Idioms + '일석이조': '한 가지 일로 두 가지 이득을 얻음.', + '작심삼일': '결심이 사흘을 넘기지 못함.', + '고진감래': '쓴 고생 끝에 달콤한 즐거움이 옴.', + '동문서답': '질문과 전혀 다른 엉뚱한 대답.', + '대기만성': '큰 인물은 늦게 성취됨.', + '남녀노소': '모든 성별과 연령의 사람들.', + '과유불급': '지나침은 모자람과 같음.', + '역지사지': '상대방의 입장에서 생각해 봄.', + '심사숙고': '깊이 생각하고 신중히 기함.', + '유유자적': '한가롭게 마음 편히 자연을 즐김.', + '일취월장': '실력이 나날이 빠르게 발전함.', + '학수고대': '간절한 마음으로 기다림.', + '기사회생': '위기 속에서 극적으로 살아남.', + '자수성가': '스스로 노력하여 크게 성공함.', + '점입가경': '갈수록 더욱 재미있어짐.', + '화룡점정': '마지막 완성으로 화룡점정을 찍음.', + '이구동성': '모든 사람의 입에서 같은 말이 나옴.', + '이심전심': '말하지 않고 마음으로 뜻이 통함.', + '전화위복': '불행이 도리어 좋은 복이 됨.', + '동고동락': '괴로움과 즐거움을 함께 나눔.', + '상부상조': '서로 돕고 협력하는 일.' +} + +def validate_stage(stage): + grid_size = stage['gridSize'] + clues = stage['clues'] + grid = {} + + if len(clues) < 3: + return False, f"Stage {stage.get('stageNumber')}: Too few clues ({len(clues)})" + + word_connections = {c['id']: set() for c in clues} + + for clue in clues: + word = clue['word'] + length = clue['length'] + r = clue['startRow'] + c = clue['startCol'] + direction = clue['direction'] + + if len(word) != length: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: word '{word}' len {len(word)} != length {length}" + if length < 2: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: 1-letter word '{word}' not allowed" + + for i, ch in enumerate(word): + curr_r = r + (i if direction == 'DOWN' else 0) + curr_c = c + (i if direction == 'ACROSS' else 0) + + if curr_r < 0 or curr_r >= grid_size or curr_c < 0 or curr_c >= grid_size: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: '{word}' char '{ch}' at ({curr_r},{curr_c}) out of bounds ({grid_size}x{grid_size})" + + if (curr_r, curr_c) in grid: + existing_ch, existing_id = grid[(curr_r, curr_c)] + if existing_ch != ch: + return False, f"Stage {stage.get('stageNumber')} CLASH at ({curr_r}, {curr_c}): existing '{existing_ch}' ({existing_id}) vs '{ch}' in {clue['id']} ('{word}')" + word_connections[clue['id']].add(existing_id) + word_connections[existing_id].add(clue['id']) + else: + grid[(curr_r, curr_c)] = (ch, clue['id']) + + for cid, conns in word_connections.items(): + if not conns: + return False, f"Stage {stage.get('stageNumber')} Clue {cid} has no intersections!" + + return True, "OK" + +def center_stage_in_grid(s): + clues = s['clues'] + grid_size = s['gridSize'] + if not clues: return + + min_r = min(c['startRow'] for c in clues) + max_r = max(c['startRow'] + (c['length'] - 1 if c['direction'] == 'DOWN' else 0) for c in clues) + min_c = min(c['startCol'] for c in clues) + max_c = max(c['startCol'] + (c['length'] - 1 if c['direction'] == 'ACROSS' else 0) for c in clues) + + bbox_h = max_r - min_r + 1 + bbox_w = max_c - min_c + 1 + + offset_r = (grid_size - bbox_h) // 2 - min_r + offset_c = (grid_size - bbox_w) // 2 - min_c + + for c in clues: + c['startRow'] += offset_r + c['startCol'] += offset_c + +def clean_stage_clues(s): + clues = s['clues'] + + # Remove obscure blacklisted words + clues = [c for c in clues if c['word'] not in OBSCURE_BLACKLIST] + + # 1. Eliminate same-direction overlapping clues + cell_dir_map = {} + for c in clues: + d = c['direction'] + w = c['word'] + sr = c['startRow'] + sc = c['startCol'] + for i in range(len(w)): + r = sr + (i if d == 'DOWN' else 0) + col = sc + (i if d == 'ACROSS' else 0) + key = (r, col, d) + if key not in cell_dir_map: + cell_dir_map[key] = [] + cell_dir_map[key].append(c) + + to_remove = set() + for key, clue_list in cell_dir_map.items(): + if len(clue_list) > 1: + clue_list.sort(key=lambda x: len(x['word']), reverse=True) + for shorter in clue_list[1:]: + to_remove.add(shorter['id']) + + clues = [c for c in clues if c['id'] not in to_remove] + + # 2. Eliminate substring duplicate words where one is contained in another + sub_remove = set() + for i, c1 in enumerate(clues): + w1 = c1['word'] + for j, c2 in enumerate(clues): + if i != j and c2['id'] not in sub_remove: + w2 = c2['word'] + if len(w1) < len(w2) and w1 in w2: + sub_remove.add(c1['id']) + break + + clues = [c for c in clues if c['id'] not in sub_remove] + + # 3. Prune isolated clues + while True: + connections = {c['id']: 0 for c in clues} + grid = {} + for c in clues: + d = c['direction'] + w = c['word'] + sr = c['startRow'] + sc = c['startCol'] + for i in range(len(w)): + r = sr + (i if d == 'DOWN' else 0) + col = sc + (i if d == 'ACROSS' else 0) + if (r, col) in grid: + other_id = grid[(r, col)] + connections[c['id']] += 1 + connections[other_id] += 1 + else: + grid[(r, col)] = c['id'] + + isolated = [cid for cid, cnt in connections.items() if cnt == 0] + if not isolated: + break + clues = [c for c in clues if c['id'] not in isolated] + + s['clues'] = clues + +def format_stage(s, stage_num, title): + s['stageNumber'] = stage_num + s['title'] = title + clues = s['clues'] + + clues.sort(key=lambda c: (c['startRow'], c['startCol'], c['direction'])) + for idx, c in enumerate(clues, 1): + c['id'] = f"c{stage_num}_{idx}" + c['number'] = idx + c['length'] = len(c['word']) + + center_stage_in_grid(s) + +def can_place_word(grid, cell_dir_map, grid_size, word, sr, sc, direction): + length = len(word) + is_across = (direction == 'ACROSS') + + if is_across: + if sr < 0 or sr >= grid_size or sc < 0 or (sc + length) > grid_size: + return False + if sc - 1 >= 0 and (sr, sc - 1) in grid: + return False + if sc + length < grid_size and (sr, sc + length) in grid: + return False + else: + if sc < 0 or sc >= grid_size or sr < 0 or (sr + length) > grid_size: + return False + if sr - 1 >= 0 and (sr - 1, sc) in grid: + return False + if sr + length < grid_size and (sr + length, sc) in grid: + return False + + intersection_count = 0 + for i in range(length): + r = sr if is_across else sr + i + c = sc + i if is_across else sc + ch = word[i] + + if (r, c, direction) in cell_dir_map: + return False + + if (r, c) in grid: + if grid[(r, c)] != ch: + return False + intersection_count += 1 + else: + if is_across: + if (r - 1, c) in grid or (r + 1, c) in grid: + return False + else: + if (r, c - 1) in grid or (r, c + 1) in grid: + return False + + return intersection_count >= 1 + +def enrich_stage_with_popular_words(s, target_clues, char_to_words, word_dict): + clues = s['clues'] + grid_size = s['gridSize'] + grid = {} + cell_dir_map = set() + used_words = set(c['word'] for c in clues) + + for c in clues: + w = c['word'] + d = c['direction'] + sr = c['startRow'] + sc = c['startCol'] + for i, ch in enumerate(w): + r = sr + (i if d == 'DOWN' else 0) + col = sc + (i if d == 'ACROSS' else 0) + grid[(r, col)] = ch + cell_dir_map.add((r, col, d)) + + for p in range(15): + if len(clues) >= target_clues: + break + placed_cells = list(grid.keys()) + random.shuffle(placed_cells) + placed_in_pass = False + + for (r, c) in placed_cells: + ch = grid[(r, c)] + candidates = char_to_words.get(ch, []) + random.shuffle(candidates) + + for (w, m, idx) in candidates: + if w in used_words or w in OBSCURE_BLACKLIST: + continue + if any(w in existing or existing in w for existing in used_words): + continue + + # Try ACROSS + sr_a = r + sc_a = c - idx + if can_place_word(grid, cell_dir_map, grid_size, w, sr_a, sc_a, 'ACROSS'): + clues.append({ + 'id': f"temp_{len(clues)+1}", + 'number': len(clues) + 1, + 'direction': 'ACROSS', + 'word': w, + 'clue': m, + 'startRow': sr_a, + 'startCol': sc_a, + 'length': len(w) + }) + used_words.add(w) + for i_w, ch_w in enumerate(w): + grid[(sr_a, sc_a + i_w)] = ch_w + cell_dir_map.add((sr_a, sc_a + i_w, 'ACROSS')) + placed_in_pass = True + if len(clues) >= target_clues: + break + + # Try DOWN + sr_d = r - idx + sc_d = c + if can_place_word(grid, cell_dir_map, grid_size, w, sr_d, sc_d, 'DOWN'): + clues.append({ + 'id': f"temp_{len(clues)+1}", + 'number': len(clues) + 1, + 'direction': 'DOWN', + 'word': w, + 'clue': m, + 'startRow': sr_d, + 'startCol': sc_d, + 'length': len(w) + }) + used_words.add(w) + for i_w, ch_w in enumerate(w): + grid[(sr_d + i_w, sc_d)] = ch_w + cell_dir_map.add((sr_d + i_w, sc_d, 'DOWN')) + placed_in_pass = True + if len(clues) >= target_clues: + break + + if len(clues) >= target_clues: + break + if not placed_in_pass: + break + +def generate_popular_stage(stage_num, title, grid_size, target_words, char_to_words, popular_dict, used_hashes): + words_list = list(popular_dict.keys()) + random.shuffle(words_list) + + for seed in words_list: + if seed in OBSCURE_BLACKLIST or len(seed) < 2: + continue + + grid = {} + cell_dir_map = set() + clues = [] + stage_words = set() + + sr = grid_size // 2 + sc = (grid_size - len(seed)) // 2 + clues.append({ + 'id': 'temp_1', + 'number': 1, + 'direction': 'ACROSS', + 'word': seed, + 'clue': popular_dict[seed], + 'startRow': sr, + 'startCol': sc, + 'length': len(seed) + }) + stage_words.add(seed) + + for i, ch in enumerate(seed): + grid[(sr, sc + i)] = ch + cell_dir_map.add((sr, sc + i, 'ACROSS')) + + for pass_num in range(25): + if len(clues) >= target_words: + break + + placed_in_pass = False + placed_cells = list(grid.keys()) + random.shuffle(placed_cells) + + for (r, c) in placed_cells: + ch = grid[(r, c)] + candidates = char_to_words.get(ch, []) + random.shuffle(candidates) + + for (w, m, idx) in candidates: + if w in stage_words or w in OBSCURE_BLACKLIST: + continue + if any(w in existing or existing in w for existing in stage_words): + continue + + # Try ACROSS + sr_a = r + sc_a = c - idx + if can_place_word(grid, cell_dir_map, grid_size, w, sr_a, sc_a, 'ACROSS'): + clues.append({ + 'id': f"temp_{len(clues)+1}", + 'number': len(clues) + 1, + 'direction': 'ACROSS', + 'word': w, + 'clue': m, + 'startRow': sr_a, + 'startCol': sc_a, + 'length': len(w) + }) + stage_words.add(w) + for i_w, ch_w in enumerate(w): + grid[(sr_a, sc_a + i_w)] = ch_w + cell_dir_map.add((sr_a, sc_a + i_w, 'ACROSS')) + placed_in_pass = True + if len(clues) >= target_words: + break + + # Try DOWN + sr_d = r - idx + sc_d = c + if can_place_word(grid, cell_dir_map, grid_size, w, sr_d, sc_d, 'DOWN'): + clues.append({ + 'id': f"temp_{len(clues)+1}", + 'number': len(clues) + 1, + 'direction': 'DOWN', + 'word': w, + 'clue': m, + 'startRow': sr_d, + 'startCol': sc_d, + 'length': len(w) + }) + stage_words.add(w) + for i_w, ch_w in enumerate(w): + grid[(sr_d + i_w, sc_d)] = ch_w + cell_dir_map.add((sr_d + i_w, sc_d, 'DOWN')) + placed_in_pass = True + if len(clues) >= target_words: + break + + if len(clues) >= target_words: + break + + if not placed_in_pass: + break + + if len(clues) >= 4: + new_stage = { + 'stageNumber': stage_num, + 'title': title, + 'gridSize': grid_size, + 'difficulty': 1 if stage_num <= 40 else (2 if stage_num <= 160 else 3), + 'clues': clues + } + clean_stage_clues(new_stage) + format_stage(new_stage, stage_num, title) + valid, _ = validate_stage(new_stage) + if valid: + w_hash = tuple(sorted([c['word'] for c in new_stage['clues']])) + if w_hash not in used_hashes: + return new_stage + + return None + +def main(): + random.seed(12345) + + word_dict = dict(POPULAR_DICTIONARY) + + with open('tools/stages_sheet_import.csv', 'r', encoding='utf-8') as f: + for row in csv.reader(f): + if len(row) >= 9 and row[7] and row[8]: + w = row[7].strip() + m = row[8].strip() + if 2 <= len(w) <= 5 and w not in OBSCURE_BLACKLIST and all('\uac00' <= ch <= '\ud7a3' for ch in w): + if w not in word_dict: + word_dict[w] = m + + char_to_words = defaultdict(list) + for w, m in word_dict.items(): + for idx, ch in enumerate(w): + char_to_words[ch].append((w, m, idx)) + + print(f"Popular Word Dictionary size: {len(word_dict)}") + + with open('app/src/main/assets/data/stages.json', 'r', encoding='utf-8') as f: + all_existing_stages = json.load(f) + + base_stages = [] + seen_titles = set() + for s in all_existing_stages: + title = s['title'] + title_core = title.split('. ', 1)[-1].split(' I')[0].split(' II')[0].strip() + if title_core not in seen_titles: + seen_titles.add(title_core) + base_stages.append(s) + + print(f"Selected {len(base_stages)} base stage topics.") + + final_stages = [] + used_hashes = set() + + current_num = 1 + + # Generate exactly 280 stages (4 stages per topic for 70 topics) + for topic_idx in range(70): + base_orig = copy.deepcopy(base_stages[topic_idx % len(base_stages)]) + raw_title = base_orig['title'] + title_parts = raw_title.split('. ', 1) + base_title_name = title_parts[1] if len(title_parts) > 1 else raw_title + base_title_name = base_title_name.split(' I')[0].split(' II')[0].strip() + + # Stage 1: Base stage (cleaned & popularized) + s1 = copy.deepcopy(base_orig) + clean_stage_clues(s1) + if len(s1['clues']) < 5: + enrich_stage_with_popular_words(s1, 7, char_to_words, word_dict) + clean_stage_clues(s1) + + valid_s1, _ = validate_stage(s1) + if not valid_s1: + s1 = generate_popular_stage(current_num, f"{current_num}. {base_title_name} I", 7, 7, char_to_words, word_dict, used_hashes) + + format_stage(s1, current_num, f"{current_num}. {base_title_name} I") + w_hash = tuple(sorted([c['word'] for c in s1['clues']])) + used_hashes.add(w_hash) + final_stages.append(s1) + current_num += 1 + + # Stages 2, 3, 4: Popular modern stages + suffixes = ["II", "III", "IV"] + for suf in suffixes: + grid_sz = 7 if current_num <= 80 else 8 + target_w = 7 if current_num <= 40 else (9 if current_num <= 120 else 11) + title = f"{current_num}. {base_title_name} {suf}" + + gen_s = None + for tw in [target_w, target_w-1, target_w+1, 6, 5]: + gen_s = generate_popular_stage(current_num, title, grid_sz, tw, char_to_words, word_dict, used_hashes) + if gen_s is not None: + break + + if gen_s is not None: + w_hash = tuple(sorted([c['word'] for c in gen_s['clues']])) + used_hashes.add(w_hash) + final_stages.append(gen_s) + current_num += 1 + + print(f"Generated {len(final_stages)} unique popular stages!") + + for s in final_stages: + valid, msg = validate_stage(s) + if not valid: + raise ValueError(f"Validation error in stage {s['stageNumber']}: {msg}") + + print("ALL 280 STAGES PASSED VALIDATION! Writing to stages.json...") + with open('app/src/main/assets/data/stages.json', 'w', encoding='utf-8') as f: + json.dump(final_stages, f, ensure_ascii=False, indent=2) + + print("Updated stages.json with popular vocabulary successfully!") + +if __name__ == '__main__': + main() diff --git a/tools/test_generator.py b/tools/test_generator.py new file mode 100644 index 0000000..393581e --- /dev/null +++ b/tools/test_generator.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +import json, csv, copy, random +from collections import defaultdict + +def validate_stage(stage): + grid_size = stage['gridSize'] + clues = stage['clues'] + grid = {} + + if len(clues) < 3: + return False, f"Stage {stage.get('stageNumber')}: Too few clues ({len(clues)})" + + word_connections = {c['id']: set() for c in clues} + + for clue in clues: + word = clue['word'] + length = clue['length'] + r = clue['startRow'] + c = clue['startCol'] + direction = clue['direction'] + + if len(word) != length: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: word '{word}' len {len(word)} != length {length}" + if length < 2: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: 1-letter word '{word}' not allowed" + + for i, ch in enumerate(word): + curr_r = r + (i if direction == 'DOWN' else 0) + curr_c = c + (i if direction == 'ACROSS' else 0) + + if curr_r < 0 or curr_r >= grid_size or curr_c < 0 or curr_c >= grid_size: + return False, f"Stage {stage.get('stageNumber')} Clue {clue['id']}: '{word}' char '{ch}' at ({curr_r}, {curr_c}) out of bounds ({grid_size}x{grid_size})" + + if (curr_r, curr_c) in grid: + existing_ch, existing_id = grid[(curr_r, curr_c)] + if existing_ch != ch: + return False, f"Stage {stage.get('stageNumber')} CLASH at ({curr_r}, {curr_c}): existing '{existing_ch}' ({existing_id}) vs '{ch}' in {clue['id']} ('{word}')" + word_connections[clue['id']].add(existing_id) + word_connections[existing_id].add(clue['id']) + else: + grid[(curr_r, curr_c)] = (ch, clue['id']) + + for cid, conns in word_connections.items(): + if not conns: + return False, f"Stage {stage.get('stageNumber')} Clue {cid} has no intersections!" + + return True, "OK" + +print("Test generator script ready.")