first commit

This commit is contained in:
2026-09-16 13:32:45 +09:00
commit 9d0654bab8
1136 changed files with 58955 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
/**
* 십자말풀이 스테이지 관리 도구 - Google Apps Script
*/
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('🧩 십자말풀이 관리')
.addItem('1. stages.json 생성 및 검증', 'exportStagesJson')
.addSeparator()
.addItem('2. 샘플 데이터 입력', 'populateSampleData')
.addToUi();
}
/**
* 시트 데이터를 읽어 검증 후 JSON 모달 창을 띄웁니다.
*/
function exportStagesJson() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const data = sheet.getDataRange().getValues();
if (data.length < 2) {
SpreadsheetApp.getUi().alert('데이터가 없습니다. 먼저 스테이지 데이터를 입력해 주세요.');
return;
}
const stagesMap = new Map();
const errors = [];
// 2행부터 데이터 파싱
for (let i = 1; i < data.length; i++) {
const row = data[i];
const rowNum = i + 1;
const stageNumber = parseInt(row[0]);
if (isNaN(stageNumber)) continue;
const title = String(row[1] || '').trim();
const gridSize = parseInt(row[2]) || 6;
const difficulty = parseInt(row[3]) || 1;
const clueId = String(row[4] || `c${stageNumber}_${row[5]}`).trim();
const number = parseInt(row[5]);
const direction = String(row[6] || 'ACROSS').trim().toUpperCase();
const word = String(row[7] || '').trim().replace(/\s+/g, '');
const clue = String(row[8] || '').trim();
const startRow = parseInt(row[9]);
const startCol = parseInt(row[10]);
const length = parseInt(row[11]) || word.length;
// 1. 유효성 검사
if (word.length !== length) {
errors.push(`[${rowNum}행] '${word}'의 글자 수(${word.length})와 length(${length})가 불일치합니다.`);
}
if (direction === 'ACROSS') {
if (startCol + length > gridSize) {
errors.push(`[${rowNum}행 Stage ${stageNumber}] '${word}' 가로 단어가 gridSize(${gridSize}) 범위를 벗어납니다. (startCol: ${startCol} + length: ${length} = ${startCol + length})`);
}
} else if (direction === 'DOWN') {
if (startRow + length > gridSize) {
errors.push(`[${rowNum}행 Stage ${stageNumber}] '${word}' 세로 단어가 gridSize(${gridSize}) 범위를 벗어납니다. (startRow: ${startRow} + length: ${length} = ${startRow + length})`);
}
}
if (!stagesMap.has(stageNumber)) {
stagesMap.set(stageNumber, {
stageNumber: stageNumber,
title: title,
gridSize: gridSize,
difficulty: difficulty,
clues: []
});
}
stagesMap.get(stageNumber).clues.push({
id: clueId,
number: number,
direction: direction,
word: word,
clue: clue,
startRow: startRow,
startCol: startCol,
length: length
});
}
// 2. 스테이지별 교차점 글자 충돌 검사
stagesMap.forEach((stage, stageNum) => {
const gridMap = new Map();
stage.clues.forEach(clue => {
for (let idx = 0; idx < clue.word.length; idx++) {
const r = clue.direction === 'ACROSS' ? clue.startRow : clue.startRow + idx;
const c = clue.direction === 'ACROSS' ? clue.startCol + idx : clue.startCol;
const key = `${r},${c}`;
const char = clue.word[idx];
if (gridMap.has(key)) {
const existing = gridMap.get(key);
if (existing.char !== char) {
errors.push(`[Stage ${stageNum}] 교차점 (${r}, ${c}) 글자 충돌: '${existing.word}'의 '${existing.char}' vs '${clue.word}'의 '${char}'`);
}
} else {
gridMap.set(key, { char: char, word: clue.word });
}
}
});
});
const stagesArray = Array.from(stagesMap.values());
const jsonString = JSON.stringify(stagesArray, null, 2);
// 모달 팝업 표시
showJsonDialog(jsonString, errors);
}
/**
* JSON 결과 및 복사 버튼 팝업 UI
*/
function showJsonDialog(jsonString, errors) {
let errorHtml = '';
if (errors.length > 0) {
errorHtml = `
<div style="background:#FFEBEE; border:1px solid #EF5350; padding:10px; border-radius:6px; margin-bottom:12px; color:#C62828;">
<b>⚠️ 검증 오류 발견 (${errors.length}건):</b><br/>
<ul style="margin:5px 0 0 15px; padding:0; font-size:12px;">
${errors.map(e => `<li>${e}</li>`).join('')}
</ul>
</div>
`;
} else {
errorHtml = `
<div style="background:#E8F5E9; border:1px solid #66BB6A; padding:10px; border-radius:6px; margin-bottom:12px; color:#2E7D32;">
<b>✅ 모든 데이터 및 교차점 검증 완료 (오류 없음)</b>
</div>
`;
}
const html = `
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
body { font-family: sans-serif; margin: 15px; }
textarea { width: 100%; height: 350px; font-family: monospace; font-size: 11px; padding: 8px; box-sizing: border-box; }
.btn { background: #4F46E5; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: bold; }
.btn:hover { background: #4338CA; }
</style>
</head>
<body>
${errorHtml}
<textarea id="jsonArea" readonly>${jsonString}</textarea>
<div style="margin-top: 10px; display: flex; justify-content: space-between; align-items: center;">
<button class="btn" onclick="copyJson()">📋 클립보드에 전체 복사</button>
<span id="copyMsg" style="color: #4F46E5; font-size: 12px; font-weight: bold;"></span>
</div>
<script>
function copyJson() {
const copyText = document.getElementById("jsonArea");
copyText.select();
document.execCommand("copy");
document.getElementById("copyMsg").innerText = "복사되었습니다! Firebase 또는 파일에 붙여넣으세요.";
}
</script>
</body>
</html>
`;
const htmlOutput = HtmlService.createHtmlOutput(html)
.setWidth(680)
.setHeight(520);
SpreadsheetApp.getUi().showModalDialog(htmlOutput, 'stages.json 내보내기');
}
/**
* 1~4 스테이지 샘플 데이터를 시트에 자동으로 채워줍니다.
*/
function populateSampleData() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
sheet.clear();
const headers = [
'stageNumber', 'title', 'gridSize', 'difficulty',
'clueId', 'number', 'direction', 'word', 'clue', 'startRow', 'startCol', 'length'
];
const sampleRows = [
[1, '첫걸음 (자연과 일상)', 6, 1, 'c1_1', 1, 'ACROSS', '가을하늘', '가을철의 맑고 푸른 하늘.', 1, 0, 4],
[1, '첫걸음 (자연과 일상)', 6, 1, 'c1_2', 2, 'DOWN', '하늘나라', '천국을 이르는 말.', 1, 2, 4],
[1, '첫걸음 (자연과 일상)', 6, 1, 'c1_3', 3, 'ACROSS', '나비효과', '미세한 변화가 엄청난 결과를 낳는 현상.', 3, 2, 4],
[2, '동네 한바퀴', 6, 1, 'c2_1', 1, 'ACROSS', '과일가게', '여러 가지 과일을 파는 상점.', 0, 1, 4],
[2, '동네 한바퀴', 6, 1, 'c2_2', 2, 'DOWN', '가면무도회', '참가자가 가면을 쓰고 춤을 추는 연회.', 0, 3, 5],
[2, '동네 한바퀴', 6, 1, 'c2_3', 3, 'ACROSS', '무지개', '비 갠 하늘에 나타나는 일곱 빛깔 호선.', 2, 3, 3],
[2, '동네 한바퀴', 6, 1, 'c2_4', 4, 'DOWN', '개구쟁이', '궂은 장난을 자주 치는 귀여운 아이.', 2, 5, 4],
[3, '사자성어와 지혜', 6, 2, 'c3_1', 1, 'ACROSS', '일석이조', '돌 하나로 새 두 마리를 잡는다는 사자성어.', 1, 0, 4],
[3, '사자성어와 지혜', 6, 2, 'c3_2', 2, 'DOWN', '조약돌', '물에 씻기어 둥글고 반드러워진 작은 돌.', 1, 3, 3],
[3, '사자성어와 지혜', 6, 2, 'c3_3', 3, 'ACROSS', '돌다리', '돌로 놓은 다리. 신중함을 뜻하는 속담 소재.', 3, 3, 3],
[3, '사자성어와 지혜', 6, 2, 'c3_4', 4, 'DOWN', '다락방', '지붕 바로 밑이나 천장 위에 꾸며 놓은 방.', 3, 4, 3],
[4, '문화와 예술', 6, 2, 'c4_1', 1, 'ACROSS', '방방곡곡', '전국 방방곡곡, 한 곳도 빠짐없는 모든 곳.', 1, 0, 4],
[4, '문화와 예술', 6, 2, 'c4_2', 2, 'DOWN', '곡예사', '서커스나 줄타기 따위 곡예를 하는 사람.', 1, 3, 3],
[4, '문화와 예술', 6, 2, 'c4_3', 3, 'ACROSS', '고사성어', '옛날 이야기나 고사에서 유래된 사자성어 및 숙어.', 3, 2, 4],
[4, '문화와 예술', 6, 2, 'c4_4', 4, 'DOWN', '어린이', '어린아이를 대접하여 부르는 말.', 3, 5, 3],
[4, '문화와 예술', 6, 2, 'c4_5', 5, 'ACROSS', '호랑이', '고양이과의 사나운 맹수, 산군.', 5, 3, 3]
];
sheet.getRange(1, 1, 1, headers.length).setValues([headers]).setFontWeight('bold').setBackground('#E0E7FF');
sheet.getRange(2, 1, sampleRows.length, headers.length).setValues(sampleRows);
sheet.autoResizeColumns(1, headers.length);
SpreadsheetApp.getUi().alert('샘플 데이터가 생성되었습니다!');
}