유틸리티 및 화면 플립 효과 추가
This commit is contained in:
Binary file not shown.
@@ -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<List<ConfettiParticle>>(emptyList()) }
|
||||
var animationTime by remember { mutableFloatStateOf(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
|
||||
)
|
||||
)
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user