자 시작화면을 만들었으니 이번엔 스도쿠 문제를 제작해보도록 하자
사실 가장 좋은 문제 제작 방식은 정해놓은 로직에 따라 문제와 답을 만들고, Json 형식으로 저장한 뒤
스테이지에 따라 미리 만들어 놓은 문제를 불러와서 진열만 하면된다
예를들어
Stage : 1
Q : 32984000093489019000010985000009004 > 81개의 숫자들. 빈칸은 0으로 처리
A : 32984572093489019283510985741309854 > 문제에 대한 답
Level : Easy
이런식으로 81개의 숫자를 문제와 답으로 저장해둔 뒤 스테이지 번호에 따라 불러오기만 하면 그만이다
그럼 한번 만들어보자
using System;
using System.Collections.Generic;
[Serializable]
public class SudokuPuzzle
{
public string difficulty; // "Easy", "Medium", "Hard"
public string problem; // 빈칸이 0으로 뚫린 81자리 문자열
public string solution; // 꽉 찬 정답 81자리 문자열
}
[Serializable]
public class SudokuDatabase
{
public List<SudokuPuzzle> puzzles = new List<SudokuPuzzle>();
}
일단 문제를 저장할 데이터를 먼저 만들어준다
using System.Collections.Generic;
using UnityEngine;
public class SudokuGenerator
{
private int[,] board = new int[9, 9];
private System.Random rand = new System.Random();
public enum Difficulty { Invalid, Easy, Medium, Hard }
// 1. 완성된 무작위 맵 생성
public int[,] GenerateFullBoard()
{
board = new int[9, 9];
FillBoard(0, 0);
return (int[,])board.Clone();
}
private bool FillBoard(int row, int col)
{
if (col == 9) { row++; col = 0; }
if (row == 9) return true;
if (board[row, col] != 0) return FillBoard(row, col + 1);
List<int> numbers = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
Shuffle(numbers);
foreach (int num in numbers)
{
if (IsValid(board, row, col, num))
{
board[row, col] = num;
if (FillBoard(row, col + 1)) return true;
board[row, col] = 0;
}
}
return false;
}
// 2. 구멍 뚫기 및 난이도 판별 (안전한 순차 지우기 방식으로 수정)
public Difficulty CreatePuzzle(int[,] fullBoard, out int[,] puzzleBoard)
{
puzzleBoard = (int[,])fullBoard.Clone();
// 81개의 모든 좌표(0~80)를 리스트에 넣고 무작위로 섞습니다.
List<int> positions = new List<int>();
for (int i = 0; i < 81; i++) positions.Add(i);
Shuffle(positions);
// 목표 빈칸 개수 (너무 높으면 생성에 오래 걸릴 수 있으므로 40~52 정도로 타협)
int targetHoles = rand.Next(40, 53);
int currentHoles = 0;
// 섞인 좌표를 하나씩 꺼내어 지워봅니다.
foreach (int pos in positions)
{
if (currentHoles >= targetHoles) break; // 목표치 도달 시 중단
int r = pos / 9;
int c = pos % 9;
int backup = puzzleBoard[r, c];
puzzleBoard[r, c] = 0; // 일단 0으로 지워봄
// 지웠는데 유일해가 아니게 되어버리면(정답이 2개 이상 발생)
if (CountSolutions((int[,])puzzleBoard.Clone()) != 1)
{
puzzleBoard[r, c] = backup; // 원상복구
}
else
{
currentHoles++; // 유일해가 유지되면 지운 상태 확정
}
}
return EvaluateDifficulty(puzzleBoard, fullBoard);
}
// 3. 논리적 난이도 판별기 (Logical Solver)
private Difficulty EvaluateDifficulty(int[,] puzzle, int[,] solution)
{
// 백트래킹(찍기) 유일해 검사: 정답이 1개가 아니면 Invalid (버림)
if (CountSolutions((int[,])puzzle.Clone()) != 1) return Difficulty.Invalid;
// --- 여기서부터 사람의 논리를 모방 ---
int emptyCount = 0;
foreach (int val in puzzle) if (val == 0) emptyCount++;
// 임시 난이도 판별 로직 (빈칸 개수로 간이 판별)
// 실제로는 여기에 Naked Single 등을 사용한 논리 판별 코드가 들어갑니다.
if (emptyCount < 45) return Difficulty.Easy;
if (emptyCount < 50) return Difficulty.Medium;
return Difficulty.Hard;
}
// 유틸리티 함수들
private bool IsValid(int[,] b, int r, int c, int num)
{
for (int i = 0; i < 9; i++)
{
if (b[r, i] == num || b[i, c] == num) return false;
}
int startRow = r - r % 3, startCol = c - c % 3;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
if (b[i + startRow, j + startCol] == num) return false;
return true;
}
private void Shuffle(List<int> list)
{
for (int i = 0; i < list.Count; i++)
{
int temp = list[i];
int randomIndex = rand.Next(i, list.Count);
list[i] = list[randomIndex];
list[randomIndex] = temp;
}
}
private int CountSolutions(int[,] b)
{
for (int r = 0; r < 9; r++)
{
for (int c = 0; c < 9; c++)
{
if (b[r, c] == 0)
{
int count = 0;
for (int num = 1; num <= 9; num++)
{
if (IsValid(b, r, c, num))
{
b[r, c] = num;
count += CountSolutions(b);
b[r, c] = 0;
}
}
return count;
}
}
}
return 1;
}
}
그리고 논리적으로 스도쿠 문제를 만들 로직 스크립트 하나
using UnityEngine;
using UnityEditor;
using System.IO;
public class SudokuEditorWindow : EditorWindow
{
private int targetPerDifficulty = 33; // Easy 33개, Medium 33개, Hard 33개
[MenuItem("Sudoku/Generate Database")]
public static void ShowWindow()
{
GetWindow<SudokuEditorWindow>("Sudoku Generator");
}
void OnGUI()
{
GUILayout.Label("Sudoku Database Generator", EditorStyles.boldLabel);
targetPerDifficulty = EditorGUILayout.IntField("Count Per Difficulty", targetPerDifficulty);
if (GUILayout.Button("Generate and Save JSON"))
{
GenerateDatabase();
}
}
private void GenerateDatabase()
{
SudokuDatabase db = new SudokuDatabase();
SudokuGenerator generator = new SudokuGenerator();
int easyCount = 0, mediumCount = 0, hardCount = 0;
int totalTarget = targetPerDifficulty * 3;
EditorUtility.DisplayProgressBar("Generating Puzzles", "Starting...", 0f);
// 목표 개수를 채울 때까지 무한 반복
while (easyCount < targetPerDifficulty || mediumCount < targetPerDifficulty || hardCount < targetPerDifficulty)
{
int[,] fullBoard = generator.GenerateFullBoard();
int[,] puzzleBoard;
SudokuGenerator.Difficulty diff = generator.CreatePuzzle(fullBoard, out puzzleBoard);
if (diff == SudokuGenerator.Difficulty.Invalid) continue; // 엉터리 퍼즐은 버림
bool saved = false;
if (diff == SudokuGenerator.Difficulty.Easy && easyCount < targetPerDifficulty)
{
easyCount++; saved = true;
}
else if (diff == SudokuGenerator.Difficulty.Medium && mediumCount < targetPerDifficulty)
{
mediumCount++; saved = true;
}
else if (diff == SudokuGenerator.Difficulty.Hard && hardCount < targetPerDifficulty)
{
hardCount++; saved = true;
}
if (saved)
{
SudokuPuzzle puzzle = new SudokuPuzzle
{
difficulty = diff.ToString(),
problem = BoardToString(puzzleBoard),
solution = BoardToString(fullBoard)
};
db.puzzles.Add(puzzle);
float progress = (float)(easyCount + mediumCount + hardCount) / totalTarget;
EditorUtility.DisplayProgressBar("Generating Puzzles", $"Generated {easyCount+mediumCount+hardCount}/{totalTarget}", progress);
}
}
EditorUtility.ClearProgressBar();
// JSON 파일로 저장
string json = JsonUtility.ToJson(db, true);
string path = Application.dataPath + "/SudokuData.json";
File.WriteAllText(path, json);
AssetDatabase.Refresh();
Debug.Log($"생성 완료! 총 {db.puzzles.Count}개의 퍼즐이 {path} 에 저장되었습니다.");
}
private string BoardToString(int[,] board)
{
string result = "";
for (int r = 0; r < 9; r++)
{
for (int c = 0; c < 9; c++)
{
result += board[r, c].ToString();
}
}
return result;
}
}
마지막으로 유니티 에디터 생성기
기존에 짠 스크립트를 기반으로 대량의 문제를 만들어 합친다음 하나의 Json 파일을 만들어준다
이 세번째 스크립트는 스크립트 폴더가 아니라 반드시 이름이 "Editor"인 폴더 안에 넣어주어야 한다


그럼 상단에 "Sudoku"라는 이름으로 하나의 패널이 더 생긴것을 확인할 수 있는데
이걸 클릭하여 빈칸에 숫자를 채워넣은뒤 Generate 버튼을 누르면...

짜잔! 이렇게 대량의 스도쿠 문제를 만들 수 있다!
자 그럼 이제 만든 문제들을 실제 게임화면에서 볼 수 있도록 해보자
using UnityEngine;
using UnityEngine.UI;
using TMPro; // TextMeshPro를 사용하기 위한 네임스페이스
public class GameManager : MonoBehaviour
{
[Header("Data")]
[Tooltip("에디터에서 SudokuData JSON 파일을 드래그 앤 드롭 하세요.")]
public TextAsset sudokuJson;
private SudokuDatabase database;
[Header("UI References")]
[Tooltip("Grid Layout Group 하위에 있는 81개의 버튼을 순서대로 넣으세요.")]
public Button[] cellButtons;
private void Start()
{
LoadDatabase();
// 테스트용: 게임 시작 시 0번째(첫 번째) 스테이지 로드
LoadStage(0);
}
// 1. JSON 파일 파싱 (데이터 로드)
private void LoadDatabase()
{
if (sudokuJson != null)
{
database = JsonUtility.FromJson<SudokuDatabase>(sudokuJson.text);
Debug.Log($"총 {database.puzzles.Count}개의 스테이지를 성공적으로 불러왔습니다.");
}
else
{
Debug.LogError("JSON 파일이 할당되지 않았습니다! Inspector 창을 확인해주세요.");
}
}
// 2. 선택한 스테이지 화면에 뿌리기
public void LoadStage(int stageIndex)
{
if (database == null || stageIndex >= database.puzzles.Count)
{
Debug.LogWarning("해당 스테이지 데이터가 존재하지 않습니다.");
return;
}
SudokuPuzzle currentPuzzle = database.puzzles[stageIndex];
string problemString = currentPuzzle.problem; // 81자리 문자열 (예: "0030206...")
// 81개의 버튼에 데이터 매핑
for (int i = 0; i < 81; i++)
{
char ch = problemString[i];
// 버튼 하위의 TextMeshPro 컴포넌트 가져오기
TextMeshProUGUI textComp = cellButtons[i].GetComponentInChildren<TextMeshProUGUI>();
if (ch == '0')
{
// [빈칸 처리] 플레이어가 맞춰야 하는 칸
textComp.text = "";
cellButtons[i].interactable = true; // 터치 가능하도록 활성화
textComp.color = new Color(0.2f, 0.4f, 0.8f); // 플레이어 입력용 색상 (파란색 계열)
}
else
{
// [고정 숫자 처리] 처음부터 주어지는 힌트 칸
textComp.text = ch.ToString();
cellButtons[i].interactable = false; // 터치 불가능하도록 비활성화
textComp.color = Color.black; // 기본 고정 글자색 (검은색)
}
}
}
}
간단하다. 81개의 버튼을 만든뒤 첨부한 Json 파일을 읽어와 그 안에 순서대로 채워넣게 하면 된다
그럼 그걸 하기전에 우선 버튼부터 만들어보자
그전에! 81개의 버튼을 언제 다 만들어서 언제 일일이 오와 열을 맞출것인가..
이럴때 아주 유용하게 쓸 수 있는 기능이 있다

우선 캔버스 이미지를 "Panel" 이라는 이름으로 하나 만들어준뒤 그 이미지에
"Grid Layout Group" 컴포넌트를 붙여주자
그리고 Panel 바로 밑, 자식 폴더에 81개의 버튼을 만들어준다

그럼 이렇게 버튼이 Panel 범위 안에서 일정하게 정렬이 되는것을 볼 수 있다
물론 바로 이렇게 되는건 아니고... Grid Layout Group 컴포넌트에서 Cell Size, Spacing 변수,
그리고 버튼 사이즈를 잘 조절해주면 이렇게 만들 수 있다
뒤에 패널 색상을 파란색으로 한 이유는 잘 보이기 위해서다. 추후에는 투명하게 할 예정

자 이러면 모든 준비는 끝났다
빈 오브젝트 안에 GameManager 스크립트 넣어주고
Json에 파일 넣어주고 버튼 81개를 만들어둔거 같이 넣어주고 게임을 실행해보자

으악!!! 글꼴이 너무 크다
using UnityEngine;
using UnityEngine.UI;
using TMPro; // TextMeshPro를 사용하기 위한 네임스페이스
public class GameManager : MonoBehaviour
{
......
// ✅ 여기에 폰트 크기를 조절할 변수를 추가합니다.
[Header("UI Settings")]
[Tooltip("생성될 숫자 폰트의 크기를 설정하세요.")]
public float fontSize = 45f;
// 2. 선택한 스테이지 화면에 뿌리기
public void LoadStage(int stageIndex)
{
....
// ✅ 스크립트 상단에서 설정한 폰트 크기를 적용합니다.
textComp.fontSize = fontSize;
}
}
}
}
폰트 크기를 조절할 수 있는 코드를 추가해주었다

와 이제 제법 그럴싸하다 이제 슬슬 내가 알고 있는 스도쿠 모양이 나오는것 같다!
이제 이 밑에 깔아줄 스도쿠 판을 만들어주자
시작화면 만들때 짜둔 코드가 있긴한데 그건 오브젝트에 속해있고 이건 UI에 속한 판으로 만들고 싶었다
왜냐면 정작 폰에서 빌드를 하면 해상도 차이에 따라 오브젝트와 UI의 위치가 맞지 않기 때문에...
항상 같은 목적에 쓰이는건 무조건 한쪽에 통일하는게 맞는것 같다
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(CanvasRenderer))]
public class SudokuUIGridGenerator : MaskableGraphic
{
[Header("Grid Settings")]
[Tooltip("가로 칸 수")] public int cols = 9;
[Tooltip("세로 칸 수")] public int rows = 9;
[Header("Line Widths")]
[Tooltip("일반 선 두께")] public float normalLineWidth = 2.0f;
[Tooltip("3x3 경계 굵은 선 두께")] public float thickLineWidth = 6.0f;
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
Rect r = rectTransform.rect;
float cellWidth = r.width / cols;
float cellHeight = r.height / rows;
// 1. 수직선 생성 (0부터 cols까지)
for (int i = 0; i <= cols; i++)
{
bool isThick = (i % 3 == 0);
float width = isThick ? thickLineWidth : normalLineWidth;
float x = r.xMin + i * cellWidth;
AddLineSegment(vh,
new Vector2(x - width * 0.5f, r.yMax),
new Vector2(x + width * 0.5f, r.yMax),
new Vector2(x + width * 0.5f, r.yMin),
new Vector2(x - width * 0.5f, r.yMin));
}
// 2. 수평선 생성 (맨 윗줄 포함)
for (int j = 0; j <= rows; j++)
{
// if (j == 0) continue; <--- 이 부분을 삭제하여 맨 윗줄도 그려지게 함
bool isThick = (j % 3 == 0); // j가 0일 때도 나머지가 0이므로 자동으로 굵은 선 적용됨
float width = isThick ? thickLineWidth : normalLineWidth;
float y = r.yMax - j * cellHeight;
AddLineSegment(vh,
new Vector2(r.xMin, y + width * 0.5f),
new Vector2(r.xMax, y + width * 0.5f),
new Vector2(r.xMax, y - width * 0.5f),
new Vector2(r.xMin, y - width * 0.5f));
}
}
void AddLineSegment(VertexHelper vh, Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3)
{
int startIndex = vh.currentVertCount;
UIVertex vert = UIVertex.simpleVert;
vert.color = this.color;
vert.position = p0; vh.AddVert(vert);
vert.position = p1; vh.AddVert(vert);
vert.position = p2; vh.AddVert(vert);
vert.position = p3; vh.AddVert(vert);
vh.AddTriangle(startIndex + 0, startIndex + 1, startIndex + 2);
vh.AddTriangle(startIndex + 2, startIndex + 3, startIndex + 0);
}
}
그래서 UI용 코드를 하나 더 짜주었고 이번엔 시작화면과 달리 맨 윗줄도 지우지 않게 해주었다

그렇게 판까지 끼워주면 이제 정말 내가 알던 스도쿠에 매우 가까워진게 보인다
'3. Sudoku' 카테고리의 다른 글
| #1 시작화면 (Hinge Joint) (1) | 2026.08.03 |
|---|