/** * 십자말풀이 스테이지 관리 도구 - 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 = `
⚠️ 검증 오류 발견 (${errors.length}건):
`; } else { errorHtml = `
✅ 모든 데이터 및 교차점 검증 완료 (오류 없음)
`; } const html = ` ${errorHtml}
`; 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('샘플 데이터가 생성되었습니다!'); }